{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "type": "registry:component",
  "title": "Tag Input",
  "description": "Type to add tags with grouped suggestions, paste a comma list to add a bunch at once, and backspace to pull the last one back for editing. Each group keeps its own colour.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/tag-input.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { PlusIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useAnimationControls, useReducedMotion } from \"motion/react\";\nimport {\n  type ClipboardEvent,\n  type KeyboardEvent,\n  type ReactNode,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst COLORS = {\n  violet: { chip: \"bg-violet-500/12 text-violet-700 ring-violet-500/20 dark:text-violet-300\", dot: \"bg-violet-500\" },\n  sky: { chip: \"bg-sky-500/12 text-sky-700 ring-sky-500/20 dark:text-sky-300\", dot: \"bg-sky-500\" },\n  amber: { chip: \"bg-amber-500/14 text-amber-800 ring-amber-500/24 dark:text-amber-300\", dot: \"bg-amber-500\" },\n  rose: { chip: \"bg-rose-500/12 text-rose-700 ring-rose-500/20 dark:text-rose-300\", dot: \"bg-rose-500\" },\n  emerald: { chip: \"bg-emerald-500/12 text-emerald-700 ring-emerald-500/20 dark:text-emerald-300\", dot: \"bg-emerald-500\" },\n  neutral: { chip: \"bg-muted text-foreground ring-border\", dot: \"bg-muted-foreground\" },\n} as const;\n\nexport type TagColor = keyof typeof COLORS;\n\nexport interface TagGroup {\n  id: string;\n  label: string;\n  color: TagColor;\n  options: string[];\n}\n\nexport interface Tag {\n  value: string;\n  group?: string;\n}\n\nexport interface TagInputProps {\n  value?: Tag[];\n  defaultValue?: Tag[];\n  onValueChange?: (tags: Tag[]) => void;\n  groups?: TagGroup[];\n  label?: ReactNode;\n  placeholder?: string;\n  allowCustom?: boolean;\n  max?: number;\n  className?: string;\n}\n\nconst normalise = (s: string) => s.trim().replace(/\\s+/g, \" \");\nconst same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();\n\nexport function TagInput({\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  groups = [],\n  label = \"Tags\",\n  placeholder = \"Add a tag…\",\n  allowCustom = true,\n  max,\n  className,\n}: TagInputProps) {\n  const [inner, setInner] = useState<Tag[]>(defaultValue);\n  const tags = valueProp ?? inner;\n  const [query, setQuery] = useState(\"\");\n  const [open, setOpen] = useState(false);\n  const [active, setActive] = useState(0);\n  const [shaking, setShaking] = useState<string | null>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const id = useId();\n  const listId = `${id}-list`;\n  const hintId = `${id}-hint`;\n  const full = max !== undefined && tags.length >= max;\n\n  const groupOf = (tag: Tag) => groups.find((g) => g.id === tag.group);\n  const findOption = (text: string) => {\n    for (const g of groups) {\n      const hit = g.options.find((o) => same(o, text));\n      if (hit) return { value: hit, group: g.id };\n    }\n    return null;\n  };\n\n  const q = normalise(query);\n  const sections = useMemo(() => {\n    const taken = (o: string) => tags.some((t) => same(t.value, o));\n    return groups\n      .map((g) => {\n        const options = g.options\n          .filter((o) => !taken(o) && (!q || o.toLowerCase().includes(q.toLowerCase())))\n          .sort((a, b) => Number(!a.toLowerCase().startsWith(q.toLowerCase())) - Number(!b.toLowerCase().startsWith(q.toLowerCase())));\n        return { group: g, options };\n      })\n      .filter((s) => s.options.length);\n  }, [groups, tags, q]);\n\n  const exact = q ? findOption(q) : null;\n  const canCreate = allowCustom && q.length > 0 && !exact && !tags.some((t) => same(t.value, q));\n  const flat = [\n    ...sections.flatMap((s) => s.options.map((o) => ({ value: o, group: s.group.id }) as Tag)),\n    ...(canCreate ? [{ value: q } as Tag] : []),\n  ];\n  const showList = open && !full && flat.length > 0;\n\n  useEffect(() => setActive(0), [q]);\n  useEffect(() => {\n    listRef.current?.querySelector(`[data-index=\"${active}\"]`)?.scrollIntoView({ block: \"nearest\" });\n  }, [active]);\n  useEffect(() => {\n    if (!shaking) return;\n    const t = window.setTimeout(() => setShaking(null), 400);\n    return () => window.clearTimeout(t);\n  }, [shaking]);\n\n  const commit = (next: Tag[]) => {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const add = (incoming: Tag[]) => {\n    let next = tags;\n    for (const raw of incoming) {\n      const text = normalise(raw.value);\n      if (!text) continue;\n      const existing = next.find((t) => same(t.value, text));\n      if (existing) {\n        setShaking(existing.value);\n        continue;\n      }\n      if (max !== undefined && next.length >= max) break;\n      const known = raw.group ? { value: text, group: raw.group } : findOption(text);\n      if (!known && !allowCustom) continue;\n      next = [...next, known ?? { value: text }];\n    }\n    if (next !== tags) commit(next);\n    setQuery(\"\");\n  };\n\n  const remove = (tag: Tag) => {\n    commit(tags.filter((t) => t !== tag));\n    inputRef.current?.focus();\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      setOpen(true);\n      if (flat.length) setActive((a) => (a + (event.key === \"ArrowDown\" ? 1 : -1) + flat.length) % flat.length);\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      if (showList && flat[active]) add([flat[active]]);\n      else if (q) add([{ value: q }]);\n    } else if (event.key === \"Tab\" && q && showList && flat[active]) {\n      event.preventDefault();\n      add([flat[active]]);\n    } else if (event.key === \",\" || event.key === \";\") {\n      event.preventDefault();\n      if (q) add([exact ?? { value: q }]);\n    } else if (event.key === \"Backspace\" && !query && tags.length) {\n      event.preventDefault();\n      const last = tags[tags.length - 1];\n      commit(tags.slice(0, -1));\n      setQuery(last.value);\n      setOpen(true);\n    } else if (event.key === \"Escape\") {\n      setOpen(false);\n    }\n  };\n\n  const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {\n    const text = event.clipboardData.getData(\"text\");\n    if (!/[,;\\n\\t]/.test(text)) return;\n    event.preventDefault();\n    add(text.split(/[,;\\n\\t]+/).map((value) => ({ value })));\n  };\n\n  let index = -1;\n  const option = (tag: Tag, content: ReactNode) => {\n    index += 1;\n    const i = index;\n    return (\n      <div\n        aria-selected={i === active}\n        className=\"flex h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 text-sm data-[active=true]:bg-accent\"\n        data-active={i === active}\n        data-index={i}\n        id={`${id}-opt-${i}`}\n        key={`${tag.group ?? \"new\"}-${tag.value}`}\n        onMouseDown={(event) => event.preventDefault()}\n        onClick={() => add([tag])}\n        onMouseMove={() => setActive(i)}\n        role=\"option\"\n      >\n        {content}\n      </div>\n    );\n  };\n\n  return (\n    <div className={cn(\"relative grid w-full gap-1.5\", className)}>\n      <div className=\"flex items-baseline justify-between gap-2\">\n        <label className=\"font-medium text-sm\" htmlFor={`${id}-input`}>\n          {label}\n        </label>\n        {max !== undefined && (\n          <span className={cn(\"text-muted-foreground text-xs tabular-nums\", full && \"text-warning-foreground\")}>\n            {tags.length}/{max}\n          </span>\n        )}\n      </div>\n      <div className=\"relative\">\n      <div\n        className=\"flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-input bg-background not-dark:bg-clip-padding p-1 shadow-xs/5 ring-ring/24 transition-shadow focus-within:border-ring focus-within:ring-[3px] dark:bg-input/32\"\n        onClick={() => inputRef.current?.focus()}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {tags.map((tag) => (\n            <Chip\n              color={groupOf(tag)?.color ?? \"neutral\"}\n              key={tag.value.toLowerCase()}\n              onRemove={() => remove(tag)}\n              reduce={!!reduce}\n              shake={shaking === tag.value}\n              tag={tag}\n            />\n          ))}\n        </AnimatePresence>\n        <input\n          aria-activedescendant={showList ? `${id}-opt-${active}` : undefined}\n          aria-autocomplete=\"list\"\n          aria-controls={listId}\n          aria-describedby={hintId}\n          aria-expanded={showList}\n          autoComplete=\"off\"\n          className=\"h-7 min-w-12 flex-1 bg-transparent px-1.5 text-base outline-none placeholder:text-muted-foreground/72 disabled:cursor-not-allowed sm:text-sm\"\n          id={`${id}-input`}\n          onBlur={() => setOpen(false)}\n          onChange={(event) => {\n            setQuery(event.target.value);\n            setOpen(true);\n          }}\n          onFocus={() => setOpen(true)}\n          onKeyDown={onKeyDown}\n          onPaste={onPaste}\n          placeholder={full ? \"Limit reached\" : tags.length ? \"\" : placeholder}\n          ref={inputRef}\n          role=\"combobox\"\n          value={query}\n        />\n      </div>\n      <AnimatePresence>\n        {showList && (\n          <motion.div\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            className=\"absolute inset-x-0 top-full z-20 mt-1 max-h-60 origin-top overflow-y-auto rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg/5\"\n            exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.1 } }}\n            id={listId}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: -4 }}\n            ref={listRef}\n            role=\"listbox\"\n            transition={{ duration: 0.14, ease: [0.23, 1, 0.32, 1] }}\n          >\n            {sections.map(({ group, options }) => (\n              <div key={group.id} role=\"presentation\">\n                <div className=\"px-2 pt-1.5 pb-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide\">{group.label}</div>\n                {options.map((o) =>\n                  option(\n                    { value: o, group: group.id },\n                    <>\n                      <span className={cn(\"size-2 shrink-0 rounded-full\", COLORS[group.color].dot)} />\n                      <Highlight query={q} text={o} />\n                    </>,\n                  ),\n                )}\n              </div>\n            ))}\n            {canCreate && (\n              <div className={cn(sections.length > 0 && \"mt-1 border-t pt-1\")} role=\"presentation\">\n                {option(\n                  { value: q },\n                  <>\n                    <PlusIcon className=\"size-3.5 shrink-0 text-muted-foreground\" />\n                    <span className=\"truncate\">\n                      Create <span className=\"font-medium\">“{q}”</span>\n                    </span>\n                  </>,\n                )}\n              </div>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n      </div>\n      <p className=\"text-pretty text-muted-foreground text-xs\" id={hintId}>\n        Enter or comma adds, Backspace edits the last. Paste a list to add many.\n      </p>\n    </div>\n  );\n}\n\nfunction Chip({\n  tag,\n  color,\n  shake,\n  reduce,\n  onRemove,\n}: {\n  tag: Tag;\n  color: TagColor;\n  shake: boolean;\n  reduce: boolean;\n  onRemove: () => void;\n}) {\n  const controls = useAnimationControls();\n  useEffect(() => {\n    if (shake && !reduce) controls.start({ x: [0, -4, 4, -2, 0], transition: { duration: 0.32 } });\n  }, [shake, reduce, controls]);\n\n  return (\n    <motion.span\n      animate={{ opacity: 1, scale: 1 }}\n      className={cn(\"inline-flex h-7 max-w-full items-center gap-1 rounded-[calc(var(--radius-lg)-5px)] pr-1 pl-2 font-medium text-xs ring-1 ring-inset\", COLORS[color].chip)}\n      exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.12 } }}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n      layout={!reduce}\n      transition={{ type: \"spring\", stiffness: 600, damping: 38 }}\n    >\n      <motion.span animate={controls} className=\"truncate\">\n        {tag.value}\n      </motion.span>\n      <button\n        aria-label={`Remove ${tag.value}`}\n        className=\"relative flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm opacity-60 outline-none transition-opacity after:absolute after:-inset-1.5 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring\"\n        onClick={(event) => {\n          event.stopPropagation();\n          onRemove();\n        }}\n        type=\"button\"\n      >\n        <XIcon className=\"size-3\" />\n      </button>\n    </motion.span>\n  );\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n  if (at < 0) return <span className=\"truncate\">{text}</span>;\n  return (\n    <span className=\"truncate\">\n      {text.slice(0, at)}\n      <span className=\"font-semibold\">{text.slice(at, at + query.length)}</span>\n      {text.slice(at + query.length)}\n    </span>\n  );\n}\n"
    }
  ]
}