{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-input","type":"registry:component","title":"Tag Input","description":"Chip field where Enter, comma or Tab adds a tag that springs in while the rest glide aside via shared layout, with Backspace-twice removal, arrow-key chip navigation, a filtered keyboard-navigable suggestion combobox, max tags, duplicate prevention and a shake on validation rejection; controlled/uncontrolled, reduced-motion safe.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/tag-input.tsx","type":"registry:component","target":"@components/motion/tag-input.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/tag-input\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ClipboardEvent,\n  type KeyboardEvent,\n  type MouseEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TagInputProps {\n  /** Controlled tags. */\n  value?: string[];\n  /** Uncontrolled initial tags. */\n  defaultValue?: string[];\n  onChange?: (tags: string[]) => void;\n  /** Optional suggestion pool, filtered by the typed text. Turns the input into a combobox. */\n  suggestions?: string[];\n  /** Most suggestions shown at once. Default 6. */\n  maxSuggestions?: number;\n  /** Refuse new tags once this many exist. */\n  maxTags?: number;\n  /**\n   * Return `false` to reject a tag, or a string to reject it with that message.\n   * Rejection shakes the field.\n   */\n  validate?: (tag: string, tags: string[]) => boolean | string;\n  /** Allow the same tag twice (case-insensitive). Default false. */\n  allowDuplicates?: boolean;\n  placeholder?: string;\n  label?: string;\n  /** Helper text under the field while there is no error. */\n  hint?: string;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\ntype PendingFocus = number | \"input\" | null;\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\n\n/**\n * Chips inside an input-like field. Enter, comma or Tab adds; Backspace on an\n * empty input arms the last chip, a second press removes it; Arrow Left/Right\n * walks the chips and Delete/Backspace removes the focused one.\n */\nexport function TagInput({\n  value: valueProp,\n  defaultValue,\n  onChange,\n  suggestions,\n  maxSuggestions = 6,\n  maxTags,\n  validate,\n  allowDuplicates = false,\n  placeholder = \"Add a tag…\",\n  label,\n  hint,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  className,\n}: TagInputProps) {\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const listId = `${uid}-list`;\n  const messageId = `${uid}-message`;\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState<string[]>(defaultValue ?? []);\n  const tags = controlled ? valueProp : internal;\n\n  const [query, setQuery] = useState(\"\");\n  const [focused, setFocused] = useState(false);\n  // Backspace on an empty input arms the last chip first, so one stray press\n  // never deletes work.\n  const [armed, setArmed] = useState(false);\n  const [open, setOpen] = useState(false);\n  const [active, setActive] = useState(-1);\n  const [error, setError] = useState<string | null>(null);\n  const [flash, setFlash] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const chipRefs = useRef(new Map<string, HTMLButtonElement>());\n  const pendingFocus = useRef<PendingFocus>(null);\n  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Stable keys that survive duplicates: the nth occurrence of a tag.\n  const keys = useMemo(() => {\n    const seen = new Map<string, number>();\n    return tags.map((tag) => {\n      const n = seen.get(tag) ?? 0;\n      seen.set(tag, n + 1);\n      return `${tag}#${n}`;\n    });\n  }, [tags]);\n\n  const filtered = useMemo(() => {\n    if (!suggestions?.length) return [];\n    const q = query.trim().toLowerCase();\n    const taken = new Set(tags.map((t) => t.toLowerCase()));\n    return suggestions\n      .filter(\n        (s) =>\n          (allowDuplicates || !taken.has(s.toLowerCase())) &&\n          (!q || s.toLowerCase().includes(q)),\n      )\n      .slice(0, maxSuggestions);\n  }, [suggestions, query, tags, allowDuplicates, maxSuggestions]);\n\n  const listOpen = open && focused && filtered.length > 0 && !disabled;\n  const activeIndex = listOpen && active < filtered.length ? active : -1;\n  const atLimit = maxTags !== undefined && tags.length >= maxTags;\n\n  // Focus lands after the render that re-indexes the chips.\n  useEffect(() => {\n    const target = pendingFocus.current;\n    if (target === null) return;\n    pendingFocus.current = null;\n    if (target === \"input\") inputRef.current?.focus();\n    else chipRefs.current.get(keys[target] ?? \"\")?.focus();\n  }, [keys]);\n\n  useEffect(\n    () => () => {\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n    },\n    [],\n  );\n\n  const commit = (next: string[]) => {\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  const reject = (message: string, existing?: string) => {\n    setError(message);\n    if (fieldRef.current && !reduce) {\n      animate(fieldRef.current, SHAKE, { duration: 0.4, ease: EASE_OUT });\n    }\n    if (existing) {\n      setFlash(existing);\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n      flashTimer.current = setTimeout(() => setFlash(null), 700);\n    }\n  };\n\n  /** Adds every acceptable entry in one commit; stops at the first rejection. */\n  const addMany = (entries: string[]) => {\n    const next = [...tags];\n    let added = 0;\n    for (const raw of entries) {\n      const tag = raw.trim();\n      if (!tag) continue;\n      if (maxTags !== undefined && next.length >= maxTags) {\n        reject(`Up to ${maxTags} tags`);\n        break;\n      }\n      if (!allowDuplicates) {\n        const existing = next.find((t) => t.toLowerCase() === tag.toLowerCase());\n        if (existing) {\n          reject(`“${tag}” is already added`, existing);\n          break;\n        }\n      }\n      const verdict = validate?.(tag, next);\n      if (verdict === false || typeof verdict === \"string\") {\n        reject(typeof verdict === \"string\" ? verdict : `“${tag}” isn't allowed`);\n        break;\n      }\n      next.push(tag);\n      added++;\n    }\n    if (added === 0) return false;\n    commit(next);\n    setError(null);\n    setArmed(false);\n    setActive(-1);\n    setAnnouncement(\n      added === 1 ? `Added ${next[next.length - 1]}` : `Added ${added} tags`,\n    );\n    return true;\n  };\n\n  const add = (raw: string) => {\n    if (addMany([raw])) setQuery(\"\");\n  };\n\n  const removeAt = (index: number, focusAfter: PendingFocus) => {\n    const removed = tags[index];\n    if (removed === undefined) return;\n    pendingFocus.current = focusAfter;\n    commit(tags.filter((_, i) => i !== index));\n    setArmed(false);\n    setError(null);\n    setAnnouncement(`Removed ${removed}`);\n  };\n\n  const focusChip = useCallback(\n    (index: number) => {\n      chipRefs.current.get(keys[index] ?? \"\")?.focus();\n    },\n    [keys],\n  );\n\n  const onInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || e.nativeEvent.isComposing) return;\n    const trimmed = query.trim();\n    switch (e.key) {\n      case \"ArrowDown\":\n        if (!filtered.length) return;\n        e.preventDefault();\n        setOpen(true);\n        setActive((a) => (listOpen ? (a + 1) % filtered.length : 0));\n        return;\n      case \"ArrowUp\":\n        if (!listOpen) return;\n        e.preventDefault();\n        setActive((a) => (a <= 0 ? filtered.length - 1 : a - 1));\n        return;\n      case \"Escape\":\n        if (listOpen) {\n          e.preventDefault();\n          setOpen(false);\n          setActive(-1);\n        } else if (armed) {\n          setArmed(false);\n        }\n        return;\n      case \"Enter\":\n        if (activeIndex >= 0) {\n          e.preventDefault();\n          add(filtered[activeIndex]);\n        } else if (trimmed) {\n          e.preventDefault();\n          add(query);\n        }\n        return;\n      case \",\":\n        e.preventDefault();\n        if (trimmed) add(query);\n        return;\n      case \"Tab\":\n        if (e.shiftKey) return;\n        // Tab only completes when there is something to complete; on an empty\n        // input it moves focus on as usual.\n        if (activeIndex >= 0) {\n          e.preventDefault();\n          add(filtered[activeIndex]);\n        } else if (trimmed) {\n          e.preventDefault();\n          add(query);\n        }\n        return;\n      case \"Backspace\":\n        if (query !== \"\" || !tags.length) return;\n        e.preventDefault();\n        if (armed) removeAt(tags.length - 1, \"input\");\n        else setArmed(true);\n        return;\n      case \"ArrowLeft\": {\n        const el = e.currentTarget;\n        if (!tags.length || el.selectionStart !== 0 || el.selectionEnd !== 0) {\n          return;\n        }\n        e.preventDefault();\n        setArmed(false);\n        focusChip(tags.length - 1);\n        return;\n      }\n      default:\n        if (armed) setArmed(false);\n    }\n  };\n\n  const onInputChange = (next: string) => {\n    setError(null);\n    setArmed(false);\n    setOpen(true);\n    setActive(-1);\n    // Soft keyboards often report \"Unidentified\" for the comma key, so the\n    // delimiter is also honoured here.\n    if (next.includes(\",\")) {\n      const parts = next.split(\",\");\n      const rest = parts.pop() ?? \"\";\n      if (addMany(parts)) setQuery(rest);\n      else setQuery(next.replace(/,/g, \"\"));\n      return;\n    }\n    setQuery(next);\n  };\n\n  const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {\n    const text = e.clipboardData.getData(\"text\");\n    if (!/[,\\n\\t]/.test(text)) return;\n    e.preventDefault();\n    if (addMany(text.split(/[,\\n\\t]/))) setQuery(\"\");\n  };\n\n  const onChipKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    const last = tags.length - 1;\n    switch (e.key) {\n      case \"ArrowLeft\":\n        e.preventDefault();\n        focusChip(Math.max(index - 1, 0));\n        return;\n      case \"ArrowRight\":\n        e.preventDefault();\n        if (index < last) focusChip(index + 1);\n        else inputRef.current?.focus();\n        return;\n      case \"Home\":\n        e.preventDefault();\n        focusChip(0);\n        return;\n      case \"End\":\n      case \"Escape\":\n        e.preventDefault();\n        inputRef.current?.focus();\n        return;\n      case \"Backspace\":\n        e.preventDefault();\n        removeAt(index, index > 0 ? index - 1 : last > 0 ? 0 : \"input\");\n        return;\n      case \"Delete\":\n        e.preventDefault();\n        removeAt(index, index < last ? index : \"input\");\n        return;\n    }\n  };\n\n  // Clicks on the field's empty space land in the input, like a native field.\n  const onFieldMouseDown = (e: MouseEvent<HTMLDivElement>) => {\n    if (disabled || e.target !== e.currentTarget) return;\n    e.preventDefault();\n    inputRef.current?.focus();\n  };\n\n  const hasSuggestions = Boolean(suggestions?.length);\n  const message = error ?? hint;\n  const chipTransition = reduce ? { duration: 0.15 } : SPRING_SWAP;\n\n  return (\n    <div className={cn(\"relative flex w-full flex-col gap-1.5\", className)}>\n      {label ? (\n        <label\n          htmlFor={inputId}\n          className=\"px-1 text-sm font-medium text-foreground\"\n        >\n          {label}\n        </label>\n      ) : null}\n\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer convenience only — the input inside owns focus and keyboard. */}\n      <div\n        ref={fieldRef}\n        onMouseDown={onFieldMouseDown}\n        data-state={error ? \"error\" : focused ? \"focused\" : \"idle\"}\n        className={cn(\n          \"flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-2xl border border-border bg-background px-2 py-1.5 transition-colors duration-200\",\n          focused && !error && \"border-foreground/40 ring-2 ring-ring/40\",\n          error && \"border-destructive ring-2 ring-destructive/25\",\n          disabled && \"cursor-not-allowed opacity-60\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {tags.map((tag, i) => {\n            const key = keys[i];\n            const isArmed = armed && i === tags.length - 1;\n            const isFlash = flash === tag;\n            return (\n              <motion.span\n                key={key}\n                layout={!reduce}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                animate={{\n                  opacity: 1,\n                  scale: isFlash && !reduce ? [1, 1.12, 1] : 1,\n                }}\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: { duration: 0.1 } }\n                    : {\n                        opacity: 0,\n                        scale: 0.6,\n                        transition: { duration: 0.14, ease: EASE_OUT },\n                      }\n                }\n                transition={{\n                  default: chipTransition,\n                  layout: SPRING_LAYOUT,\n                  scale: isFlash\n                    ? { duration: 0.35, ease: EASE_OUT }\n                    : chipTransition,\n                }}\n                className={cn(\n                  \"inline-flex h-7 max-w-full items-center gap-1 rounded-full bg-muted pl-2.5 pr-1 text-sm text-foreground transition-colors duration-150\",\n                  \"focus-within:ring-2 focus-within:ring-ring\",\n                  (isArmed || isFlash) &&\n                    \"bg-destructive/15 text-destructive ring-1 ring-destructive/40\",\n                )}\n              >\n                <span className=\"truncate\">{tag}</span>\n                <button\n                  ref={(node) => {\n                    if (!node) return;\n                    chipRefs.current.set(key, node);\n                    return () => {\n                      if (chipRefs.current.get(key) === node) {\n                        chipRefs.current.delete(key);\n                      }\n                    };\n                  }}\n                  type=\"button\"\n                  tabIndex={-1}\n                  disabled={disabled}\n                  aria-label={`Remove ${tag}`}\n                  onKeyDown={(e) => onChipKeyDown(e, i)}\n                  onClick={() => removeAt(i, \"input\")}\n                  className=\"grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground\"\n                >\n                  <X className=\"size-3\" aria-hidden />\n                </button>\n              </motion.span>\n            );\n          })}\n\n          <motion.input\n            key=\"input\"\n            layout={!reduce ? \"position\" : false}\n            transition={{ layout: SPRING_LAYOUT }}\n            ref={inputRef}\n            id={inputId}\n            type=\"text\"\n            value={query}\n            disabled={disabled}\n            placeholder={atLimit ? \"\" : placeholder}\n            autoComplete=\"off\"\n            spellCheck={false}\n            aria-label={label ? undefined : (ariaLabel ?? \"Add tag\")}\n            aria-invalid={error ? true : undefined}\n            aria-describedby={message ? messageId : undefined}\n            role={hasSuggestions ? \"combobox\" : undefined}\n            aria-autocomplete={hasSuggestions ? \"list\" : undefined}\n            aria-expanded={hasSuggestions ? listOpen : undefined}\n            aria-controls={hasSuggestions && listOpen ? listId : undefined}\n            aria-activedescendant={\n              activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined\n            }\n            onChange={(e) => onInputChange(e.target.value)}\n            onKeyDown={onInputKeyDown}\n            onPaste={onPaste}\n            onFocus={() => {\n              setFocused(true);\n              setOpen(true);\n            }}\n            onBlur={() => {\n              setFocused(false);\n              setArmed(false);\n              setActive(-1);\n            }}\n            className=\"h-7 min-w-24 flex-1 bg-transparent px-1 text-sm text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed\"\n          />\n        </AnimatePresence>\n      </div>\n\n      <AnimatePresence>\n        {listOpen ? (\n          <motion.div\n            id={listId}\n            role=\"listbox\"\n            aria-label=\"Suggestions\"\n            initial={\n              reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }\n            }\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : {\n                    opacity: 0,\n                    y: -4,\n                    scale: 0.98,\n                    transition: { duration: 0.12, ease: EASE_OUT },\n                  }\n            }\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            style={{ transformOrigin: \"top\" }}\n            className=\"absolute inset-x-0 top-full z-20 mt-1.5 max-h-60 overflow-auto rounded-2xl border border-border bg-popover p-1 text-popover-foreground shadow-lg\"\n          >\n            {filtered.map((item, i) => {\n              const selected = i === activeIndex;\n              return (\n                // Keyboard selection runs through the combobox input\n                // (aria-activedescendant), so options stay out of the tab order.\n                <button\n                  key={item}\n                  type=\"button\"\n                  tabIndex={-1}\n                  id={`${listId}-${i}`}\n                  role=\"option\"\n                  aria-selected={selected}\n                  onMouseDown={(e) => e.preventDefault()}\n                  onPointerMove={() => setActive(i)}\n                  onClick={() => add(item)}\n                  className=\"relative block w-full cursor-pointer rounded-xl px-3 py-1.5 text-left text-sm outline-none\"\n                >\n                  {selected ? (\n                    <motion.span\n                      layoutId={`${uid}-active`}\n                      transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                      className=\"absolute inset-0 rounded-xl bg-muted\"\n                    />\n                  ) : null}\n                  <span className=\"relative\">\n                    <Highlight text={item} query={query.trim()} />\n                  </span>\n                </button>\n              );\n            })}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      <div className=\"flex min-h-4 items-start justify-between gap-3 px-1 text-xs\">\n        <p\n          id={messageId}\n          aria-live=\"polite\"\n          className={cn(error ? \"text-destructive\" : \"text-muted-foreground\")}\n        >\n          {message}\n        </p>\n        {maxTags !== undefined ? (\n          <span className=\"shrink-0 tabular-nums text-muted-foreground\">\n            {tags.length}/{maxTags}\n          </span>\n        ) : null}\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>;\n  const at = text.toLowerCase().indexOf(query.toLowerCase());\n  if (at < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at)}\n      <span className=\"font-semibold text-foreground\">\n        {text.slice(at, at + query.length)}\n      </span>\n      {text.slice(at + query.length)}\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}