{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-input","type":"registry:component","title":"Tag Input","description":"A field that turns what you type into tags. Enter or a comma adds one, Backspace marks then removes the last, and a duplicate shakes the tag that already exists.","author":"Ryan","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// easeui.dev/components/motion/tag-input\n\nimport { X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TagInputProps {\n  /** Controlled list of tags. */\n  value?: string[];\n  /** Starting tags when uncontrolled. */\n  defaultValue?: string[];\n  onValueChange?: (tags: string[]) => void;\n  placeholder?: string;\n  /** Stops adding once this many tags exist. */\n  max?: number;\n  /** Names the field for screen readers. Default \"Tags\". */\n  label?: string;\n  className?: string;\n}\n\n/**\n * A field that turns what you type into tags. Enter or a comma adds one, Backspace on an empty\n * field marks the last tag then removes it, and a duplicate shakes the tag that already exists.\n */\nexport function TagInput({ value, defaultValue = [], onValueChange, placeholder = \"Add a tag\", max, label = \"Tags\", className }: TagInputProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const input = useRef<HTMLInputElement>(null);\n  const [inner, setInner] = useState(defaultValue);\n  const tags = value ?? inner;\n  const [draft, setDraft] = useState(\"\");\n  const [marked, setMarked] = useState(false);\n  const [shake, setShake] = useState<{ tag: string; n: number } | null>(null);\n  const full = max !== undefined && tags.length >= max;\n\n  const commit = (next: string[]) => {\n    setInner(next);\n    onValueChange?.(next);\n  };\n\n  const add = () => {\n    const tag = draft.trim().replace(/,$/, \"\").trim();\n    if (!tag || full) return;\n    const existing = tags.find((t) => t.toLowerCase() === tag.toLowerCase());\n    if (existing) {\n      setShake((s) => ({ tag: existing, n: (s?.n ?? 0) + 1 }));\n      return;\n    }\n    commit([...tags, tag]);\n    setDraft(\"\");\n  };\n\n  const remove = (tag: string) => {\n    commit(tags.filter((t) => t !== tag));\n    setMarked(false);\n    input.current?.focus();\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Enter\" || event.key === \",\") {\n      event.preventDefault();\n      add();\n    } else if (event.key === \"Backspace\" && draft === \"\" && tags.length) {\n      // The first press marks the last tag, the second removes it, so one slip doesn't lose it.\n      if (marked) remove(tags[tags.length - 1]);\n      else setMarked(true);\n    } else if (marked) {\n      setMarked(false);\n    }\n  };\n\n  return (\n    // biome-ignore lint/a11y/useKeyWithClickEvents: clicking the padding focuses the input, which has its own keyboard handling.\n    // biome-ignore lint/a11y/noStaticElementInteractions: the input inside is the real control.\n    <div\n      onClick={() => input.current?.focus()}\n      className={cn(\n        \"flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl bg-background px-2 py-1.5 shadow-[0_0_0_1px_var(--border-strong)]\",\n        \"transition-shadow duration-150 focus-within:shadow-[0_0_0_2px_var(--accent)]\",\n        className,\n      )}\n    >\n      <label htmlFor={id} className=\"sr-only\">\n        {label}\n      </label>\n      <ul className=\"contents\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {tags.map((tag, index) => {\n            const isMarked = marked && index === tags.length - 1;\n            const shaking = shake?.tag === tag;\n            return (\n              <motion.li\n                key={tag}\n                layout={!reduce}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n                animate={\n                  shaking && !reduce\n                    ? { opacity: 1, scale: 1, x: [0, -4, 4, -3, 3, 0] }\n                    : { opacity: 1, scale: 1, x: 0 }\n                }\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n                transition={{ duration: 0.2, ease: EASE_OUT, x: { duration: 0.32 } }}\n                onAnimationComplete={() => shaking && setShake(null)}\n                className={cn(\n                  \"flex h-7 items-center gap-1 rounded-lg pl-2.5 pr-1 text-sm transition-colors duration-150\",\n                  isMarked ? \"bg-accent text-accent-foreground\" : \"bg-muted text-foreground shadow-[inset_0_0_0_1px_var(--border)]\",\n                )}\n              >\n                {tag}\n                <button\n                  type=\"button\"\n                  aria-label={`Remove ${tag}`}\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    remove(tag);\n                  }}\n                  className=\"relative grid h-5 w-5 place-items-center rounded-md opacity-60 outline-none transition-opacity duration-150 after:absolute after:-inset-1.5 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-foreground/40\"\n                >\n                  <X aria-hidden=\"true\" className=\"h-3 w-3\" />\n                </button>\n              </motion.li>\n            );\n          })}\n        </AnimatePresence>\n      </ul>\n      <input\n        ref={input}\n        id={id}\n        value={draft}\n        onChange={(event) => {\n          setDraft(event.target.value);\n          setMarked(false);\n        }}\n        onKeyDown={onKeyDown}\n        onBlur={() => setMarked(false)}\n        disabled={full}\n        placeholder={full ? `Up to ${max} tags` : tags.length ? \"\" : placeholder}\n        className=\"h-7 min-w-24 flex-1 bg-transparent px-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm\"\n      />\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\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.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\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 closely 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"}]}