Kontroller
Tag picker
Vælg tags fra en pulje. Hvert tag flyver op i feltet og tilbage, når det fjernes.
Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.
Forhåndsvisning
Brug
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' },
]}
/>
)
}Kilde
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>
)
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/tag-picker.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/tag-picker