{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-input","type":"registry:component","title":"Tag input","description":"The invite field, done properly: typed letters grow a chip round themselves, pasted lists spill out of the caret, and a duplicate flies into its twin.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json","https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/tag-input.tsx","type":"registry:component","content":"// Hairline UI · tag-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\nimport {\n  carryFrames,\n  flyFrames,\n  LEAVE_FRAMES,\n  playTokens,\n  pulseToken,\n  shakeToken,\n  snapshotTokens,\n  TOKEN_EASE,\n  type TokenBox,\n  type TokenEnter,\n  type TokenSnapshot,\n} from \"./use-token-flip\";\n\n/* ─────────────────────────────────────────────────────────\n * TAG INPUT: the To field, the invite field, the labels field\n *\n *   type      Enter or a comma, and the letters you typed stay\n *             where they are while a hairline shell grows round\n *             them; the caret is already past it\n *   paste     a pasted list spills out of the caret chip by\n *             chip; a duplicate flies into its twin, which\n *             bumps; an invalid one lands red and shakes\n *   resolve   \"maya@fernhill.co\" morphs into \"Maya Chen\" while\n *             her initials open in, once resolve() knows her\n *   pick      a suggestion's name is carried from the list\n *             into its chip\n *   back      Backspace in an empty field selects the last\n *             chip; again, and its shell drops away and the\n *             letters are text you can edit\n *   reflow    every chip glides when one leaves or a line\n *             wraps; the field's height eases to fit\n *   collapse  out of focus, lines past `collapse` fold away and\n *             a \"+4\" counts them\n * ───────────────────────────────────────────────────────── */\n\nexport interface TagInputTag {\n  id: string;\n  /** What was typed or picked, normalised: the address, the label. */\n  value: string;\n  /** What the chip shows instead, once known: a name for an address. */\n  label?: string;\n  /** A picture for the chip. Without one, a known label gets its initials. */\n  image?: string;\n  /** Why the value isn't valid. The chip turns red and stays until it's fixed or removed. */\n  error?: string;\n}\n\nexport interface TagInputSuggestion {\n  value: string;\n  label?: string;\n  image?: string;\n  /** A second line in the list; defaults to the value when a label is shown. */\n  description?: string;\n}\n\nexport interface TagInputHandle {\n  /** Runs text through the paste path: split, checked and cascaded in. */\n  paste: (text: string) => void;\n  focus: () => void;\n  /** Removes every tag. */\n  clear: () => void;\n}\n\nexport interface TagInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  value?: TagInputTag[];\n  defaultValue?: TagInputTag[];\n  onValueChange?: (tags: TagInputTag[]) => void;\n  /** Return a message to mark a value invalid. */\n  validate?: (value: string) => string | null | undefined | void;\n  /** Look a value up once it's added: resolve to a label (and image) for the chip to morph into, or null to leave it. */\n  resolve?: (value: string) => Promise<{ label?: string; image?: string } | null | undefined | void>;\n  /** A list to filter, or a function that finds matches for what's typed (may be async). */\n  suggestions?: TagInputSuggestion[] | ((query: string) => TagInputSuggestion[] | Promise<TagInputSuggestion[]>);\n  /** Applied to every value before it's checked and compared, e.g. lower-casing addresses. */\n  normalize?: (value: string) => string;\n  /** What splits typed and pasted text into values. */\n  separators?: RegExp;\n  /** How many tags the field takes. */\n  max?: number;\n  /** Shown when a value doesn't fit under `max`. */\n  limitMessage?: string;\n  /** The suggestion row that adds what's typed as it is; null hides it. */\n  createLabel?: ((text: string) => string) | null;\n  /** Lines to show while the field is out of focus; the rest fold into a count. */\n  collapse?: number;\n  /** Accessible name of the field. */\n  label?: string;\n  placeholder?: string;\n  /** A line under the field, replaced by errors and notices while they last. */\n  hint?: string;\n  /** Submits the valid values, comma-separated, with a form. */\n  name?: string;\n  disabled?: boolean;\n  ref?: React.Ref<TagInputHandle>;\n}\n\ntype Draft = { value: string; label?: string; image?: string };\ntype Ghost = {\n  key: number;\n  kind: \"leave\" | \"fly\" | \"unspool\" | \"carry\";\n  text: string;\n  /** Root coordinates. */\n  box: TokenBox;\n  to?: TokenBox;\n  /** Where the letters sit inside the box. */\n  inner?: TokenBox;\n  twin?: string;\n  delay?: number;\n  /** Fades in where it starts (a pasted duplicate) instead of already being there. */\n  appear?: boolean;\n  error?: boolean;\n  avatar?: { image?: string; name: string };\n};\n/** A copy flying to a chip whose place is only known after layout; `after` starts it where it would have landed, after that chip. */\ntype Flight = { kind: \"fly\" | \"carry\"; text: string; from: TokenBox | { after: string | null; w: number }; twin: string; delay: number };\ntype Pending = {\n  before: TokenSnapshot | null;\n  enter: Map<string, TokenEnter>;\n  skipInput?: boolean;\n  ghosts: Ghost[];\n  /** Ghosts flying to a chip whose place is only known after layout. */\n  flights: Flight[];\n  shake: { id: string; delay: number }[];\n  shakeField?: boolean;\n  /** Edited text glides out of this box (root coordinates) into the input. */\n  inputFrom?: { box: TokenBox; fade: boolean };\n  focus?: \"input\" | string;\n};\ntype Notice = { text: string; tone: \"muted\" | \"warn\"; key: number };\ntype Row = TagInputSuggestion & { create?: boolean };\ntype How = { type: TokenBox | null } | { paste: { x: number; y: number } } | { pick: TokenBox };\n\nconst EASE = TOKEN_EASE;\nconst EMPTY: TagInputTag[] = [];\nconst SEPARATORS = /[,;\\n]+/;\nconst CHIP_H = 28;\n/** Labels longer than this many characters are cut to fit a phone-width field; the chip's title has the whole. */\nconst LONG = 28;\nconst PAD = 5;\nconst GAP = 6;\nconst CREATE = (text: string) => `Add “${text}”`;\nconst SAME = (v: string) => v;\n\nlet seq = 0;\nconst newId = () => `tag-${Date.now().toString(36)}-${(seq++).toString(36)}`;\nconst display = (t: { value: string; label?: string }) => t.label || t.value;\nconst initials = (name: string) =>\n  name\n    .split(/[\\s._-]+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]!.toUpperCase())\n    .join(\"\");\nconst shift = (b: TokenBox, dx: number, dy: number): TokenBox => ({ x: b.x + dx, y: b.y + dy, w: b.w, h: b.h });\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 380ms ${EASE}, filter 240ms ${EASE}`,\n});\n\nconst shellStyle = (error: boolean, selected: boolean): React.CSSProperties => ({\n  background: error ? \"color-mix(in oklab, #ef4444 7%, var(--background))\" : selected ? \"var(--accent)\" : \"var(--background)\",\n  boxShadow: error\n    ? `inset 0 0 0 1px color-mix(in oklab, #ef4444 ${selected ? 75 : 45}%, transparent)`\n    : selected\n      ? \"inset 0 0 0 1px color-mix(in oklab, var(--ring) 70%, transparent)\"\n      : \"inset 0 0 0 1px var(--border)\",\n});\n\nfunction Avatar({ image, name, size }: { image?: string; name: string; size: number }) {\n  return image ? (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img src={image} alt=\"\" draggable={false} className=\"shrink-0 rounded-full object-cover\" style={{ width: size, height: size }} />\n  ) : (\n    <span\n      className=\"flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}\n    >\n      {initials(name)}\n    </span>\n  );\n}\n\nfunction Cross() {\n  return (\n    <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M3.5 3.5 8.5 8.5M8.5 3.5 3.5 8.5\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/** A sentence that changes crossfades whole: the old one lifts away through a blur while the new one rises in. */\nfunction Line({ text, reduced }: { text: string; reduced: boolean }) {\n  const [shown, setShown] = React.useState({ text, key: 0 });\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  if (text !== shown.text) {\n    if (shown.text && !reduced) setLeaving((l) => [...l, shown]);\n    setShown({ text, key: shown.key + 1 });\n  }\n  return (\n    <span className=\"relative block\">\n      <span\n        key={shown.key}\n        className=\"block\"\n        ref={(el) => {\n          if (!el || reduced || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate(\n            [\n              { opacity: 0, transform: \"translateY(0.35em)\", filter: \"blur(4px)\" },\n              { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            ],\n            { duration: 420, delay: 60, easing: EASE, fill: \"backwards\" },\n          );\n        }}\n      >\n        {shown.text || \" \"}\n      </span>\n      {leaving.map((l) => (\n        <span\n          key={l.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 block\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate(\n              [\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                { opacity: 0, transform: \"translateY(-0.35em)\", filter: \"blur(4px)\" },\n              ],\n              { duration: 200, easing: EASE, fill: \"forwards\" },\n            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\n/** A copy of a chip (or of its letters) that leaves, flies into a twin, drops its shell or carries a name in. */\nfunction GhostChip({ ghost, onDone }: { ghost: Ghost; onDone: (ghost: Ghost) => void }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const shell = React.useRef<HTMLSpanElement>(null);\n  const letters = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let a: Animation;\n    if (ghost.kind === \"leave\") a = el.animate(LEAVE_FRAMES, { duration: 280, easing: EASE, fill: \"forwards\" });\n    else if (ghost.kind === \"fly\" && ghost.to)\n      a = el.animate(flyFrames(ghost.box, ghost.to, ghost.appear), { duration: ghost.appear ? 820 : 620, delay: ghost.delay ?? 0, easing: EASE, fill: \"both\" });\n    else if (ghost.kind === \"carry\" && ghost.to) a = el.animate(carryFrames(ghost.box, ghost.to), { duration: 480, easing: EASE, fill: \"both\" });\n    else {\n      // The shell shrinks round the letters and fades; the letters fade into the input's own.\n      const b = ghost.box;\n      const i = ghost.inner ?? b;\n      const left = Math.max(0, i.x - b.x - 3);\n      const right = Math.max(0, b.x + b.w - (i.x + i.w) - 3);\n      shell.current?.animate(\n        [\n          { clipPath: \"inset(0px 0px 0px 0px round 999px)\", opacity: 1 },\n          { clipPath: `inset(4px ${right}px 4px ${left}px round 999px)`, opacity: 0 },\n        ],\n        { duration: 360, easing: EASE, fill: \"forwards\" },\n      );\n      el.querySelector<HTMLElement>(\"[data-ghost-avatar]\")?.animate(LEAVE_FRAMES, { duration: 240, easing: EASE, fill: \"forwards\" });\n      a = (letters.current ?? el).animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, filter: \"blur(4px)\" }], { duration: 320, easing: EASE, fill: \"forwards\" });\n    }\n    a.onfinish = () => onDone(ghost);\n    return () => a.cancel();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const carry = ghost.kind === \"carry\";\n  const inner = ghost.inner;\n  return (\n    <span\n      ref={ref}\n      aria-hidden=\"true\"\n      className={`pointer-events-none absolute flex items-center whitespace-nowrap will-change-transform ${carry ? \"text-[13.5px]\" : \"rounded-full px-2.5 text-[13px]\"} ${\n        ghost.error ? \"text-red-600 dark:text-red-400\" : \"text-foreground\"\n      }`}\n      style={{ left: ghost.box.x, top: ghost.box.y, width: ghost.box.w, height: ghost.box.h, transformOrigin: carry ? \"left center\" : \"center\" }}\n    >\n      {!carry && <span ref={shell} className=\"absolute inset-0 rounded-full\" style={shellStyle(!!ghost.error, false)} />}\n      {ghost.avatar && (\n        <span data-ghost-avatar=\"\" className=\"absolute left-1 top-1\">\n          <Avatar image={ghost.avatar.image} name={ghost.avatar.name} size={20} />\n        </span>\n      )}\n      <span ref={letters} className={inner ? \"absolute block py-1\" : \"relative\"} style={inner ? { left: inner.x - ghost.box.x, top: inner.y - ghost.box.y } : undefined}>\n        {ghost.text}\n      </span>\n    </span>\n  );\n}\n\nexport function TagInput({\n  value,\n  defaultValue,\n  onValueChange,\n  validate,\n  resolve,\n  suggestions,\n  normalize = SAME,\n  separators = SEPARATORS,\n  max,\n  limitMessage = \"No room for more\",\n  createLabel = CREATE,\n  collapse,\n  label = \"Add tags\",\n  placeholder,\n  hint,\n  name,\n  disabled = false,\n  ref,\n  className = \"\",\n  ...props\n}: TagInputProps) {\n  const reduced = useReducedMotion();\n  const uid = React.useId();\n  const [own, setOwn] = React.useState<TagInputTag[]>(defaultValue ?? EMPTY);\n  const tags = value ?? own;\n  const tagsRef = React.useRef(tags);\n  tagsRef.current = tags;\n  const [text, setText] = React.useState(\"\");\n  const [resolving, setResolving] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [selected, setSelected] = React.useState<string | null>(null);\n  const [within, setWithin] = React.useState(false);\n  const [inputFocus, setInputFocus] = React.useState(false);\n  const [options, setOptions] = React.useState<TagInputSuggestion[]>([]);\n  const [active, setActive] = React.useState(-1);\n  const [dismissed, setDismissed] = React.useState(false);\n  const [notice, setNotice] = React.useState<Notice | null>(null);\n  const [ghosts, setGhosts] = React.useState<Ghost[]>([]);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [tick, setTick] = React.useState(0);\n  const [cut, setCut] = React.useState<{ hidden: string[]; x: number; y: number; height: number } | null>(null);\n  const pending = React.useRef<Pending | null>(null);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const fieldRef = React.useRef<HTMLDivElement>(null);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const mirrorRef = React.useRef<HTMLSpanElement>(null);\n  const pillRef = React.useRef<HTMLSpanElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const ghostKey = React.useRef(0);\n  /** Focus we moved ourselves (after an edit or a removal) doesn't open the suggestions. */\n  const quietFocus = React.useRef(false);\n  /** Whether the chip being clicked was already selected: a second click (or tap) edits it. */\n  const wasSelected = React.useRef(false);\n  const alive = React.useRef(true);\n  React.useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const collapsed = collapse !== undefined && !within && tags.length > 0;\n  const collapsedRef = React.useRef(collapsed);\n  collapsedRef.current = collapsed;\n\n  const commit = (next: TagInputTag[]) => {\n    tagsRef.current = next;\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  const say = (message: string) => setAnnounce((a) => (a === message ? `${message} ` : message));\n  const flash = (message: string, tone: Notice[\"tone\"]) => {\n    const key = Date.now();\n    setNotice({ text: message, tone, key });\n    window.setTimeout(() => setNotice((n) => (n?.key === key ? null : n)), 2600);\n  };\n\n  // ── Measuring ──────────────────────────────────────────\n  /** From list coordinates to the root's, where ghosts live. */\n  const rootOffset = () => {\n    const list = listRef.current?.getBoundingClientRect();\n    const root = rootRef.current?.getBoundingClientRect();\n    return list && root ? { dx: list.left - root.left, dy: list.top - root.top } : { dx: 0, dy: 0 };\n  };\n  const textWidth = (s: string) => {\n    const m = mirrorRef.current;\n    if (!m) return s.length * 7;\n    m.textContent = s;\n    return m.offsetWidth;\n  };\n  /** Where a stretch of the input's text is drawn, in list coordinates. */\n  const textBox = (start: number, s: string): TokenBox | null => {\n    const input = inputRef.current;\n    const list = listRef.current;\n    if (!input || !list) return null;\n    const i = input.getBoundingClientRect();\n    const l = list.getBoundingClientRect();\n    const h = 18;\n    return { x: i.left - l.left + textWidth(input.value.slice(0, start)) - input.scrollLeft, y: i.top - l.top + (i.height - h) / 2, w: Math.max(1, textWidth(s)), h };\n  };\n  const caretPoint = () => {\n    const input = inputRef.current;\n    const b = textBox(input?.selectionStart ?? text.length, \"\") ?? { x: PAD, y: PAD, w: 1, h: 18 };\n    return { x: b.x, y: b.y + b.h / 2 - CHIP_H / 2 };\n  };\n\n  // ── Changes ────────────────────────────────────────────\n  /** Queue a change to play after React lays it out. Changes in the same render merge; the earliest snapshot wins. */\n  const stage = (p: Partial<Pending>, before?: TokenSnapshot | null) => {\n    const prev = pending.current;\n    pending.current = {\n      ...prev,\n      ...p,\n      before: prev ? prev.before : (before ?? (listRef.current ? snapshotTokens(listRef.current) : null)),\n      enter: new Map([...(prev?.enter ?? []), ...(p.enter ?? [])]),\n      ghosts: [...(prev?.ghosts ?? []), ...(p.ghosts ?? [])],\n      flights: [...(prev?.flights ?? []), ...(p.flights ?? [])],\n      shake: [...(prev?.shake ?? []), ...(p.shake ?? [])],\n    };\n    setTick((t) => t + 1);\n  };\n\n  const plan = (drafts: Draft[], base: TagInputTag[]) => {\n    const next = base.slice();\n    const added: TagInputTag[] = [];\n    const dups: { draft: Draft; twin: string; index: number; after: string | null }[] = [];\n    let overflow = 0;\n    let index = 0;\n    for (const d of drafts) {\n      const v = normalize(d.value.trim());\n      if (!v) continue;\n      const twin = next.find((t) => t.value === v);\n      if (twin) {\n        dups.push({ draft: { ...d, value: v }, twin: twin.id, index: index++, after: next[next.length - 1]?.id ?? null });\n        continue;\n      }\n      index++;\n      if (max !== undefined && next.length >= max) {\n        overflow++;\n        continue;\n      }\n      const error = (validate?.(v) as string | null | undefined) || undefined;\n      const tag: TagInputTag = { id: newId(), value: v, ...(d.label ? { label: d.label } : {}), ...(d.image ? { image: d.image } : {}), ...(error ? { error } : {}) };\n      next.push(tag);\n      added.push(tag);\n    }\n    return { next, added, dups, overflow };\n  };\n\n  const lookUp = (added: TagInputTag[]) => {\n    if (!resolve) return;\n    const run = added.filter((t) => !t.error && !t.label);\n    if (!run.length) return;\n    setResolving((s) => new Set([...s, ...run.map((t) => t.id)]));\n    for (const tag of run) {\n      Promise.resolve()\n        .then(() => resolve(tag.value))\n        .catch(() => null)\n        .then((result) => {\n          if (!alive.current) return;\n          const found = result as { label?: string; image?: string } | null | undefined;\n          const fill = found && (found.label || found.image) ? { ...(found.label ? { label: found.label } : {}), ...(found.image ? { image: found.image } : {}) } : null;\n          const current = tagsRef.current;\n          stage({});\n          setResolving((s) => {\n            const n = new Set(s);\n            n.delete(tag.id);\n            return n;\n          });\n          if (fill && current.some((t) => t.id === tag.id)) {\n            commit(current.map((t) => (t.id === tag.id ? { ...t, ...fill } : t)));\n            if (fill.label) say(`${tag.value} is ${fill.label}.`);\n          }\n        });\n    }\n  };\n\n  /** Adds values however they came: typed (the letters stay put), pasted (they spill from the caret) or picked (carried from the list). */\n  const add = (drafts: Draft[], how: How) => {\n    const result = plan(drafts, tagsRef.current);\n    const { dx, dy } = rootOffset();\n    const delayOf = (i: number) => (\"paste\" in how ? Math.min(i * 50, 500) : 0);\n    const enter = new Map<string, TokenEnter>();\n    const shake: Pending[\"shake\"] = [];\n    const flights: Flight[] = [];\n\n    result.added.forEach((t, i) => {\n      if (\"type\" in how) enter.set(t.id, how.type ? { from: \"text\", box: how.type } : { from: \"text\" });\n      else if (\"paste\" in how) enter.set(t.id, { from: \"point\", x: how.paste.x, y: how.paste.y, delay: delayOf(i) });\n      else {\n        enter.set(t.id, { from: \"text\", hold: 480 });\n        flights.push({ kind: \"carry\", text: display(t), from: how.pick, twin: t.id, delay: 0 });\n      }\n      if (t.error) shake.push({ id: t.id, delay: delayOf(i) + 380 });\n    });\n\n    result.dups.forEach((d) => {\n      const word = display(d.draft);\n      const w = textWidth(word) * (13 / 14) + 20;\n      // A typed or picked repeat leaves from where it was; a pasted one turns up, in its turn, where it would have gone.\n      const from =\n        \"pick\" in how\n          ? { x: how.pick.x - 10, y: how.pick.y + how.pick.h / 2 - CHIP_H / 2, w, h: CHIP_H }\n          : \"paste\" in how\n            ? { after: d.after, w }\n            : how.type\n              ? { x: how.type.x - 10 + dx, y: how.type.y + how.type.h / 2 - CHIP_H / 2 + dy, w, h: CHIP_H }\n              : { x: dx, y: dy, w, h: CHIP_H };\n      flights.push({ kind: \"fly\", text: word, from, twin: d.twin, delay: delayOf(d.index) });\n    });\n\n    stage({ enter, shake, flights, skipInput: \"type\" in how && !!how.type, shakeField: result.overflow > 0 });\n    if (result.added.length) commit(result.next);\n    // The list stays closed until you type again.\n    setDismissed(true);\n    lookUp(result.added);\n\n    if (result.overflow) flash(limitMessage, \"warn\");\n    else if (result.dups.length)\n      flash(result.dups.length === 1 ? `${display(result.dups[0].draft)} is already here` : `${result.dups.length} were already here`, \"muted\");\n    const invalid = result.added.filter((t) => t.error).length;\n    say(\n      [\n        result.added.length === 1 ? `Added ${display(result.added[0])}.` : result.added.length ? `Added ${result.added.length}.` : \"\",\n        invalid ? `${invalid} not valid.` : \"\",\n        result.dups.length ? `${result.dups.length} already added.` : \"\",\n        result.overflow ? `${limitMessage}.` : \"\",\n      ]\n        .filter(Boolean)\n        .join(\" \"),\n    );\n  };\n\n  /** The typed text becomes a chip where it stands. */\n  const commitText = (raw = text) => {\n    const words = raw.split(separators).filter((w) => w.trim());\n    if (!words.length) return;\n    if (words.length > 1) {\n      const point = caretPoint();\n      setText(\"\");\n      add(words.map((w) => ({ value: w })), { paste: point });\n      return;\n    }\n    const lead = raw.length - raw.trimStart().length;\n    const box = raw === text ? textBox(lead, words[0].trim()) : null;\n    setText(\"\");\n    add([{ value: words[0] }], { type: box });\n  };\n\n  const pasteText = (pasted: string, start: number, end: number) => {\n    const combined = text.slice(0, start) + pasted + text.slice(end);\n    const point = caretPoint();\n    setText(\"\");\n    add(combined.split(separators).map((w) => ({ value: w })), { paste: point });\n  };\n\n  const ghostOf = (t: TagInputTag, kind: Ghost[\"kind\"], snap: TokenSnapshot): Ghost | null => {\n    const was = snap.chips.get(t.id);\n    if (!was) return null;\n    const { dx, dy } = rootOffset();\n    const named = !!t.label && t.label !== t.value;\n    return {\n      key: ++ghostKey.current,\n      kind,\n      text: display(t),\n      box: shift(was.screen, dx, dy),\n      inner: shift(was.screenLabel, dx, dy),\n      error: !!t.error,\n      avatar: !t.error && (t.image || named) ? { image: t.image, name: display(t) } : undefined,\n    };\n  };\n\n  const remove = (id: string, focus?: \"input\" | string) => {\n    const tag = tagsRef.current.find((t) => t.id === id);\n    const list = listRef.current;\n    if (!tag || !list || disabled) return;\n    const before = snapshotTokens(list);\n    const ghost = ghostOf(tag, \"leave\", before);\n    stage({ ghosts: ghost ? [ghost] : [], focus }, before);\n    commit(tagsRef.current.filter((t) => t.id !== id));\n    say(`Removed ${display(tag)}.`);\n  };\n\n  /** Back to text: the shell drops away and its letters become what you're typing. */\n  const edit = (id: string) => {\n    const tag = tagsRef.current.find((t) => t.id === id);\n    const list = listRef.current;\n    if (!tag || !list || disabled) return;\n    const before = snapshotTokens(list);\n    const ghost = ghostOf(tag, \"unspool\", before);\n    // Anything already typed becomes chips first.\n    const typed = plan(\n      text\n        .split(separators)\n        .filter((w) => w.trim())\n        .map((w) => ({ value: w })),\n      tagsRef.current.filter((t) => t.id !== id),\n    );\n    stage(\n      {\n        ghosts: ghost ? [ghost] : [],\n        skipInput: true,\n        inputFrom: ghost?.inner ? { box: ghost.inner, fade: display(tag) !== tag.value } : undefined,\n        focus: \"input\",\n      },\n      before,\n    );\n    commit(typed.next);\n    lookUp(typed.added);\n    setText(tag.value);\n    setDismissed(true);\n    say(`Editing ${tag.value}.`);\n  };\n\n  const clear = () => {\n    const list = listRef.current;\n    if (!list || !tagsRef.current.length) return;\n    const before = snapshotTokens(list);\n    stage({ ghosts: tagsRef.current.flatMap((t) => ghostOf(t, \"leave\", before) ?? []) }, before);\n    commit([]);\n    say(\"Cleared.\");\n  };\n\n  React.useImperativeHandle(ref, () => ({ paste: (s: string) => pasteText(s, text.length, text.length), focus: () => inputRef.current?.focus(), clear }));\n\n  // ── Playing a change ───────────────────────────────────\n  // TextMorph commits a label's new letters one render after it gets the text, so measure one render later (still before\n  // paint): otherwise a chip that resolves to a shorter name would change width after the glide was measured.\n  const [playTick, setPlayTick] = React.useState(0);\n  React.useLayoutEffect(() => {\n    if (pending.current) setPlayTick((t) => t + 1);\n  }, [tick]);\n  React.useLayoutEffect(() => {\n    const p = pending.current;\n    const list = listRef.current;\n    if (!p || !list) return;\n    pending.current = null;\n    const after = p.before ? playTokens(list, p.before, { enter: p.enter, skipInput: p.skipInput, reduced }) : snapshotTokens(list);\n    const chip = (id: string) => list.querySelector<HTMLElement>(`[data-token=\"${CSS.escape(id)}\"]`);\n    p.shake.forEach((s) => shakeToken(chip(s.id), reduced, s.delay));\n    if (p.shakeField) shakeToken(fieldRef.current, reduced);\n\n    if (!reduced) {\n      const { dx, dy } = rootOffset();\n      const flights: Ghost[] = p.flights.flatMap((f) => {\n        const twin = after.chips.get(f.twin);\n        if (!twin) return [];\n        let box: TokenBox;\n        if (\"after\" in f.from) {\n          // Where it would have landed: after the chip before it, or at the start of the next line if it doesn't fit.\n          const prev = f.from.after ? after.chips.get(f.from.after)?.box : undefined;\n          let x = prev ? prev.x + prev.w + GAP : PAD;\n          let y = prev ? prev.y : PAD;\n          if (x + f.from.w > list.clientWidth - PAD) {\n            x = PAD;\n            y += CHIP_H + PAD;\n          }\n          box = { x: x + dx, y: y + dy, w: f.from.w, h: CHIP_H };\n        } else box = f.from;\n        return [{ key: ++ghostKey.current, kind: f.kind, text: f.text, box, to: shift(f.kind === \"carry\" ? twin.label : twin.box, dx, dy), twin: f.twin, delay: f.delay, appear: \"after\" in f.from }];\n      });\n      if (p.ghosts.length || flights.length) setGhosts((g) => [...g, ...p.ghosts, ...flights]);\n\n      const input = inputRef.current;\n      if (p.inputFrom && input) {\n        const i = input.getBoundingClientRect();\n        const root = rootRef.current!.getBoundingClientRect();\n        const from = p.inputFrom.box;\n        input.parentElement!.animate(\n          [{ transform: `translate(${from.x - (i.left - root.left)}px, ${from.y + from.h / 2 - (i.top - root.top + i.height / 2)}px)` }, { transform: \"none\" }],\n          { duration: 440, easing: EASE },\n        );\n        if (p.inputFrom.fade)\n          input.animate([{ opacity: 0, filter: \"blur(4px)\" }, { opacity: 1, filter: \"blur(0px)\" }], { duration: 360, delay: 80, easing: EASE, fill: \"backwards\" });\n      }\n    }\n\n    const input = inputRef.current;\n    if (p.focus === \"input\" && input) {\n      quietFocus.current = document.activeElement !== input;\n      input.focus({ preventScroll: true });\n      const end = input.value.length;\n      input.setSelectionRange(end, end);\n    } else if (p.focus) chip(p.focus)?.focus({ preventScroll: true });\n    // Widths settle here (after TextMorph), so the folded count is measured again too.\n    measure();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [playTick]);\n\n  const ghostDone = React.useCallback(\n    (ghost: Ghost) => {\n      setGhosts((g) => g.filter((x) => x.key !== ghost.key));\n      if (ghost.kind === \"fly\" && ghost.twin) pulseToken(listRef.current?.querySelector<HTMLElement>(`[data-token=\"${CSS.escape(ghost.twin)}\"]`), reduced);\n    },\n    [reduced],\n  );\n\n  // ── Height and collapse ────────────────────────────────\n  const measure = React.useCallback(() => {\n    const list = listRef.current;\n    const wrap = wrapRef.current;\n    if (!list || !wrap) return;\n    let next: typeof cut = null;\n    if (collapsedRef.current && collapse !== undefined) {\n      const chips = Array.from(list.querySelectorAll<HTMLElement>(\"[data-token]\"));\n      const lines = [...new Set(chips.map((c) => c.offsetTop))].sort((a, b) => a - b);\n      if (lines.length > collapse) {\n        const last = lines[Math.max(0, collapse - 1)];\n        const limit = list.clientWidth - PAD - (pillRef.current?.offsetWidth ?? 40) - GAP;\n        const hidden: string[] = [];\n        let x = PAD;\n        for (const c of chips) {\n          if (c.offsetTop < last) continue;\n          if (c.offsetTop === last && !hidden.length && c.offsetLeft + c.offsetWidth <= limit) x = c.offsetLeft + c.offsetWidth + GAP;\n          else hidden.push(c.dataset.token!);\n        }\n        next = { hidden, x, y: last, height: last + CHIP_H + PAD };\n      }\n    }\n    setCut((c) => (c === next || (c && next && c.x === next.x && c.y === next.y && c.height === next.height && c.hidden.join() === next.hidden.join()) ? c : next));\n    const target = `${next ? next.height : list.offsetHeight}px`;\n    if (wrap.style.height !== target) {\n      wrap.style.transition = wrap.dataset.ready && !reduced ? `height 420ms ${EASE}` : \"none\";\n      wrap.style.height = target;\n      wrap.dataset.ready = \"1\";\n    }\n  }, [collapse, reduced]);\n\n  React.useLayoutEffect(() => {\n    measure();\n  }, [measure, collapsed, tags, resolving]);\n\n  React.useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const observer = new ResizeObserver(() => measure());\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [measure]);\n\n  // ── Suggestions ────────────────────────────────────────\n  React.useEffect(() => {\n    if (!suggestions || !inputFocus) return;\n    const q = text.trim().toLowerCase();\n    const taken = new Set(tags.map((t) => t.value));\n    const keep = (found: TagInputSuggestion[]) => found.filter((s) => !taken.has(normalize(s.value))).slice(0, 6);\n    if (Array.isArray(suggestions)) {\n      setOptions(keep(q ? suggestions.filter((s) => [s.value, s.label, s.description].some((f) => f?.toLowerCase().includes(q))) : suggestions));\n      return;\n    }\n    let live = true;\n    Promise.resolve(suggestions(text.trim())).then((found) => {\n      if (live) setOptions(keep(found));\n    });\n    return () => {\n      live = false;\n    };\n  }, [text, tags, suggestions, inputFocus, normalize]);\n\n  const query = text.trim();\n  const exact = options.some((o) => normalize(o.value) === normalize(query) || o.label?.toLowerCase() === query.toLowerCase());\n  const rows: Row[] = [...options, ...(createLabel && query && !exact ? [{ value: query, label: createLabel(query), create: true }] : [])];\n  const open = !!suggestions && inputFocus && !dismissed && rows.length > 0 && !disabled;\n  const activeRow = open ? Math.min(active, rows.length - 1) : -1;\n  // While the list folds away it keeps the rows it had, rather than flashing what an empty query would match.\n  const lastRows = React.useRef(rows);\n  if (open) lastRows.current = rows;\n  const shownRows = open ? rows : lastRows.current;\n\n  // The first match is ready for Enter while you type; nothing is while the field is empty.\n  React.useEffect(() => {\n    setActive(text.trim() ? 0 : -1);\n  }, [text, options.length]);\n\n  const pick = (row: Row, index: number) => {\n    setDismissed(true);\n    if (row.create) return commitText(row.value);\n    const label = rowRefs.current[index]?.querySelector(\"[data-option-label]\")?.getBoundingClientRect();\n    const root = rootRef.current!.getBoundingClientRect();\n    const from = label ? { x: label.left - root.left, y: label.top - root.top, w: label.width, h: label.height } : { x: 0, y: 0, w: 1, h: 18 };\n    setText(\"\");\n    add([{ value: row.value, label: row.label, image: row.image }], { pick: from });\n  };\n\n  // The highlight glides between rows; its first placement doesn't.\n  const [mark, setMark] = React.useState({ y: 0, h: 0, on: false, slide: false });\n  React.useLayoutEffect(() => {\n    const el = rowRefs.current[activeRow];\n    setMark((m) => {\n      if (!el) return m.on ? { ...m, on: false, slide: false } : m;\n      const next = { y: el.offsetTop, h: el.offsetHeight, on: true, slide: m.on };\n      return m.y === next.y && m.h === next.h && m.on === next.on && m.slide === next.slide ? m : next;\n    });\n  }, [activeRow, rows.length, open]);\n  React.useEffect(() => {\n    if (activeRow >= 0) rowRefs.current[activeRow]?.scrollIntoView({ block: \"nearest\" });\n  }, [activeRow]);\n\n  // ── Keys ───────────────────────────────────────────────\n  const ids = tags.map((t) => t.id);\n  const focusChip = (id: string | undefined) => {\n    if (!id) return inputRef.current?.focus();\n    listRef.current?.querySelector<HTMLElement>(`[data-token=\"${CSS.escape(id)}\"]`)?.focus();\n  };\n\n  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const input = e.currentTarget;\n    if (open && (e.key === \"ArrowDown\" || e.key === \"ArrowUp\")) {\n      e.preventDefault();\n      const n = rows.length;\n      setActive((a) => (e.key === \"ArrowDown\" ? (a + 1) % n : a <= 0 ? n - 1 : a - 1));\n    } else if (e.key === \"ArrowDown\" && suggestions && dismissed) {\n      e.preventDefault();\n      setDismissed(false);\n    } else if (e.key === \"Enter\") {\n      if (open && activeRow >= 0) {\n        e.preventDefault();\n        pick(rows[activeRow], activeRow);\n      } else if (text.trim()) {\n        e.preventDefault();\n        commitText();\n      }\n    } else if (e.key === \"Tab\" && !e.shiftKey && text.trim()) {\n      commitText();\n    } else if (e.key === \"Escape\" && open) {\n      e.preventDefault();\n      setDismissed(true);\n    } else if ((e.key === \"Backspace\" || e.key === \"ArrowLeft\") && input.selectionStart === 0 && input.selectionEnd === 0 && ids.length) {\n      e.preventDefault();\n      focusChip(ids[ids.length - 1]);\n    }\n  };\n\n  const onChipKey = (e: React.KeyboardEvent<HTMLElement>, id: string) => {\n    const i = ids.indexOf(id);\n    const keys: Record<string, () => void> = {\n      ArrowLeft: () => focusChip(ids[Math.max(0, i - 1)]),\n      ArrowRight: () => focusChip(ids[i + 1]),\n      Home: () => focusChip(ids[0]),\n      End: () => focusChip(undefined),\n      Escape: () => focusChip(undefined),\n      Backspace: () => edit(id),\n      Enter: () => edit(id),\n      Delete: () => remove(id, ids[i + 1] ?? \"input\"),\n    };\n    const run = keys[e.key];\n    if (!run) return;\n    e.preventDefault();\n    run();\n  };\n\n  const onType = (next: string) => {\n    if (!separators.test(next)) {\n      setDismissed(false);\n      return setText(next);\n    }\n    const words = next.split(separators);\n    const rest = words.pop() ?? \"\";\n    const done = words.filter((w) => w.trim());\n    if (done.length === 1) {\n      const lead = next.length - next.trimStart().length;\n      const box = textBox(lead, done[0].trim());\n      setText(rest);\n      add([{ value: done[0] }], { type: box });\n    } else if (done.length > 1) {\n      const point = caretPoint();\n      setText(rest);\n      add(done.map((w) => ({ value: w })), { paste: point });\n    } else setText(rest);\n  };\n\n  // ── Render ─────────────────────────────────────────────\n  const invalid = tags.find((t) => t.error);\n  const line = notice ?? (invalid ? { text: invalid.error!, tone: \"error\" as const } : hint ? { text: hint, tone: \"muted\" as const } : null);\n  const lineId = `${uid}-line`;\n  const listId = `${uid}-options`;\n  const hidden = new Set(cut?.hidden ?? []);\n  const tone = line?.tone === \"error\" ? \"text-red-600 dark:text-red-400\" : line?.tone === \"warn\" ? \"text-amber-700 dark:text-amber-400\" : \"text-muted-foreground\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative w-full text-foreground ${className}`}\n      onFocus={() => setWithin(true)}\n      onBlur={(e) => {\n        const root = e.currentTarget;\n        // A chip that's removed while focused takes focus with it for a moment; look again once things settle.\n        requestAnimationFrame(() => {\n          if (root.contains(document.activeElement)) return;\n          setWithin(false);\n          setSelected(null);\n        });\n      }}\n      {...props}\n    >\n      <span ref={mirrorRef} aria-hidden=\"true\" className=\"pointer-events-none invisible absolute left-0 top-0 whitespace-pre text-[14px] [@media(pointer:coarse)]:text-[16px]\" />\n\n      <div className=\"relative\">\n        <div\n          ref={fieldRef}\n          onMouseDown={(e) => {\n            if (disabled || (e.target as HTMLElement).closest(\"[data-token], button, input\")) return;\n            e.preventDefault();\n            inputRef.current?.focus();\n          }}\n          className={`cursor-text rounded-[19px] border bg-background transition-[border-color,opacity] duration-200 ${disabled ? \"cursor-default opacity-60\" : \"\"}`}\n          style={{ borderColor: inputFocus ? \"color-mix(in oklab, var(--ring) 55%, var(--border))\" : \"var(--border)\" }}\n        >\n          {/* clip, not hidden: a hidden box can still be scrolled, and focusing the input would scroll the chips away. */}\n          <div ref={wrapRef} className=\"overflow-clip\">\n            <div ref={listRef} className=\"relative flex flex-wrap items-center gap-x-1.5\" style={{ padding: PAD, rowGap: PAD }}>\n              <div role=\"list\" aria-label={label} className=\"contents\">\n                {tags.map((t, i) => {\n                  const busy = resolving.has(t.id);\n                  const shown = display(t);\n                  const named = !!t.label && t.label !== t.value;\n                  const slot = busy || !!t.error || !!t.image || named;\n                  const gone = hidden.has(t.id);\n                  return (\n                    <span\n                      key={t.id}\n                      data-token={t.id}\n                      role=\"listitem\"\n                      tabIndex={-1}\n                      aria-label={`${shown}${named ? `, ${t.value}` : \"\"}${t.error ? `, ${t.error}` : \"\"}${busy ? \", looking up\" : \"\"}`}\n                      aria-describedby={`${uid}-keys`}\n                      title={shown.length > LONG ? shown : undefined}\n                      onFocus={() => setSelected(t.id)}\n                      onBlur={() => setSelected((s) => (s === t.id ? null : s))}\n                      onKeyDown={(e) => onChipKey(e, t.id)}\n                      onMouseDown={() => {\n                        wasSelected.current = selected === t.id;\n                      }}\n                      onClick={(e) => {\n                        if (wasSelected.current && !(e.target as HTMLElement).closest(\"button\")) edit(t.id);\n                      }}\n                      className={`relative inline-flex h-7 max-w-full shrink-0 cursor-default select-none items-center rounded-full text-[13px] outline-none will-change-transform ${\n                        t.error ? \"text-red-600 dark:text-red-400\" : \"text-foreground\"\n                      }`}\n                      style={{\n                        opacity: gone ? 0 : 1,\n                        filter: gone ? \"blur(3px)\" : \"none\",\n                        pointerEvents: gone ? \"none\" : undefined,\n                        transition: reduced ? \"none\" : `opacity 260ms ${EASE} ${gone ? 0 : Math.min(i * 18, 180)}ms, filter 260ms ${EASE}`,\n                      }}\n                    >\n                      <span\n                        data-token-shell=\"\"\n                        aria-hidden=\"true\"\n                        className=\"absolute inset-y-0 left-0 w-full rounded-full transition-[background-color,box-shadow] duration-200\"\n                        style={shellStyle(!!t.error, selected === t.id)}\n                      />\n                      <span data-token-extra=\"\" aria-hidden=\"true\" className=\"relative flex h-5 shrink-0 items-center justify-center\" style={{ width: slot ? 20 : 0, marginLeft: slot ? 4 : 0 }}>\n                        <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(busy, reduced)}>\n                          <span className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent text-muted-foreground motion-reduce:animate-none ${busy ? \"animate-spin\" : \"\"}`} />\n                        </span>\n                        <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(!busy && !!t.error, reduced)}>\n                          <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\">\n                            <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n                            <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n                            <circle cx=\"8\" cy=\"11\" r=\"1\" fill=\"currentColor\" />\n                          </svg>\n                        </span>\n                        <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(!busy && !t.error && (!!t.image || named), reduced)}>\n                          {(t.image || named) && <Avatar image={t.image} name={shown} size={20} />}\n                        </span>\n                      </span>\n                      {/* Only a long label clips (inside its own box, which is what glides): a short one never cuts the letters\n                          of the address it's morphing from. */}\n                      <span\n                        data-token-label=\"\"\n                        className={`relative whitespace-nowrap py-1 ${shown.length > LONG ? \"min-w-0 max-w-[11rem] overflow-hidden\" : \"\"}`}\n                        style={{ marginLeft: slot ? 6 : 10, marginRight: 2 }}\n                      >\n                        <TextMorph animateWidth={false} aria-hidden=\"true\">\n                          {shown}\n                        </TextMorph>\n                      </span>\n                      <button\n                        type=\"button\"\n                        data-token-extra=\"\"\n                        tabIndex={-1}\n                        aria-label={`Remove ${shown}`}\n                        disabled={disabled}\n                        onMouseDown={(e) => e.preventDefault()}\n                        onClick={() => remove(t.id, rootRef.current?.contains(document.activeElement) ? \"input\" : undefined)}\n                        className=\"relative mr-1 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default\"\n                      >\n                        <Cross />\n                      </button>\n                    </span>\n                  );\n                })}\n              </div>\n\n              <span data-token-input=\"\" className=\"flex h-7 items-center pl-1.5\" style={{ flex: collapsed ? \"0 0 0px\" : \"1 1 7rem\", minWidth: collapsed ? 0 : \"7rem\" }}>\n                <input\n                  ref={inputRef}\n                  value={text}\n                  disabled={disabled}\n                  placeholder={tags.length ? undefined : placeholder}\n                  role=\"combobox\"\n                  aria-label={label}\n                  aria-expanded={open}\n                  aria-controls={open ? listId : undefined}\n                  aria-activedescendant={activeRow >= 0 ? `${uid}-opt-${activeRow}` : undefined}\n                  aria-autocomplete={suggestions ? \"list\" : undefined}\n                  aria-describedby={line ? lineId : undefined}\n                  autoComplete=\"off\"\n                  spellCheck={false}\n                  onFocus={() => {\n                    setInputFocus(true);\n                    if (!quietFocus.current) setDismissed(false);\n                    quietFocus.current = false;\n                  }}\n                  onBlur={(e) => {\n                    setInputFocus(false);\n                    if (!rootRef.current?.contains(e.relatedTarget as Node | null) && text.trim()) commitText();\n                  }}\n                  onChange={(e) => onType(e.target.value)}\n                  onPaste={(e) => {\n                    const pasted = e.clipboardData.getData(\"text\");\n                    if (!separators.test(pasted)) return;\n                    e.preventDefault();\n                    pasteText(pasted, e.currentTarget.selectionStart ?? text.length, e.currentTarget.selectionEnd ?? text.length);\n                  }}\n                  onKeyDown={onInputKey}\n                  className=\"h-7 w-full min-w-0 bg-transparent text-[14px] outline-none placeholder:text-muted-foreground disabled:cursor-default [@media(pointer:coarse)]:text-[16px]\"\n                />\n              </span>\n\n              {/* What folded away, counted at the end of the last line that shows. */}\n              <span\n                ref={pillRef}\n                aria-hidden=\"true\"\n                onMouseDown={(e) => {\n                  e.preventDefault();\n                  inputRef.current?.focus();\n                }}\n                className=\"absolute left-0 top-0 inline-flex h-7 cursor-pointer items-center rounded-full px-2.5 text-[12.5px] tabular-nums text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n                style={{\n                  transform: `translate(${cut?.x ?? PAD}px, ${cut?.y ?? PAD}px)`,\n                  opacity: cut ? 1 : 0,\n                  filter: cut ? \"none\" : \"blur(3px)\",\n                  pointerEvents: cut ? undefined : \"none\",\n                  transition: reduced ? \"none\" : `opacity 260ms ${EASE} ${cut ? 120 : 0}ms, filter 260ms ${EASE}`,\n                }}\n              >\n                +<NumberRoll value={cut?.hidden.length ?? 0} duration={600} />\n              </span>\n            </div>\n          </div>\n        </div>\n\n        {/* Suggestions open clear of the field and unfold from its edge. */}\n        <div\n          className=\"absolute inset-x-0 top-full z-20 pt-1.5\"\n          style={{\n            clipPath: open ? \"inset(0px -2px -2px -2px)\" : \"inset(0px -2px 100% -2px)\",\n            opacity: open ? 1 : 0,\n            visibility: open ? \"visible\" : \"hidden\",\n            transition: reduced ? \"none\" : `clip-path 340ms ${EASE}, opacity 200ms ${EASE}, visibility 0s linear ${open ? \"0s\" : \"340ms\"}`,\n          }}\n        >\n          <div\n            role=\"listbox\"\n            id={listId}\n            aria-label={`${label}: suggestions`}\n            onMouseDown={(e) => e.preventDefault()}\n            className=\"relative max-h-64 overflow-auto rounded-[14px] border border-border bg-popover p-1 text-popover-foreground\"\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-x-1 top-0 rounded-[10px] bg-accent\"\n              style={{\n                transform: `translateY(${mark.y}px)`,\n                height: mark.h,\n                opacity: mark.on ? 1 : 0,\n                transition: reduced ? \"none\" : `${mark.slide ? `transform 260ms ${EASE}, height 260ms ${EASE}, ` : \"\"}opacity 160ms ${EASE}`,\n              }}\n            />\n            {shownRows.map((row, i) => (\n              <div\n                key={`${row.create ? \"create\" : \"row\"}-${row.value}`}\n                id={`${uid}-opt-${i}`}\n                ref={(el) => {\n                  rowRefs.current[i] = el;\n                }}\n                role=\"option\"\n                aria-selected={i === activeRow}\n                onMouseMove={() => i !== activeRow && setActive(i)}\n                onClick={() => pick(row, i)}\n                className=\"relative flex min-h-10 cursor-pointer items-center gap-2.5 rounded-[10px] px-2 py-1.5\"\n              >\n                {row.create ? (\n                  <span className=\"flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n                    <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n                      <path d=\"M6 2.5v7M2.5 6h7\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n                    </svg>\n                  </span>\n                ) : (\n                  (row.image || row.label) && <Avatar image={row.image} name={display(row)} size={24} />\n                )}\n                <span className=\"flex min-w-0 flex-col\">\n                  <span className=\"truncate text-[13.5px] leading-tight\">\n                    <span data-option-label=\"\">{display(row)}</span>\n                  </span>\n                  {!row.create && (row.description || (row.label && row.label !== row.value)) && (\n                    <span className=\"truncate text-[12px] leading-tight text-muted-foreground\">{row.description ?? row.value}</span>\n                  )}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      {/* Hint, error or notice: one line that morphs, and folds away when there's nothing to say. */}\n      <div className=\"grid transition-[grid-template-rows] duration-300\" style={{ gridTemplateRows: line ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }}>\n        <div className=\"min-w-0 overflow-hidden\">\n          <p id={lineId} className={`pl-1 pt-2 text-[12.5px] transition-colors duration-300 ${tone}`}>\n            <Line text={line?.text ?? \"\"} reduced={reduced} />\n          </p>\n        </div>\n      </div>\n\n      {/* Ghosts: chips that leave, fly into a twin, drop their shell, or carry a name in. */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-30\">\n        {ghosts.map((g) => (\n          <GhostChip key={g.key} ghost={g} onDone={ghostDone} />\n        ))}\n      </div>\n\n      {name && <input type=\"hidden\" name={name} value={tags.filter((t) => !t.error).map((t) => t.value).join(\",\")} />}\n      <p id={`${uid}-keys`} className=\"sr-only\">\n        Backspace edits, Delete removes, arrow keys move between tags.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/use-token-flip.ts","type":"registry:component","content":"// Hairline UI · tag-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * TOKEN FLIP: chips that keep their place on screen\n *\n *   snapshot  where every chip, its label and the input sit in\n *             the layout, plus where they are on screen right\n *             now; taken at the keystroke, the paste or the\n *             click, never at the last render\n *   play      after React lays the new order out, every chip\n *             glides from its old place to its new one. Glides\n *             are additive: a change that lands mid-glide (a name\n *             resolving while a paste is still landing) adds its\n *             own offset on top, so nothing restarts or jumps.\n *             A chip that changed width eases its shell; a new\n *             chip grows its shell round the letters you typed,\n *             or spills out of the caret when pasted.\n *   ghosts    copies that leave, or fly into a twin, live in an\n *             overlay so the real list never waits for them\n *\n * Transforms, a shell's width and a one-off clip only; nothing\n * here runs per pointer move. Elements are found by data\n * attributes: data-token, data-token-shell, data-token-label,\n * data-token-extra and data-token-input.\n * ───────────────────────────────────────────────────────── */\n\nexport type TokenBox = { x: number; y: number; w: number; h: number };\nexport type TokenChip = {\n  /** Layout boxes in list coordinates, transforms ignored. */\n  box: TokenBox;\n  label: TokenBox;\n  /** The shell's width as drawn right now (mid-ease included). */\n  shell: number;\n  /** Where the chip and its label are on screen right now, in list coordinates. */\n  screen: TokenBox;\n  screenLabel: TokenBox;\n};\nexport type TokenSnapshot = { chips: Map<string, TokenChip>; input: TokenBox | null };\n\n/**\n * How a new chip arrives. \"text\": its letters stay where they were typed (box) or where they are (no box) while the\n * shell grows round them; `hold` keeps them hidden while a copy is carried in. \"point\": it spills out of a point (paste).\n */\nexport type TokenEnter = { from: \"text\"; box?: TokenBox; delay?: number; hold?: number } | { from: \"point\"; x: number; y: number; delay: number };\n\nexport const TOKEN_EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nexport const TOKEN_THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst GLIDE = 440;\nconst SPILL = 560;\nconst ENTER = \"token-enter\";\n\nconst chipsOf = (list: HTMLElement) => list.querySelectorAll<HTMLElement>(\"[data-token]\");\nconst part = (chip: HTMLElement, name: string) => chip.querySelector<HTMLElement>(`[data-token-${name}]`);\n\nconst screenBox = (el: Element, origin: DOMRect): TokenBox => {\n  const r = el.getBoundingClientRect();\n  return { x: r.left - origin.left, y: r.top - origin.top, w: r.width, h: r.height };\n};\n\n/** The layout box (offsets, so running transforms don't count) in list coordinates. */\nfunction layoutBox(el: HTMLElement, list: HTMLElement, origin: DOMRect): TokenBox {\n  let x = 0;\n  let y = 0;\n  let node: HTMLElement | null = el;\n  while (node && node !== list) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n    node = node.offsetParent as HTMLElement | null;\n  }\n  return node === list ? { x, y, w: el.offsetWidth, h: el.offsetHeight } : screenBox(el, origin);\n}\n\nexport function snapshotTokens(list: HTMLElement): TokenSnapshot {\n  const origin = list.getBoundingClientRect();\n  const chips = new Map<string, TokenChip>();\n  chipsOf(list).forEach((chip) => {\n    const label = part(chip, \"label\") ?? chip;\n    const shell = part(chip, \"shell\");\n    chips.set(chip.dataset.token!, {\n      box: layoutBox(chip, list, origin),\n      label: layoutBox(label, list, origin),\n      shell: shell ? parseFloat(getComputedStyle(shell).width) || chip.offsetWidth : chip.offsetWidth,\n      screen: screenBox(chip, origin),\n      screenLabel: screenBox(label, origin),\n    });\n  });\n  const input = list.querySelector<HTMLElement>(\"[data-token-input]\");\n  return { chips, input: input ? layoutBox(input, list, origin) : null };\n}\n\nconst moved = (dx: number, dy: number) => Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5;\n\n/** An offset that decays to nothing, added on top of whatever else is moving the element. */\nfunction glide(el: HTMLElement, dx: number, dy: number) {\n  el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"translate(0px, 0px)\" }], { duration: GLIDE, easing: TOKEN_EASE, composite: \"add\" });\n}\n\nconst stopMoving = (el: HTMLElement) =>\n  el.getAnimations().forEach((a) => (a.effect as KeyframeEffect | null)?.getKeyframes().some((k) => \"transform\" in k) && a.cancel());\n\n/** A pasted chip spills out of a point (list coordinates) after `delay`, and remembers it in case the layout moves first. */\nfunction spill(chip: HTMLElement, x: number, y: number, at: TokenBox, delay: number) {\n  chip.dataset.tokenFromX = String(x);\n  chip.dataset.tokenFromY = String(y);\n  const a = chip.animate(\n    [\n      { transform: `translate(${x - at.x}px, ${y - at.y}px) scale(0.86)`, opacity: 0, filter: \"blur(4px)\" },\n      { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n    ],\n    { duration: SPILL, delay, easing: TOKEN_EASE, fill: \"backwards\" },\n  );\n  a.id = ENTER;\n}\n\n/**\n * The layout moved while a chip was still spilling in. Stacking a glide on top would drift it (its own offset waits out\n * the delay while the glide decays), so aim it again: from its point if it hasn't left, or on from where it is now.\n */\nfunction reaim(chip: HTMLElement, entry: Animation, was: TokenChip, now: TokenBox) {\n  const delay = Number(entry.effect?.getTiming().delay ?? 0);\n  const t = Number(entry.currentTime ?? 0);\n  if (t < delay) {\n    stopMoving(chip);\n    spill(chip, Number(chip.dataset.tokenFromX), Number(chip.dataset.tokenFromY), now, delay - t);\n    return;\n  }\n  const { opacity, filter } = getComputedStyle(chip);\n  stopMoving(chip);\n  const s = was.screen;\n  const a = chip.animate(\n    [\n      { transform: `translate(${s.x + s.w / 2 - (now.x + now.w / 2)}px, ${s.y + s.h / 2 - (now.y + now.h / 2)}px) scale(${s.w / Math.max(1, now.w)})`, opacity, filter },\n      { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n    ],\n    { duration: Math.max(160, SPILL + delay - t), easing: TOKEN_EASE },\n  );\n  a.id = ENTER;\n}\n\n/**\n * Glide everything from `before` to where it sits now. `enter` says how chips that weren't there arrive; `skipInput`\n * keeps the input still (when typed text has just become a chip, the caret is already in place). Returns the new layout.\n */\nexport function playTokens(list: HTMLElement, before: TokenSnapshot, options: { enter?: Map<string, TokenEnter>; skipInput?: boolean; reduced: boolean }): TokenSnapshot {\n  const { enter, skipInput, reduced } = options;\n  const after = snapshotTokens(list);\n  if (reduced) return after;\n\n  for (const chip of chipsOf(list)) {\n    const id = chip.dataset.token!;\n    const now = after.chips.get(id)!;\n    const was = before.chips.get(id);\n    const shell = part(chip, \"shell\");\n    const label = part(chip, \"label\");\n\n    if (was) {\n      const entry = chip.getAnimations().find((a) => a.id === ENTER && a.playState === \"running\");\n      if (moved(was.box.x - now.box.x, was.box.y - now.box.y)) {\n        if (entry) reaim(chip, entry, was, now.box);\n        else glide(chip, was.box.x - now.box.x, was.box.y - now.box.y);\n      }\n      // Its width changed (a name arrived, an avatar opened): the shell eases from the width it's drawn at.\n      if (shell && Math.abs(was.shell - now.box.w) > 0.5) {\n        shell.getAnimations().forEach((a) => (a.effect as KeyframeEffect | null)?.getKeyframes().some((k) => \"width\" in k) && a.cancel());\n        shell.animate([{ width: `${was.shell}px` }, { width: `${now.box.w}px` }], { duration: GLIDE, easing: TOKEN_EASE });\n      }\n      const ldx = was.label.x - was.box.x - (now.label.x - now.box.x);\n      if (label && Math.abs(ldx) > 0.5) glide(label, ldx, 0);\n      continue;\n    }\n\n    const how = enter?.get(id);\n    if (!how) continue;\n    if (how.from === \"text\") {\n      // The letters stay where they were; the shell grows round them from their own outline.\n      const L = now.label;\n      const C = now.box;\n      const delay = how.delay ?? 0;\n      if (label && how.box) {\n        label.style.transformOrigin = \"left center\";\n        label.animate(\n          [{ transform: `translate(${how.box.x - L.x}px, ${how.box.y + how.box.h / 2 - (L.y + L.h / 2)}px) scale(${how.box.w / Math.max(1, L.w)})` }, { transform: \"none\" }],\n          { duration: GLIDE, delay, easing: TOKEN_EASE, fill: \"backwards\" },\n        );\n      }\n      if (label && how.hold) label.animate([{ opacity: 0 }, { opacity: 0 }], { duration: how.hold });\n      const left = Math.max(0, L.x - C.x - 3);\n      const right = Math.max(0, C.x + C.w - (L.x + L.w) - 3);\n      shell?.animate(\n        [\n          { clipPath: `inset(4px ${right}px 4px ${left}px round 999px)`, opacity: 0 },\n          { clipPath: `inset(0px 0px 0px 0px round 999px)`, opacity: 1 },\n        ],\n        { duration: 480, delay, easing: TOKEN_EASE, fill: \"backwards\" },\n      );\n      chip.querySelectorAll<HTMLElement>(\"[data-token-extra]\").forEach((el) =>\n        el.animate(\n          [\n            { opacity: 0, transform: \"scale(0.6)\", filter: \"blur(3px)\" },\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n          ],\n          { duration: 340, delay: delay + 140, easing: TOKEN_EASE, fill: \"backwards\" },\n        ),\n      );\n    } else {\n      // Pasted: the chip spills out of the caret and lands in its place.\n      spill(chip, how.x, how.y, now.box, how.delay);\n    }\n  }\n\n  const input = list.querySelector<HTMLElement>(\"[data-token-input]\");\n  if (input && before.input && after.input && !skipInput && moved(before.input.x - after.input.x, before.input.y - after.input.y))\n    glide(input, before.input.x - after.input.x, before.input.y - after.input.y);\n  return after;\n}\n\n/** A chip that's already here takes a little bump and its ring flashes: \"this one\". */\nexport function pulseToken(chip: HTMLElement | null | undefined, reduced: boolean) {\n  if (!chip || reduced) return;\n  chip.animate([{ transform: \"scale(1)\" }, { transform: \"scale(1.08)\" }, { transform: \"scale(1)\" }], { duration: 420, easing: TOKEN_THROW, composite: \"add\" });\n  part(chip, \"shell\")?.animate(\n    [{ boxShadow: \"inset 0 0 0 1px var(--ring)\" }, { boxShadow: \"inset 0 0 0 1px var(--ring)\", offset: 0.4 }, { boxShadow: \"inset 0 0 0 1px var(--border)\" }],\n    { duration: 900, easing: TOKEN_EASE },\n  );\n}\n\n/** The no: a short shake. */\nexport function shakeToken(el: HTMLElement | null | undefined, reduced: boolean, delay = 0) {\n  if (!el || reduced) return;\n  el.animate(\n    [{ transform: \"translateX(0)\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"translateX(0)\" }],\n    { duration: 380, delay, easing: \"ease-out\", composite: \"add\" },\n  );\n}\n\n/** Keyframes for a ghost leaving where it stood. */\nexport const LEAVE_FRAMES: Keyframe[] = [\n  { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n  { opacity: 0, transform: \"scale(0.86)\", filter: \"blur(4px)\" },\n];\n\n/** Keyframes for letters carried from one box onto another (same text), landing exactly. */\nexport function carryFrames(from: TokenBox, to: TokenBox): Keyframe[] {\n  const scale = to.w / Math.max(1, from.w);\n  return [\n    { transform: \"none\" },\n    { transform: `translate(${to.x - from.x}px, ${to.y + to.h / 2 - (from.y + from.h / 2)}px) scale(${Math.min(1.2, Math.max(0.8, scale))})` },\n  ];\n}\n\n/** Keyframes for a ghost flying from one box into another and dissolving as it arrives; `appear` fades it in first. */\nexport function flyFrames(from: TokenBox, to: TokenBox, appear = false): Keyframe[] {\n  // Centre onto centre at its own size: letters that grew or shrank in flight would read as a different chip.\n  const dx = to.x + to.w / 2 - (from.x + from.w / 2);\n  const dy = to.y + to.h / 2 - (from.y + from.h / 2);\n  return [\n    ...(appear\n      ? [\n          { transform: \"scale(0.86)\", opacity: 0, filter: \"blur(4px)\" },\n          { transform: \"none\", opacity: 1, filter: \"blur(0px)\", offset: 0.24 },\n        ]\n      : [{ transform: \"none\", opacity: 1, filter: \"blur(0px)\" }]),\n    { transform: `translate(${dx * 0.7}px, ${dy * 0.7}px)`, opacity: 0.85, filter: \"blur(0px)\", offset: appear ? 0.62 : 0.5 },\n    { transform: `translate(${dx}px, ${dy}px) scale(0.92)`, opacity: 0, filter: \"blur(3px)\" },\n  ];\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/tag-input. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}