Mikkel Manniche
Dansk English Deutsch

Bedienelemente

Tag picker

Tags aus einem Vorrat wählen. Jeder Tag fliegt ins Feld und beim Entfernen zurück.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

tag-picker-demo.tsx
import { TagPicker } from '@/components/tag-picker'

export default function TagPickerDemo() {
  return (
    <TagPicker
      label="Product tags"
      defaultValue={['linen']}
      options={[
        { id: 'linen', label: 'Linen' },
        { id: 'organic', label: 'Organic' },
        { id: 'handmade', label: 'Handmade' },
        { id: 'summer', label: 'Summer' },
        { id: 'gift', label: 'Gift idea' },
        { id: 'sale', label: 'On sale' },
        { id: 'new', label: 'New in' },
      ]}
    />
  )
}

Quellcode

tag-picker.tsx
// Based on Watermelon UI's “Tags” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as a controlled picker with labelled buttons.
import { X } from 'lucide-react'
import { LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useState } from 'react'
import { cn } from '@/lib/utils'

export type TagOption = { id: string; label: string }

export type TagPickerProps = {
  options: TagOption[]
  /** Controlled ids of the picked tags, in the order they were picked. */
  value?: string[]
  defaultValue?: string[]
  onChange?: (ids: string[]) => void
  label?: string
  /** Shown in the empty box before anything is picked. */
  placeholder?: string
  className?: string
}

/** Pick tags from a pool; each one flies up into the box and back down when removed. */
export function TagPicker({ options, value, defaultValue = [], onChange, label = 'Tags', placeholder = 'Pick a few below', className }: TagPickerProps) {
  const [own, setOwn] = useState(defaultValue)
  const picked = value ?? own
  const group = useId()
  const heading = useId()

  const set = (ids: string[]) => {
    setOwn(ids)
    onChange?.(ids)
  }

  const byId = new Map(options.map((o) => [o.id, o]))
  const pool = options.filter((o) => !picked.includes(o.id))

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 300, damping: 35 }}>
      <LayoutGroup id={group}>
        <div className={cn('w-full', className)} role="group" aria-labelledby={heading}>
          <p id={heading} className="text-sm font-medium">
            {label}
          </p>
          <motion.ul layout aria-label="Picked" className="mt-2 flex min-h-14 flex-wrap items-center gap-1.5 rounded-2xl border bg-card p-1.5">
            {picked.length === 0 && <li className="px-2.5 text-sm text-muted-foreground">{placeholder}</li>}
            {picked.map((id) => {
              const tag = byId.get(id)
              if (!tag) return null
              return (
                <motion.li key={id} layoutId={id} style={{ borderRadius: 12 }} className="z-20 flex items-center gap-0.5 border bg-background py-0.5 pr-0.5 pl-3 text-sm font-medium">
                  <motion.span layout="position">{tag.label}</motion.span>
                  <button
                    type="button"
                    onClick={() => set(picked.filter((p) => p !== id))}
                    aria-label={`Remove ${tag.label}`}
                    className="grid size-9 place-items-center rounded-[10px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
                  >
                    <X className="size-4" aria-hidden />
                  </button>
                </motion.li>
              )
            })}
          </motion.ul>
          {pool.length > 0 && (
            <motion.ul layout aria-label="Available" className="mt-3 flex flex-wrap gap-2 rounded-2xl border bg-card p-2">
              {pool.map((tag) => (
                <motion.li key={tag.id} layoutId={tag.id} style={{ borderRadius: 12 }} className="z-10 bg-muted">
                  <button
                    type="button"
                    onClick={() => set([...picked, tag.id])}
                    className="min-h-10 px-4 text-sm font-medium text-muted-foreground transition-colors duration-150 hover:text-foreground"
                  >
                    <motion.span layout="position" className="inline-block">
                      {tag.label}
                    </motion.span>
                  </button>
                </motion.li>
              ))}
            </motion.ul>
          )}
        </div>
      </LayoutGroup>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/tag-picker.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/tag-picker

Dateien

  • tag-picker.tsx