{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "type": "registry:ui",
  "title": "Tag input",
  "description": "Turn short text values into removable tags.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/tag-input/tag-input.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, MouseEvent } from \"react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { X as Xmark } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface TagInputProps {\n  label: string;\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  placeholder?: string;\n  description?: string;\n  id?: string;\n}\n\nconst srOnly = \"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\";\n/* The shell animates its height to the content box, which carries the wrapping rows. The ring contracts onto the border on focus. */\nconst controlClass =\n  \"box-content overflow-hidden rounded-control border border-line bg-surface shadow-[0_0_0_6px_transparent] [transition:border-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-standard)_var(--ease-standard)] focus-within:border-ink focus-within:shadow-[0_0_0_3px_var(--focus-ring)] focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-transparent hover:not-focus-within:border-line-strong motion-reduce:transition-none\";\nconst tagClass =\n  \"relative box-border inline-flex min-h-7 cursor-default items-center gap-[3px] rounded-full border border-line bg-hover py-0 pr-1 pl-[9px] text-(length:--text-sm) whitespace-nowrap text-ink\";\n/* A resting tag is not a stacking context (its filter ends at none), so its label and button sit above the ring while its fill\n   stays below. Quick press, spring release. */\nconst removeClass =\n  \"relative z-2 grid size-[22px] place-items-center rounded-button border-0 bg-transparent text-ink-2 [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] hover:bg-surface hover:text-ink focus-visible:bg-surface focus-visible:text-ink focus-visible:shadow-[inset_0_0_0_1.5px_var(--focus-ring)] focus-visible:outline-none active:[transform:scale(.96)] active:[transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] motion-reduce:transition-none motion-reduce:active:[transform:none]\";\n\n/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */\nfunction MotionText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const words = text.split(\" \");\n  return (\n    <>\n      <span className={srOnly}>{text}</span>\n      {/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */}\n      <span className=\"relative block\" aria-hidden=\"true\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block whitespace-pre\"\n              initial={\n                reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      y: \"-0.35em\",\n                      filter: `blur(${motionTokens.blur.subtle}px)`,\n                      transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {index < words.length - 1 ? `${word} ` : word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </>\n  );\n}\n\n/** Helper copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({ id, text, className }: { id?: string; text?: string; className?: string }) {\n  return (\n    <AnimatePresence initial={false}>\n      {text ? <MessageRow key=\"message\" id={id} text={text} className={className} /> : null}\n    </AnimatePresence>\n  );\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({ id, text, className }: { id?: string; text: string; className?: string }) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) =>\n      setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight),\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  // Reduced motion mounts the row at full height: a zero-duration open would still paint one collapsed frame.\n  return (\n    <motion.span\n      className=\"block overflow-hidden\"\n      initial={reduced ? false : { height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <motion.span\n        ref={copyRef}\n        id={id}\n        className={className}\n        initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n      >\n        <MotionText text={text} />\n      </motion.span>\n    </motion.span>\n  );\n}\n\n/**\n * Tags keep the field still: a new tag blurs in where its text was typed while the caret glides aside, a removed tag\n * leaves its slot and the rest glide in, and the shell follows wrapped rows on a spring. Backspace or the arrow keys\n * pick a tag first, a ring glides to it, and the next Backspace removes it.\n */\nexport function TagInput({\n  label,\n  value,\n  defaultValue = [],\n  onValueChange,\n  placeholder = \"Add a tag\",\n  description,\n  id,\n}: TagInputProps) {\n  const generatedId = useId();\n  const inputId = id ?? generatedId;\n  const hintId = description ? `${inputId}-description` : undefined;\n  const [internal, setInternal] = useState(defaultValue);\n  const [draft, setDraft] = useState(\"\");\n  const [picked, setPicked] = useState<string | null>(null);\n  const [notice, setNotice] = useState(\"\");\n  const reduced = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const ringRef = useRef<HTMLSpanElement>(null);\n  const ringAt = useRef(\"\");\n  // The shell follows its wrapped rows on a spring instead of jumping when a tag starts or leaves a line.\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  const tags = value ?? internal;\n  const active = picked !== null && tags.includes(picked) ? picked : null;\n  useEffect(() => {\n    const node = contentRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setHeight(node.offsetHeight));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  // The ring measures the picked tag's resting box, glides between picks, and fades in place when the pick clears.\n  useLayoutEffect(() => {\n    const ring = ringRef.current;\n    const node =\n      active === null ? null : contentRef.current?.querySelector<HTMLElement>(`[data-tag=\"${CSS.escape(active)}\"]`);\n    if (!ring) return;\n    // It leaves on the same curve as a removed tag, so a picked tag and its ring fade out as one piece.\n    if (!node) {\n      if (ringAt.current)\n        animate(ring, reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }, {\n          duration: reduced ? 0 : motionTokens.duration.instant,\n          ease: [...motionTokens.ease.standard],\n        });\n      ringAt.current = \"\";\n      return;\n    }\n    const box = { x: node.offsetLeft, y: node.offsetTop, width: node.offsetWidth, height: node.offsetHeight };\n    const at = Object.values(box).join(\" \");\n    if (at === ringAt.current) return;\n    // A ring that is still fading from the last pick glides on to the next one, so quick Backspaces read as one motion.\n    if (!reduced && (ringAt.current || Number(getComputedStyle(ring).opacity) > 0.02))\n      animate(\n        ring,\n        { ...box, opacity: 1, scale: 1 },\n        { ...motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } },\n      );\n    else\n      animate(\n        ring,\n        { ...box, opacity: [0, 1], scale: [reduced ? 1 : 0.9, 1] },\n        reduced\n          ? { duration: 0, opacity: { duration: motionTokens.duration.instant } }\n          : { duration: 0, opacity: { duration: motionTokens.duration.fast }, scale: motionTokens.spring.snappy },\n      );\n    ringAt.current = at;\n  });\n  function say(message: string) {\n    setNotice((previous) => (previous === message ? `${message}\\u00a0` : message));\n  }\n  function update(next: string[]) {\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  }\n  function pick(tag: string | null) {\n    setPicked(tag);\n    if (tag !== null) say(`${tag} selected. Press Backspace to remove it.`);\n  }\n  function add() {\n    const tag = draft.trim();\n    if (!tag) return;\n    const existing = tags.find((item) => item.toLowerCase() === tag.toLowerCase());\n    // A duplicate pulses the tag that already exists, so the ignored Enter still gets an answer.\n    if (existing) {\n      const node = scope.current?.querySelector(`[data-tag=\"${CSS.escape(existing)}\"]`);\n      if (node && !reduced)\n        animate(\n          node,\n          { scale: [1, 1.06, 1] },\n          {\n            duration: motionTokens.duration.standard + motionTokens.duration.instant,\n            ease: [...motionTokens.ease.inOut],\n          },\n        );\n      say(`${existing} is already added`);\n      return;\n    }\n    update([...tags, tag]);\n    setDraft(\"\");\n    setPicked(null);\n    say(`Added ${tag}`);\n  }\n  function remove(tag: string) {\n    update(tags.filter((item) => item !== tag));\n    setPicked(null);\n    say(`Removed ${tag}`);\n    inputRef.current?.focus();\n  }\n  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    const { key, currentTarget: input } = event;\n    const atStart = input.selectionStart === 0 && input.selectionEnd === 0;\n    const index = active === null ? tags.length : tags.indexOf(active);\n    let handled = true;\n    if (key === \"Enter\" || key === \",\") add();\n    else if ((key === \"Backspace\" || key === \"Delete\") && active !== null) remove(active);\n    else if (key === \"Backspace\" && atStart && tags.length) pick(tags[tags.length - 1]!);\n    else if (key === \"ArrowLeft\" && (atStart || active !== null) && index > 0) pick(tags[index - 1]!);\n    else if (key === \"ArrowRight\" && active !== null) pick(tags[index + 1] ?? null);\n    else if (key === \"Escape\" && active !== null) pick(null);\n    else handled = false;\n    if (handled) event.preventDefault();\n  }\n  // A click on a tag picks it without taking focus from the field. The remove button keeps its own press.\n  function onTagPointer(event: MouseEvent<HTMLElement>, tag?: string) {\n    if ((event.target as HTMLElement).closest(\"button\")) return;\n    if (!tag) {\n      event.preventDefault();\n      return;\n    }\n    pick(active === tag ? null : tag);\n    inputRef.current?.focus();\n  }\n  const move = reduced ? { duration: 0 } : motionTokens.spring.morph;\n  const glide = reduced ? { duration: 0 } : motionTokens.spring.smooth;\n  return (\n    <div className=\"grid w-full min-w-0\">\n      <label htmlFor={inputId} className=\"mb-2 text-(length:--text-sm) font-medium\">\n        {label}\n      </label>\n      <motion.div ref={scope} className={controlClass} initial={false} animate={{ height }} transition={glide}>\n        {/* Equal 7px padding makes one row fill the minimum height exactly, so existing tags hold their line when a second row opens.\n          A click on the empty area is a pointer shortcut to the input, which is itself the keyboard target. */}\n        {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- pointer-only shortcut; keyboard users are already in the input */}\n        <div\n          ref={contentRef}\n          className=\"relative box-border flex min-h-[calc(var(--control-height-md)-2px)] cursor-text flex-wrap content-start items-center gap-1.5 p-[7px]\"\n          onClick={(event) => {\n            if (event.target === event.currentTarget) inputRef.current?.focus();\n          }}\n        >\n          <span\n            ref={ringRef}\n            className=\"pointer-events-none absolute top-0 left-0 z-1 box-border rounded-full border border-accent bg-accent-tint opacity-0\"\n            aria-hidden=\"true\"\n          />\n          <AnimatePresence initial={false}>\n            {!draft && !tags.length && (\n              <motion.span\n                key=\"placeholder\"\n                className=\"pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-(length:--text-sm) whitespace-nowrap text-ink-3\"\n                aria-hidden=\"true\"\n                initial={\n                  reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }\n                }\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, transition: { duration: 0 } }}\n                transition={\n                  reduced\n                    ? { duration: motionTokens.duration.instant }\n                    : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n                }\n              >\n                {placeholder}\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {tags.map((tag) => (\n              <motion.span\n                layout={reduced ? false : \"position\"}\n                className={tagClass}\n                key={tag}\n                data-tag={tag}\n                data-picked={tag === active || undefined}\n                onMouseDown={(event) => onTagPointer(event)}\n                onClick={(event) => onTagPointer(event, tag)}\n                initial={\n                  reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: `blur(${motionTokens.blur.soft}px)` }\n                }\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } }}\n                exit={\n                  reduced\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : {\n                        opacity: 0,\n                        scale: 0.9,\n                        filter: `blur(${motionTokens.blur.subtle}px)`,\n                        transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n                      }\n                }\n                transition={\n                  reduced\n                    ? { duration: motionTokens.duration.instant }\n                    : {\n                        ...motionTokens.spring.morph,\n                        layout: move,\n                        opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n                        filter: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n                      }\n                }\n              >\n                <span className=\"relative z-2\">{tag}</span>\n                <button\n                  type=\"button\"\n                  className={cn(removeClass, tag === active && \"text-ink\")}\n                  onClick={() => remove(tag)}\n                  aria-label={`Remove ${tag}`}\n                >\n                  <Xmark width={14} height={14} aria-hidden=\"true\" />\n                </button>\n              </motion.span>\n            ))}\n          </AnimatePresence>\n          <motion.input\n            className=\"box-border min-h-7 min-w-20 flex-[1_1_100px] border-0 bg-transparent px-[5px] py-0 text-(length:--text-sm) text-ink outline-none placeholder:text-transparent\"\n            layout={reduced ? false : \"position\"}\n            transition={{ layout: move }}\n            ref={inputRef}\n            id={inputId}\n            value={draft}\n            onChange={(event) => {\n              setDraft(event.currentTarget.value);\n              setPicked(null);\n            }}\n            onKeyDown={onKeyDown}\n            onBlur={() => {\n              add();\n              setPicked(null);\n            }}\n            placeholder={tags.length ? \"\" : placeholder}\n            aria-describedby={hintId}\n          />\n        </div>\n      </motion.div>\n      <span className={srOnly} aria-live=\"polite\">\n        {notice}\n      </span>\n      <FieldMessage id={hintId} text={description} className=\"block pt-2 text-xs leading-body text-ink-3\" />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/tag-input/tag-input.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/tag-input",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "TagInput",
    "tags": [
      "field",
      "tags"
    ],
    "docs": "https://irisui.dev/components/tag-input"
  }
}
