{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/tag-input/tag-input.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./tag-input.module.css\";\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\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 <><span className={styles.srOnly}>{text}</span><span className={styles.words} aria-hidden=\"true\"><AnimatePresence initial={false} mode=\"popLayout\">{words.map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word}\n    initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n    exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.35em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }}\n    transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{index < words.length - 1 ? `${word} ` : word}</motion.span>)}</AnimatePresence></span></>;\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 <AnimatePresence initial={false}>{text ? <MessageRow key=\"message\" id={id} text={text} className={className} /> : null}</AnimatePresence>;\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]) => setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight));\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 <motion.span className={styles.messageSlot} initial={reduced ? false : { height: 0, opacity: 0 }} animate={{ height, opacity: 1 }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>\n    <motion.span ref={copyRef} id={id} className={className} initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ y: 0, filter: \"blur(0px)\" }} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}><MotionText text={text} /></motion.span>\n  </motion.span>;\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({ label, value, defaultValue = [], onValueChange, placeholder = \"Add a tag\", description, id }: 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 = 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) { if (ringAt.current) animate(ring, reduced ? { opacity: 0 } : { opacity: 0, scale: .9 }, { duration: reduced ? 0 : motionTokens.duration.instant, ease: [...motionTokens.ease.standard] }); ringAt.current = \"\"; return; }\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) > .02)) animate(ring, { ...box, opacity: 1, scale: 1 }, { ...motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } });\n    else animate(ring, { ...box, opacity: [0, 1], scale: [reduced ? 1 : .9, 1] }, reduced ? { duration: 0, opacity: { duration: motionTokens.duration.instant } } : { duration: 0, opacity: { duration: motionTokens.duration.fast }, scale: motionTokens.spring.snappy });\n    ringAt.current = at;\n  });\n  function say(message: string) { setNotice(previous => previous === message ? `${message}\\u00a0` : message); }\n  function update(next: string[]) { if (value === undefined) setInternal(next); onValueChange?.(next); }\n  function pick(tag: string | null) { setPicked(tag); if (tag !== null) say(`${tag} selected. Press Backspace to remove it.`); }\n  function add() {\n    const tag = draft.trim(); 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) { const node = scope.current?.querySelector(`[data-tag=\"${CSS.escape(existing)}\"]`); if (node && !reduced) animate(node, { scale: [1, 1.06, 1] }, { duration: motionTokens.duration.standard + motionTokens.duration.instant, ease: [...motionTokens.ease.inOut] }); say(`${existing} is already added`); return; }\n    update([...tags, tag]); setDraft(\"\"); setPicked(null); say(`Added ${tag}`);\n  }\n  function remove(tag: string) { update(tags.filter(item => item !== tag)); setPicked(null); say(`Removed ${tag}`); inputRef.current?.focus(); }\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) { event.preventDefault(); return; }\n    pick(active === tag ? null : tag); inputRef.current?.focus();\n  }\n  const move = reduced ? { duration: 0 } : motionTokens.spring.morph;\n  const glide = reduced ? { duration: 0 } : motionTokens.spring.smooth;\n  return <div className={styles.field}>\n    <label htmlFor={inputId}>{label}</label>\n    <motion.div ref={scope} className={styles.control} initial={false} animate={{ height }} transition={glide}>\n      <div ref={contentRef} className={styles.content} onClick={event => { if (event.target === event.currentTarget) inputRef.current?.focus(); }}>\n        <span ref={ringRef} className={styles.ring} aria-hidden=\"true\" />\n        <AnimatePresence initial={false}>{!draft && !tags.length && <motion.span key=\"placeholder\" className={styles.placeholder} aria-hidden=\"true\"\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={{ opacity: 0, transition: { duration: 0 } }}\n          transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{placeholder}</motion.span>}</AnimatePresence>\n        <AnimatePresence initial={false} mode=\"popLayout\">{tags.map(tag => <motion.span layout={reduced ? false : \"position\"} className={styles.tag} key={tag} data-tag={tag} data-picked={tag === active || undefined}\n          onMouseDown={event => onTagPointer(event)} onClick={event => onTagPointer(event, tag)}\n          initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .9, filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } }}\n          exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .9, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }}\n          transition={reduced ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.morph, layout: move, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }}><span className={styles.tagLabel}>{tag}</span><button type=\"button\" onClick={() => remove(tag)} aria-label={`Remove ${tag}`}><Xmark width={14} height={14} aria-hidden=\"true\" /></button></motion.span>)}</AnimatePresence>\n        <motion.input layout={reduced ? false : \"position\"} transition={{ layout: move }} ref={inputRef} id={inputId} value={draft} onChange={event => { setDraft(event.currentTarget.value); setPicked(null); }} onKeyDown={onKeyDown} onBlur={() => { add(); setPicked(null); }} placeholder={tags.length ? \"\" : placeholder} aria-describedby={hintId} />\n      </div>\n    </motion.div>\n    <span className={styles.srOnly} aria-live=\"polite\">{notice}</span>\n    <FieldMessage id={hintId} text={description} className={styles.hint} />\n  </div>;\n}\n"
    },
    {
      "path": "registry/components/tag-input/tag-input.module.css",
      "type": "registry:component",
      "target": "@components/xzero/tag-input/tag-input.module.css",
      "content": "/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */\n/* The field fills its column, so a new tag never widens it or re-centers it inside a centered layout. */\n.field { display: grid; width: 100%; min-width: 0; }\n.field label { margin-bottom: var(--space-2); font-size: var(--text-sm); font-weight: 500; }\n/* The shell animates its height to .content, which carries the wrapping rows. The ring contracts onto the border on focus. */\n.control { box-sizing: content-box; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); box-shadow: 0 0 0 6px transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .control:hover:not(:focus-within) { border-color: var(--border-strong); } }\n.control:focus-within { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); outline: 2px solid transparent; outline-offset: 2px; }\n/* Equal 7px padding makes one row fill the minimum height exactly, so existing tags hold their line when a second row opens. */\n.content { position: relative; display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start; gap: 6px; min-height: calc(var(--control-height-md) - 2px); box-sizing: border-box; padding: 7px; cursor: text; }\n.tag { position: relative; display: inline-flex; align-items: center; gap: 3px; min-height: 28px; box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0 4px 0 9px; background: var(--surface-muted); color: var(--foreground); font-size: var(--text-sm); white-space: nowrap; cursor: default; }\n.tag button { display: grid; width: 22px; height: 22px; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .tag button:hover { background: var(--surface); color: var(--foreground); } }\n.tag button:focus-visible { background: var(--surface); color: var(--foreground); outline: none; box-shadow: inset 0 0 0 1.5px var(--focus-ring); }\n/* Quick press, spring release. */\n.tag button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.content input { flex: 1 1 100px; min-width: 80px; min-height: 28px; box-sizing: border-box; border: 0; padding: 0 5px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }\n.content input:focus, .content input:focus-visible { outline: none; }\n.content input::placeholder { color: transparent; }\n/* The visible placeholder is drawn here so it can rise in when the last tag leaves. The native one stays for assistive tech. Inset matches the content and input padding. */\n.placeholder { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; padding: 0 12px; color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; pointer-events: none; }\n/* The pick ring rides above the pills and sizes itself to the picked tag, so it can glide between tags without scaling its border. */\n.ring { position: absolute; top: 0; left: 0; z-index: 1; box-sizing: border-box; border: 1px solid var(--accent); border-radius: var(--radius-pill); background: var(--accent-subtle); opacity: 0; pointer-events: none; }\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 stays below. */\n.tagLabel, .tag button { position: relative; z-index: 2; }\n.tag[data-picked] button { color: var(--foreground); }\n/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */\n.messageSlot { display: block; overflow: hidden; }\n.hint { display: block; padding-top: var(--space-2); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */\n.words { position: relative; display: block; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .control, .tag button { transition: none; } .tag button:active { transform: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/tag-input",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "tags"
    ],
    "docs": "https://ui.xzero.co.uk/components/tag-input",
    "markdown": "https://ui.xzero.co.uk/components/tag-input/markdown"
  }
}