{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input-backspace",
  "title": "Tag Input Backspace",
  "description": "Tag input where Backspace on an empty field arms the last tag with a depleting bar instead of deleting it: a second Backspace inside the window removes it.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/tag-input-backspace/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from \"react\";\n\n// A tag field whose one deviation from every other tag field is what Backspace\n// does to an empty input. It does not delete the last tag — it *arms* it: the\n// pill takes the accent border and a 2px bar inside it depletes left-to-right\n// over exactly `armDuration`. A second Backspace inside that window removes it;\n// any other key, any click, a blur, or the bar running out disarms. That kills\n// the classic accident of destroying three tags because you over-backspaced\n// while typing fast, and the depleting bar is what makes the two-step rule\n// discoverable instead of mysterious.\n//\n// The armed state is the whole risk surface, so it is disarmed from four\n// independent directions: the timer, the input's own key/change/blur handlers,\n// a document pointerdown listener that exists only while something is armed,\n// and a guard that drops `armedId` the moment it stops matching a tag that is\n// actually in the list (the mouse-reaches-for-another-tag case, where the list\n// shifts under a pending timer). Any one of those alone leaves a pill stranded\n// wearing the accent ring forever.\n//\n// Hover is tracked in React rather than :hover so the landing-card autoplay\n// driver's synthetic pointer events reach it. One useRef timer, no state-held\n// ids, everything cleared on unmount. Colors are tokens only, so both themes\n// read correctly. prefers-reduced-motion drops the tag entrance and freezes\n// the depletion bar at full width — the armed state stays unmistakable without\n// motion, and the live region carries the timing instead.\n\nconst DEFAULT_COMMIT_KEYS = [\"Enter\", \",\"];\nconst ENTER_MS = 160;\nconst ENTER_EASE = \"cubic-bezier(0.34, 1.56, 0.64, 1)\";\n\n/** Identity of a tag *at its position*. A list edit anywhere before it changes\n *  the id, which is exactly what the stale-arm guard needs to notice. */\nconst tagKey = (tag: string, i: number) => `${i}\u0000${tag}`;\n\nexport interface LooseThreadProps {\n  /** Required. Rendered as a real <label> bound to the input. */\n  label: string;\n  /** Controlled tag list. Presence of this prop decides the mode, once. */\n  value?: string[];\n  /** Initial tag list when uncontrolled. */\n  defaultValue?: string[];\n  /** Called with the full tag list after an add or remove. */\n  onChange?: (tags: string[]) => void;\n  /** Shown only while the field is empty. Default \"Add a tag…\". */\n  placeholder?: string;\n  /** Hard cap on tag count. No default — unlimited. */\n  max?: number;\n  /** Allow the same tag text more than once. Default false — rejects a duplicate as invalid. */\n  allowDuplicates?: boolean;\n  /** Return the normalized value to accept, or null to reject the entry. */\n  validate?: (raw: string) => string | null;\n  /** Keys that commit the draft. Default [\"Enter\", \",\"]. */\n  commitKeys?: string[];\n  /** ms the armed tag stays armed. Default 2000. */\n  armDuration?: number;\n  /** Blocks all keyboard interaction (typing, arming, arrow-key roving) and freezes the caret. */\n  disabled?: boolean;\n  /** id for the input; bound to the label. Auto-generated when omitted. */\n  id?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function LooseThread({\n  label,\n  value,\n  defaultValue,\n  onChange,\n  placeholder = \"Add a tag…\",\n  max,\n  allowDuplicates = false,\n  validate,\n  commitKeys = DEFAULT_COMMIT_KEYS,\n  armDuration = 2000,\n  disabled = false,\n  id,\n  className = \"\",\n}: LooseThreadProps) {\n  // resolved once, never mirrored into state\n  const isControlled = value !== undefined;\n  const [internal, setInternal] = useState<string[]>(() => defaultValue ?? []);\n  const tags = isControlled ? (value as string[]) : internal;\n\n  const [draft, setDraft] = useState(\"\");\n  const [armedId, setArmedId] = useState<string | null>(null);\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [focused, setFocused] = useState(false);\n  const [invalid, setInvalid] = useState(false);\n  const [rove, setRove] = useState(0);\n  const [live, setLive] = useState(\"\");\n\n  const timerRef = useRef(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const pendingFocus = useRef<number | null>(null);\n\n  const autoId = useId();\n  const inputId = id ?? `${autoId}-input`;\n  const labelId = `${autoId}-label`;\n  const hintId = `${autoId}-hint`;\n\n  // positional ids — arming identity, deliberately invalidated by any shift\n  const keys = useMemo(() => tags.map(tagKey), [tags]);\n  // value ids — React keys and hover, stable when a *different* tag is removed,\n  // so removing one pill doesn't replay the entrance animation on all the rest\n  const rkeys = 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 disarm = useCallback(() => {\n    clearTimeout(timerRef.current);\n    setArmedId((prev) => (prev === null ? prev : null));\n  }, []);\n\n  // Defense 1 of 3: the id armed no longer names a tag that exists at that\n  // position — the list moved under a pending arm. Drop it immediately.\n  useEffect(() => {\n    if (armedId !== null && !keys.includes(armedId)) disarm();\n  }, [keys, armedId, disarm]);\n\n  // Defense 2 of 3: any pointer press anywhere, including on another tag's\n  // remove button, which never reaches the input's key handlers. Listener only\n  // exists while something is armed, and is capture-phase so it lands first.\n  useEffect(() => {\n    if (armedId === null) return;\n    const onDown = () => disarm();\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [armedId, disarm]);\n\n  useEffect(() => () => clearTimeout(timerRef.current), []);\n\n  // focus lands where the removed tag was, or on the input if nothing is left\n  useEffect(() => {\n    const target = pendingFocus.current;\n    if (target === null) return;\n    pendingFocus.current = null;\n    if (target < 0 || tags.length === 0) inputRef.current?.focus();\n    else btnRefs.current[Math.min(target, tags.length - 1)]?.focus();\n  }, [tags]);\n\n  const setTags = useCallback(\n    (next: string[]) => {\n      if (!isControlled) setInternal(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange]\n  );\n\n  const removeAt = useCallback(\n    (index: number, refocus: number | null) => {\n      const tag = tags[index];\n      if (tag === undefined) return;\n      disarm();\n      pendingFocus.current = refocus;\n      setTags(tags.filter((_, i) => i !== index));\n      setInvalid(false); // the hint line goes back to being a hint\n      setLive(`Removed ${tag}`);\n    },\n    [tags, setTags, disarm]\n  );\n\n  const commit = useCallback(\n    (raw: string) => {\n      const trimmed = raw.trim();\n      if (!trimmed) return; // empty commit is a no-op, not an error\n      const normalized = validate ? validate(trimmed) : trimmed;\n      if (normalized === null || normalized === \"\") {\n        setInvalid(true);\n        setLive(`${trimmed} is not a valid tag`);\n        return;\n      }\n      if (max !== undefined && tags.length >= max) {\n        setInvalid(true);\n        setLive(`Limit of ${max} tags reached`);\n        return;\n      }\n      if (!allowDuplicates && tags.includes(normalized)) {\n        setInvalid(true);\n        setLive(`${normalized} is already added`);\n        return;\n      }\n      setTags([...tags, normalized]);\n      setDraft(\"\");\n      setInvalid(false);\n      setLive(`Added ${normalized}`);\n    },\n    [validate, max, allowDuplicates, tags, setTags]\n  );\n\n  const onInputKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const empty = e.currentTarget.value === \"\";\n\n    if (e.key === \"Backspace\" && empty) {\n      if (tags.length === 0) return;\n      e.preventDefault();\n      const lastIndex = tags.length - 1;\n      const lastId = keys[lastIndex];\n      if (armedId === lastId) {\n        removeAt(lastIndex, null); // second press inside the window: gone\n        return;\n      }\n      clearTimeout(timerRef.current);\n      setArmedId(lastId);\n      setLive(`Press backspace again to remove ${tags[lastIndex]}`);\n      timerRef.current = window.setTimeout(() => setArmedId(null), armDuration);\n      return;\n    }\n\n    if (commitKeys.includes(e.key)) {\n      e.preventDefault();\n      disarm();\n      commit(e.currentTarget.value);\n      return;\n    }\n\n    if (e.key === \"ArrowLeft\" && empty && tags.length > 0) {\n      e.preventDefault();\n      disarm();\n      setRove(tags.length - 1);\n      btnRefs.current[tags.length - 1]?.focus();\n      return;\n    }\n\n    // every other keystroke, Escape included, disarms and throws nothing\n    disarm();\n  };\n\n  const onTagKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {\n    if (disabled) return;\n    const last = tags.length - 1;\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\" || e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault();\n      const next =\n        e.key === \"Home\"\n          ? 0\n          : e.key === \"End\"\n            ? last\n            : Math.min(last, Math.max(0, index + (e.key === \"ArrowLeft\" ? -1 : 1)));\n      if (e.key === \"ArrowRight\" && index === last) {\n        inputRef.current?.focus();\n        return;\n      }\n      setRove(next);\n      btnRefs.current[next]?.focus();\n      return;\n    }\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      inputRef.current?.focus();\n      return;\n    }\n    // focusing a tag is explicit intent — no arming step here\n    if (e.key === \"Enter\" || e.key === \" \" || e.key === \"Delete\" || e.key === \"Backspace\") {\n      e.preventDefault();\n      removeAt(index, tags.length === 1 ? -1 : Math.max(0, index - 1));\n    }\n  };\n\n  const hint = max !== undefined ? `Press Enter to add · ${tags.length} / ${max}` : \"Press Enter to add\";\n  // the rejection is only useful where the eye already is: under the field\n  const hintText = invalid && live ? live : hint;\n  const caretOn = tags.length > 0 && !focused && !disabled && draft === \"\";\n\n  return (\n    <div className={[\"flex w-full flex-col gap-2\", className].join(\" \")}>\n      <style>{`\n.ns-lt-tag{animation:ns-lt-in ${ENTER_MS}ms ${ENTER_EASE} both}\n@keyframes ns-lt-in{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}\n@keyframes ns-lt-deplete{from{transform:scaleX(1)}to{transform:scaleX(0)}}\n@keyframes ns-lt-caret{0%,49%{opacity:1}50%,100%{opacity:0}}\n.ns-lt-bar{transform-origin:left;animation:ns-lt-deplete var(--ns-lt-arm) linear forwards}\n.ns-lt-caret{animation:ns-lt-caret 1.06s step-end infinite}\n.ns-lt-field:focus-within{border-color:var(--ns-accent);box-shadow:0 0 0 3px color-mix(in oklab,var(--ns-accent) 16%,transparent)}\n.ns-lt-tag[data-armed]{border-color:var(--ns-accent);box-shadow:0 0 0 2px color-mix(in oklab,var(--ns-accent) 22%,transparent)}\n.ns-lt-x[data-hover]{background:color-mix(in oklab,var(--foreground) 14%,transparent);color:var(--foreground)}\n@media (prefers-reduced-motion: reduce){\n.ns-lt-tag{animation:none}\n.ns-lt-bar{animation:none;transform:scaleX(1)}\n.ns-lt-caret{animation:none}\n.ns-lt-field,.ns-lt-x{transition:none}\n}`}</style>\n\n      <label\n        id={labelId}\n        htmlFor={inputId}\n        className=\"text-[13px] font-medium tracking-tight text-foreground\"\n      >\n        {label}\n      </label>\n\n      <div\n        role=\"group\"\n        aria-labelledby={labelId}\n        onMouseDown={(e) => {\n          // clicking anywhere that isn't a remove button or the input itself\n          // puts the caret in the input, the way a real field behaves\n          const target = e.target as HTMLElement;\n          if (disabled || target === inputRef.current || target.closest(\"button\")) return;\n          e.preventDefault();\n          inputRef.current?.focus();\n        }}\n        className={[\n          \"ns-lt-field flex flex-wrap items-center gap-[6px] rounded-sm border border-border px-2 py-[6px]\",\n          \"bg-surface transition-[border-color,box-shadow] duration-150 ease-out\",\n          disabled ? \"cursor-not-allowed opacity-55\" : \"cursor-text\",\n        ].join(\" \")}\n      >\n        {tags.map((tag, i) => {\n          const rkey = rkeys[i];\n          const armed = armedId === keys[i];\n          return (\n            <span\n              key={rkey}\n              data-armed={armed || undefined}\n              style={armed ? ({ \"--ns-lt-arm\": `${armDuration}ms` } as CSSProperties) : undefined}\n              className={[\n                \"ns-lt-tag relative flex items-center overflow-hidden rounded-full border border-border\",\n                \"py-[2px] pl-[10px] pr-[4px]\",\n                \"bg-background text-[13px] leading-5 text-foreground\",\n                \"transition-[border-color,box-shadow] duration-150 ease-out\",\n              ].join(\" \")}\n            >\n              {tag}\n              <button\n                ref={(el) => {\n                  btnRefs.current[i] = el;\n                }}\n                type=\"button\"\n                tabIndex={i === Math.min(rove, tags.length - 1) ? 0 : -1}\n                disabled={disabled}\n                data-hover={hoveredId === rkey || undefined}\n                aria-label={`Remove ${tag}`}\n                onPointerEnter={() => setHoveredId(rkey)}\n                onPointerLeave={() => setHoveredId((prev) => (prev === rkey ? null : prev))}\n                onFocus={() => setRove(i)}\n                onKeyDown={(e) => onTagKeyDown(e, i)}\n                onClick={() => removeAt(i, i === 0 ? -1 : i - 1)}\n                className={[\n                  \"ns-lt-x ml-[4px] flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full\",\n                  \"text-ns-muted transition-[color,background-color] duration-150 ease-out\",\n                  \"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ns-accent\",\n                ].join(\" \")}\n              >\n                <svg viewBox=\"0 0 12 12\" aria-hidden className=\"h-[10px] w-[10px]\">\n                  <path\n                    d=\"M3 3l6 6M9 3l-6 6\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"1.6\"\n                    strokeLinecap=\"round\"\n                  />\n                </svg>\n              </button>\n              {armed ? (\n                <span\n                  aria-hidden\n                  className=\"ns-lt-bar pointer-events-none absolute bottom-0 left-0 right-0 h-[2px] bg-ns-accent\"\n                />\n              ) : null}\n            </span>\n          );\n        })}\n\n        {/* resting caret: the field reads as a live text field in a still frame,\n            and hands off to the real caret the moment it is focused */}\n        <span\n          aria-hidden\n          className={[\n            \"h-[15px] w-px shrink-0 bg-foreground\",\n            caretOn ? \"ns-lt-caret\" : \"opacity-0\",\n          ].join(\" \")}\n        />\n\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"text\"\n          value={draft}\n          disabled={disabled}\n          placeholder={tags.length === 0 ? placeholder : \"\"}\n          aria-describedby={hintId}\n          aria-invalid={invalid || undefined}\n          autoComplete=\"off\"\n          onChange={(e) => {\n            disarm();\n            setInvalid(false);\n            setDraft(e.target.value);\n          }}\n          onFocus={() => setFocused(true)}\n          onBlur={() => {\n            setFocused(false);\n            disarm(); // Defense 3 of 3: the pointer left for somewhere else\n          }}\n          onKeyDown={onInputKeyDown}\n          className={[\n            \"min-w-[8ch] flex-1 border-0 bg-transparent p-0 text-[13px] leading-5 text-foreground\",\n            \"outline-none placeholder:text-ns-muted disabled:cursor-not-allowed\",\n          ].join(\" \")}\n        />\n      </div>\n\n      <p id={hintId} className=\"text-[12px] leading-4 text-ns-muted\">\n        {hintText}\n      </p>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {live}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/tag-input-backspace.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "input",
      "tags",
      "form",
      "keyboard",
      "undo",
      "micro-interaction"
    ],
    "instruction": "A tag / chip multi-input — a bordered field of rounded pills each with an 18px circular remove button, a borderless flex-1 text input, and a muted hint line below that doubles as the rejection message. Its one deviation from every other tag field is what Backspace does to an empty input: it does not delete the last tag, it arms it. The armed pill takes an accent border and a 2px accent bar inside it depletes left-to-right over exactly armDuration (default 2000ms); a second Backspace inside that window removes it, and any other keystroke, any pointer press anywhere, a blur, or the bar running out disarms. That removes the classic accident of destroying three tags because you over-backspaced while typing fast, and the depleting bar makes the two-step rule discoverable rather than mysterious. The armed state is disarmed from four independent directions — the timer, the input's key/change/blur handlers, a capture-phase document pointerdown listener that exists only while something is armed, and a guard that drops the armed id the moment it stops matching a tag actually present at that position — because the reach-for-the-mouse case shifts the tag list under a pending timer and would otherwise strand a pill wearing the accent ring forever. Controlled or uncontrolled, resolved once from whether `value` is passed and never mirrored into state. Commit keys default to Enter and comma; entries pass through an optional validate that returns a normalized string or null to reject, with duplicates and a max cap rejected the same way and announced. Full keyboard model: clicking the field focuses the input, ArrowLeft from an empty input jumps to the last tag, remove buttons use roving tabindex with ArrowLeft/ArrowRight/Home/End, Enter, Space, Delete or Backspace on a focused tag removes it immediately (explicit focus is explicit intent, so no arming there), Escape returns to the input, and a visually-hidden polite live region announces additions, removals and the arming prompt. Hover is tracked in React rather than :hover so synthetic pointer events reach it. Zero dependencies, one useRef timer cleared on unmount, colors from --background, --foreground, --surface, --border, --ns-muted and --ns-accent only, so both themes read correctly. prefers-reduced-motion drops the tag entrance and the caret blink and freezes the depletion bar at full width, leaving the armed state unmistakable without motion while the live region carries the timing."
  },
  "type": "registry:ui"
}