{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "token-input",
  "type": "registry:ui",
  "title": "Token input",
  "description": "A field that collects short values as removable tokens: recipients, sender domains, tags. Enter, a comma or blur adds what you typed, a pasted list is split, and Backspace or the x removes one. The Input's own size, edge, label and focus ring, with the Tag's look inside. A long list folds into a +N token until the field is focused.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/token-input.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/token-input 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * TokenInput: a field that collects short values as removable tokens, such as the sender\n * domains of a mail rule, the recipients of an invoice or the tags of a project. It is the\n * Input's sibling (components.md, \"Token input\"): same field, same label and hint.\n *\n * - Bodies (proposals/worlds.md): the contract below is one, and the material picks the look.\n *   Press is the Input's cut (--litt-slot-shade with --litt-field-shade: the top wall shaded, the lower wall lit) with each\n *   token a block standing out of it (--litt-block with --litt-highlight and --litt-key-lift) and a small block on it (--litt-block with\n *   --litt-block-lift) that removes it. Flat is a grey pill field, the tokens darker grey pills with a cross, and the \"+N\"\n *   bare words. Device is the Input's glass (bodies/token-input.device.tsx), each token an inverted cell of lit mono\n *   words with a small key. The body decides the field, the chip, the remove button and the \"+N\", through TokenSkin.\n * - Field: heights are minimums on the shared scale (sm 32, md 44, lg 56), and the 2px focus ring wraps the whole\n *   field while its text input is focus-visible. An invalid field draws a --litt-danger line. Like the Input it has no\n *   hover. Padding sits on the 4px grid, so the field grows by whole token rows (24px tokens, 4px apart) as they wrap.\n * - Token: 24px, the sans at the caption size, corners the control radius less 4px, with a remove button that grows to\n *   a 44px hit area on coarse pointers through an invisible ::before. The \"+N\" stands for tokens, it is not one: no\n *   button. The placeholder shows only while there are no tokens.\n * - Adding: Enter, a comma and blur commit the typed text, and a paste is split on commas,\n *   semicolons and line breaks. Values are trimmed, and empty ones and case-insensitive\n *   duplicates are ignored. A value that `validate` rejects stays in the input with its message\n *   until the text changes. Blur is the field losing focus, so moving to a remove button\n *   inside it does not commit.\n * - Removing: the x, Backspace in an empty input, and Backspace or Delete on a remove button.\n *   The remove buttons are one roving tab stop, and the arrow keys move between them and the\n *   input. A polite live region says what was added and removed.\n * - Disabled: the Input's, flat (surface-2 fill, --litt-line hairline, no shade), and the tokens lie flat too, as plain\n *   Tags. The labels stay at --litt-fg-subtle because they are still read, and the remove buttons are disabled and left\n *   out of Tab.\n * - Collapse: while the field is not focused and holds more tokens than `collapseAfter`, it\n *   shows that many and a \"+N\" token, and it opens when it takes focus.\n * - Motion (responsive tier, hooks only): a new token fades in with a 0.98 scale, its neighbors\n *   and the input move with `layout=\"position\"`, and a removed token fades out on the exit\n *   token. The edge and the ring fade on the instant tier. Nothing else moves.\n * - Phones: 16px text in the input on coarse pointers so iOS never zooms, the small field grows\n *   to 44, and the remove buttons are 44px targets. After a tap on x the input is not focused,\n *   so the keyboard does not open.\n */\nimport { useEffect, useId, useRef, useState, type ClipboardEvent, type FocusEventHandler, type KeyboardEvent, type MouseEventHandler, type ReactNode, type RefObject } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { byWorld, useDevice, useExit, useMaterial, useTransition } from \"../../lib/litt/motion\";\nimport { useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { TokenInputDeviceBody } from \"./bodies/token-input.device\";\nimport { TokenInputFlatBody } from \"./bodies/token-input.flat\";\n\nexport type TokenInputProps = {\n  /** The label above the field. Required, because a placeholder is not a label. Clicking it focuses the input. */\n  label: ReactNode;\n  /** A hint under the field. An error, a rejected value or the limit replaces it. */\n  hint?: ReactNode;\n  /** Shown in danger color under the field and linked with aria-describedby. Also draws the danger edge. Say how to fix it. */\n  error?: ReactNode;\n  /** The values, when you control them. Pair it with onValueChange. Values are unique, ignoring case. */\n  value?: string[];\n  /** The values it starts with when you do not control it. */\n  defaultValue?: string[];\n  /** Called with the new list whenever a value is added or removed. */\n  onValueChange?: (value: string[]) => void;\n  /** Checks a value before it is added. Return true to accept it, or a message that says how to fix it. A rejected value stays in the input with the message under the field until the text changes. */\n  validate?: (value: string) => string | true;\n  /** Hint text inside the empty field. It disappears once there is a token, and it is never a label. */\n  placeholder?: string;\n  /** Minimum height on the shared control scale: sm is 32, md is 44, lg is 56. The field grows taller as tokens wrap. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** The most values it holds. At the limit the input stops adding and the hint area says so. */\n  max?: number;\n  /** While the field is not focused and holds more values than this, it shows this many and a \"+N\" token. It opens on focus. */\n  collapseAfter?: number;\n  /** The field name a form sends the values under, once per value, through hidden inputs. */\n  name?: string;\n  /** Makes the field unavailable: muted, skipped by Tab, and left out of a form, like a native field. */\n  disabled?: boolean;\n  /** Extra classes for the whole component: the label, the field and the note under it. */\n  className?: string;\n};\n\n/** What a body decides about one token: its chip, its label, its remove button and the \"+N\" chip. The part owns the list, the motion and the handlers. */\nexport type TokenSkin = {\n  /** The chip around one token: its label and its remove button are the children. */\n  Chip: (props: { disabled: boolean; children: ReactNode }) => ReactNode;\n  /** The classes of the token's label. */\n  text: (disabled: boolean) => string;\n  /** The classes of the remove button, which keeps its name, its tab stop and its 44px target on coarse pointers. */\n  remove: string;\n  /** The cross in the remove button. */\n  Cross: () => ReactNode;\n  /** The classes of the \"+N\" chip. */\n  more: string;\n};\n\n/** The state the part resolves, and the pieces it owns, for a body to dress. A body draws the label, the field, the note and the live region. */\nexport type TokenInputBodyProps = {\n  size: \"sm\" | \"md\" | \"lg\";\n  invalid: boolean;\n  disabled: boolean;\n  className: string;\n  label: ReactNode;\n  labelId: string;\n  inputId: string;\n  note: ReactNode;\n  noteId: string;\n  /** Goes on the element that wraps the tokens and the input: it finds focus and takes a press on its own air. */\n  field: { ref: RefObject<HTMLDivElement | null>; onMouseDown: MouseEventHandler<HTMLDivElement>; onFocus: () => void; onBlur: FocusEventHandler<HTMLDivElement> };\n  /** The tokens and the \"+N\" chip, drawn with the body's skin. */\n  list: (skin: TokenSkin) => ReactNode;\n  /** The real text input, in its slot. */\n  input: ReactNode;\n  /** One hidden input per value when the field has a name. */\n  hidden: ReactNode;\n  /** What the polite live region says. */\n  said: string;\n};\n\n/* The field: the Input's cut (floor, line, shade) and its focus, error and disabled looks, on a\n   wrapper that holds the tokens. The ring follows the text input only (the remove buttons draw their own). */\nconst FIELD =\n  \"relative flex w-full min-w-0 cursor-text bg-litt-field font-litt-field font-normal text-litt-field-ink \" +\n  \"shadow-[var(--litt-slot-shade),var(--litt-field-shade)] \" +\n  \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[input:focus-visible]:shadow-[var(--litt-slot-shade),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"data-[invalid]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-slot-shade),var(--litt-field-shade)] \" +\n  \"data-[invalid]:has-[input:focus-visible]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-slot-shade),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  /* Forced colors drops box-shadow, so the edge becomes a real border and focus gets an outline. */\n  \"forced-colors:border forced-colors:has-[input:focus-visible]:outline-2 forced-colors:has-[input:focus-visible]:outline-solid forced-colors:has-[input:focus-visible]:outline-offset-2\";\n\n/* The corners: the control radius, but never past half the one-line height, so a field that wraps to more rows of\n   tokens keeps the corners it had on one line and stays concentric with the tokens inside, instead of turning into a\n   lozenge at the pill. On one line it is the same as the control radius. */\nconst RADIUS = {\n  sm: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-sm)/2))] pointer-coarse:rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  md: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  lg: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-lg)/2))]\",\n};\n\n/* Padding on the 4px grid. A 24px token row plus this air lands on 32, 44 (centered in the minimum) and 56. */\nconst FIELD_SIZE = {\n  sm: \"min-h-litt-control-sm px-1 py-1 pointer-coarse:min-h-litt-control-md\",\n  md: \"min-h-litt-control-md px-2 py-2\",\n  lg: \"min-h-litt-control-lg px-4 py-4\",\n};\n\n/* The text's size. The input inherits it, so 8ch is measured in the input's own font. */\nconst TEXT_SIZE = {\n  sm: \"text-litt-small\",\n  md: \"text-litt-body\",\n  lg: \"text-(length:--litt-control-text-lg)\",\n};\n\n/* Press: a small block on the token. It stands on the token's face (--litt-block with its --litt-highlight and --litt-block-lift) and\n   sinks the --litt-press-depth under a press, its cast stepping down to --litt-block-press. Nothing glides: the sink and the cast\n   are on the instant tier. A disabled token lies flat, so its key does too. */\nconst CORNER = \"rounded-[max(calc(var(--litt-radius-control)-4px),min(calc(var(--litt-radius-control)-2px),2px))]\";\nconst REMOVE =\n  \"relative grid size-5 shrink-0 cursor-pointer place-items-center \" + CORNER + \" bg-litt-block text-litt-fg-muted shadow-[var(--litt-highlight),var(--litt-block-lift)] \" +\n  \"transition-[color,transform,box-shadow] duration-(--litt-dur-instant) ease-litt hover:bg-(--litt-block-hover) hover:text-litt-fg \" +\n  \"active:shadow-[var(--litt-highlight),var(--litt-block-press)] active:[transform:translateY(var(--litt-press-depth))] \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus forced-colors:border \" +\n  \"disabled:cursor-not-allowed disabled:bg-transparent disabled:text-litt-fg-disabled disabled:shadow-none disabled:hover:bg-transparent disabled:hover:text-litt-fg-disabled \" +\n  /* An invisible 44px target around the 20px block on a finger. */\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-3\";\n\nconst SEPARATOR = /[,;\\r\\n]/;\n\n/** The text that went in between two states of the input: its change, found by trimming the shared start and end. */\nfunction inserted(before: string, after: string): string {\n  let start = 0;\n  while (start < before.length && start < after.length && before[start] === after[start]) start++;\n  let end = 0;\n  while (end < before.length - start && end < after.length - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++;\n  return after.slice(start, after.length - end);\n}\n\n/** A 16px cross, sitting with the caption-size label. */\nfunction RemoveIcon() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M5.5 5.5l5 5M10.5 5.5l-5 5\" />\n    </svg>\n  );\n}\n\n/* Press, the cut: today's field, with the Tag's raised block for each token. */\nconst PRESS_SKIN: TokenSkin = {\n  Chip: ({ disabled, children }) => (\n    <span\n      className={\n        \"inline-flex h-6 max-w-full min-w-0 shrink-0 items-center gap-1 whitespace-nowrap pr-0.5 pl-2 text-litt-caption \" +\n        CORNER +\n        (disabled ? \" bg-litt-surface-3 text-litt-fg-subtle\" : \" bg-litt-block text-litt-fg shadow-[var(--litt-highlight),var(--litt-key-lift)] forced-colors:border\")\n      }\n    >\n      {children}\n    </span>\n  ),\n  text: () => \"min-w-0 truncate\",\n  remove: REMOVE,\n  Cross: RemoveIcon,\n  /* \"+N\" stands for tokens and is not one: a shallow dent in the field, with no block to lift out. */\n  more: \"inline-flex h-6 shrink-0 cursor-default items-center bg-litt-tray px-2 text-litt-caption text-litt-fg shadow-(--litt-tray-shade) \" + CORNER + \" forced-colors:border\",\n};\n\nfunction PressBody({ size, invalid, disabled, className, label, labelId, inputId, note, noteId, field, list, input, hidden, said }: TokenInputBodyProps) {\n  return (\n    <div data-body=\"press\" className={\"grid gap-2 \" + className}>\n      <label id={labelId} htmlFor={inputId} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n        {label}\n      </label>\n\n      <div\n        ref={field.ref}\n        data-invalid={invalid ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={[FIELD, RADIUS[size], FIELD_SIZE[size], TEXT_SIZE[size], \"pointer-coarse:text-(length:--litt-control-text-lg)\"].join(\" \")}\n        onMouseDown={field.onMouseDown}\n        onFocus={field.onFocus}\n        onBlur={field.onBlur}\n      >\n        <div className=\"relative flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n          {list(PRESS_SKIN)}\n          {input}\n        </div>\n        {hidden}\n      </div>\n\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (invalid ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n\n/**\n * A labelled field that turns typed or pasted values into removable tokens. Enter or a comma adds what you typed, blur adds it\n * too so nothing is lost, and a pasted list is split into tokens. Backspace removes the last token; the arrow keys walk the\n * remove buttons. Hold the values in state with value and onValueChange, or give it a defaultValue and a name to post them with a form.\n */\nexport function TokenInput({\n  label,\n  hint,\n  error,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  validate,\n  placeholder,\n  size = \"md\",\n  max,\n  collapseAfter,\n  name,\n  disabled = false,\n  className = \"\",\n}: TokenInputProps) {\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const labelId = `${uid}-label`;\n  const noteId = `${uid}-note`;\n\n  const [inner, setInner] = useState<string[]>(defaultValue ?? []);\n  const value = valueProp ?? inner;\n  const [draft, setDraft] = useState(\"\");\n  const [rejection, setRejection] = useState<string | null>(null);\n  const [focused, setFocused] = useState(false);\n  const [active, setActive] = useState<string | null>(null);\n  const [said, setSaid] = useState(\"\");\n\n  const { coarse } = useDevice();\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const buttons = useRef(new Map<string, HTMLButtonElement>());\n  const after = useRef<{ token: string } | \"input\" | null>(null);\n\n  /* Focus lands where the removed token was, once the list has re-rendered. */\n  useEffect(() => {\n    const target = after.current;\n    if (!target) return;\n    after.current = null;\n    if (target !== \"input\") {\n      const next = buttons.current.get(target.token);\n      if (next) return next.focus();\n    }\n    inputRef.current?.focus();\n  });\n\n  /* A focused button that is removed fires no blur, so the field checks where focus really is once a token has gone. */\n  const syncFocus = () => setFocused(fieldRef.current?.contains(document.activeElement) ?? false);\n\n  const collapsed = !focused && collapseAfter !== undefined && value.length > collapseAfter;\n  const shown = collapsed ? value.slice(0, collapseAfter) : value;\n  const more = value.length - shown.length;\n  /* One roving tab stop among the remove buttons: the one last focused, else the last one in view. */\n  const stop = active !== null && shown.includes(active) ? active : shown[shown.length - 1];\n\n  const atMax = max !== undefined && value.length >= max;\n  const invalid = rejection !== null || !!error;\n  const note = rejection ?? error ?? (atMax ? `Limit of ${max} reached. Remove one to add another.` : hint);\n\n  function setValue(next: string[]) {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  }\n\n  /** Splits text on commas, semicolons and line breaks, then adds each part that passes. Rejected parts stay in the input. */\n  function commit(text: string) {\n    const parts = text.split(/[,;\\r\\n]+/).map((s) => s.trim()).filter(Boolean);\n    const next = [...value];\n    const taken = new Set(next.map((v) => v.toLowerCase()));\n    const added: string[] = [];\n    const kept: string[] = [];\n    let duplicate: string | null = null;\n    let message: string | null = null;\n    let full = false;\n    for (const part of parts) {\n      if (taken.has(part.toLowerCase())) {\n        duplicate ??= part;\n      } else if (max !== undefined && next.length >= max) {\n        full = true;\n      } else {\n        const verdict = validate ? validate(part) : true;\n        if (verdict !== true) {\n          kept.push(part);\n          message ??= verdict;\n        } else {\n          next.push(part);\n          taken.add(part.toLowerCase());\n          added.push(part);\n        }\n      }\n    }\n    if (added.length) setValue(next);\n    setDraft(kept.join(\", \"));\n    setRejection(message);\n    const spoken: string[] = [];\n    if (added.length) spoken.push(`Added ${added.join(\", \")}`);\n    if (message) spoken.push(message);\n    else if (full || (added.length && max !== undefined && next.length >= max)) spoken.push(`Limit of ${max} reached`);\n    else if (!added.length && duplicate) spoken.push(`${duplicate} is already added`);\n    if (spoken.length) setSaid(spoken.join(\". \"));\n  }\n\n  /** Removes a token. `stay` leaves focus alone; otherwise it moves to the next token or the input, except after a tap, so the keyboard stays shut. */\n  function remove(token: string, focus: \"stay\" | \"key\" | \"pointer\") {\n    const at = value.indexOf(token);\n    if (at < 0 || disabled) return;\n    const next = value.filter((v) => v !== token);\n    setValue(next);\n    setSaid(`Removed ${token}`);\n    if (focus === \"key\" || (focus === \"pointer\" && !coarse)) after.current = next[at] !== undefined ? { token: next[at] } : \"input\";\n  }\n\n  function focusButton(token: string | undefined) {\n    if (token !== undefined) buttons.current.get(token)?.focus();\n  }\n\n  function onInputKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n    if (e.nativeEvent.isComposing) return;\n    const el = e.currentTarget;\n    if (e.key === \"Enter\" && draft.trim() !== \"\") {\n      e.preventDefault(); // an empty field still submits its form\n      commit(draft);\n    } else if (e.key === \"Backspace\" && el.value === \"\" && value.length > 0) {\n      e.preventDefault();\n      remove(value[value.length - 1], \"stay\");\n    } else if (e.key === \"ArrowLeft\" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && el.selectionStart === 0 && el.selectionEnd === 0 && shown.length > 0) {\n      e.preventDefault();\n      focusButton(shown[shown.length - 1]);\n    }\n  }\n\n  function onPaste(e: ClipboardEvent<HTMLInputElement>) {\n    const text = e.clipboardData.getData(\"text\");\n    if (!SEPARATOR.test(text)) return; // one value lands in the input, to check before it is added\n    e.preventDefault(); // an input flattens line breaks, so the list is read from the clipboard itself\n    const el = e.currentTarget;\n    const from = el.selectionStart ?? draft.length;\n    commit(draft.slice(0, from) + text + draft.slice(el.selectionEnd ?? from));\n  }\n\n  function onRemoveKeyDown(e: KeyboardEvent<HTMLButtonElement>, token: string, at: number) {\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      focusButton(shown[Math.max(0, at - 1)]);\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      if (at < shown.length - 1) focusButton(shown[at + 1]);\n      else inputRef.current?.focus();\n    } else if (e.key === \"Backspace\" || e.key === \"Delete\") {\n      e.preventDefault();\n      remove(token, \"key\");\n    }\n  }\n\n  const material = useMaterial();\n  const om = useOverlayMotion();\n  /* Press and Flat grow and slide their neighbors; Device switches in place and nothing glides. */\n  const switches = material === \"device\" && om.switches;\n  const itemEnter = switches ? om.switchIn : enter;\n  const itemExit = switches ? om.switchOut : exit;\n  const Body = byWorld(material, { press: PressBody, flat: TokenInputFlatBody, device: TokenInputDeviceBody });\n\n  const list = (skin: TokenSkin) => (\n    <ul role=\"list\" aria-labelledby={labelId} className=\"contents\">\n      <AnimatePresence initial={false} mode=\"popLayout\" onExitComplete={syncFocus}>\n        {shown.map((token, at) => (\n          <motion.li\n            key={token}\n            layout={switches ? false : \"position\"}\n            className=\"flex max-w-full min-w-0 cursor-default\"\n            initial={{ opacity: 0, scale: switches ? 1 : 0.98 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: itemExit }}\n            transition={itemEnter}\n          >\n            <skin.Chip disabled={disabled}>\n              <span className={skin.text(disabled)}>{token}</span>\n              <button\n                type=\"button\"\n                ref={(el) => {\n                  if (el) buttons.current.set(token, el);\n                  else buttons.current.delete(token);\n                }}\n                aria-label={`Remove ${token}`}\n                tabIndex={token === stop ? 0 : -1}\n                disabled={disabled}\n                className={skin.remove}\n                onFocus={() => setActive(token)}\n                onKeyDown={(e) => onRemoveKeyDown(e, token, at)}\n                onClick={(e) => remove(token, e.detail === 0 ? \"key\" : \"pointer\")}\n              >\n                <skin.Cross />\n              </button>\n            </skin.Chip>\n          </motion.li>\n        ))}\n        {more > 0 && (\n          <motion.li\n            key=\"more\"\n            layout={switches ? false : \"position\"}\n            aria-label={`${more} more`}\n            className={skin.more}\n            initial={{ opacity: 0, scale: switches ? 1 : 0.98 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: itemExit }}\n            transition={itemEnter}\n          >\n            +{more}\n          </motion.li>\n        )}\n      </AnimatePresence>\n    </ul>\n  );\n\n  return (\n    <Body\n      size={size}\n      invalid={invalid}\n      disabled={disabled}\n      className={className}\n      label={label}\n      labelId={labelId}\n      inputId={inputId}\n      note={note}\n      noteId={noteId}\n      field={{\n        ref: fieldRef,\n        onMouseDown: (e) => {\n          /* A press on the field's own air focuses the input and keeps focus from falling off it. */\n          if (disabled || (e.target as HTMLElement).closest(\"button, input\")) return;\n          e.preventDefault();\n          inputRef.current?.focus();\n        },\n        onFocus: () => setFocused(true),\n        onBlur: (e) => {\n          if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; // moving inside the field\n          if (document.activeElement === e.target) return; // the window lost focus, not the field\n          setFocused(false);\n          if (draft.trim() !== \"\") commit(draft);\n        },\n      }}\n      list={list}\n      input={\n        <motion.div layout={switches ? false : \"position\"} transition={itemEnter} className={\"flex flex-1 \" + (atMax ? \"min-w-1\" : \"min-w-[8ch]\")}>\n          <input\n            ref={inputRef}\n            id={inputId}\n            type=\"text\"\n            value={draft}\n            placeholder={value.length === 0 ? placeholder : undefined}\n            disabled={disabled}\n            readOnly={atMax}\n            autoComplete=\"off\"\n            autoCapitalize=\"none\"\n            spellCheck={false}\n            aria-invalid={invalid || undefined}\n            aria-describedby={note ? noteId : undefined}\n            className={\n              \"box-content h-6 min-w-0 flex-1 bg-transparent pl-2 text-litt-field-ink outline-none placeholder:text-litt-field-ink-subtle \" +\n              \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:placeholder:text-litt-fg-disabled\"\n            }\n            onChange={(e) => {\n              const text = e.target.value;\n              if (SEPARATOR.test(inserted(draft, text))) return commit(text); // a typed comma, or a paste the browser kept whole\n              setDraft(text);\n              setRejection(null);\n            }}\n            onKeyDown={onInputKeyDown}\n            onPaste={onPaste}\n          />\n        </motion.div>\n      }\n      hidden={name && value.map((v) => <input key={v} type=\"hidden\" name={name} value={v} disabled={disabled} />)}\n      said={said}\n    />\n  );\n}\n",
      "target": "components/litt/token-input.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/token-input.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/token-input 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Token input, the Device body (proposals/worlds.md, Bodies; material.md, 8): the Input's glass, with the tokens as lit\n * cells on it.\n *\n * - The field is the Input's: a bezel well (--litt-tray, --litt-tray-shade, 2px of body between the glass and the wall) around a\n *   pane of LCD glass (--litt-field, --litt-field-shade, the mono through font-litt-field). It grows by whole rows of cells as they\n *   wrap, never under the control height (32, 44, 56). Focus and an error ring the well, not the glass.\n * - A token is an inverted cell: the glass lit in --litt-field-ink with its words dark on it, in the mono at the caption\n *   size. At its right end sits a small key (--litt-block, --litt-key-lift) that removes it. The key is a real button named\n *   \"Remove @stripe.com\", 20px, and a 44px target on coarse pointers through an invisible ::before. Pressed, it sinks the\n *   whole --litt-press-depth and its cast steps down (--litt-block-press).\n * - The \"+N\" stands for tokens and is not one, so it is plain lit words on the glass in --litt-field-ink-subtle, with no cell\n *   and no key.\n * - Disabled lies flat: a --litt-surface-2 well with a hairline, the glass dimmed, the cells plain --litt-surface-3 with their\n *   words in --litt-fg-subtle and the keys off.\n * - Motion: nothing glides. A token switches in and out in place in three steps on the instant tier, its neighbors do\n *   not slide, and the ring switches in steps too. Reduced motion zeroes the tier.\n * - The label is engraved on the body above the well (litt-legend). The hint is a dim LCD line under it, an error its\n *   words beside a lamp in --litt-danger, as the Input's note.\n * - Forced colors drops shadows and fills, so the well, the glass, the cells and the keys get real borders.\n */\nimport type { CSSProperties } from \"react\";\nimport { useDevice } from \"../../../lib/litt/motion\";\nimport type { TokenInputBodyProps, TokenSkin } from \"../token-input\";\n\n/* Switches in three steps on the instant tier, as the switch's detent does. */\nconst STEPS = \"steps(3, end)\";\nconst steps: CSSProperties = { transitionProperty: \"box-shadow\", transitionDuration: \"var(--litt-dur-instant)\", transitionTimingFunction: `${STEPS}` };\nconst stepsKey: CSSProperties = { transitionProperty: \"transform,box-shadow,color,background-color\", transitionDuration: \"var(--litt-dur-instant)\", transitionTimingFunction: `${STEPS}` };\n\n/* The bezel. It carries the field's state through :has and data, because the element inside is the one that takes focus. */\nconst WELL =\n  \"relative flex w-full min-w-0 cursor-text bg-litt-tray p-0.5 shadow-(--litt-tray-shade) \" +\n  \"has-[input:focus-visible]:shadow-[var(--litt-tray-shade),var(--litt-focus-ring)] \" +\n  \"data-[invalid]:shadow-[var(--litt-tray-shade),0_0_0_2px_var(--litt-danger)] \" +\n  \"data-[invalid]:has-[input:focus-visible]:shadow-[var(--litt-tray-shade),0_0_0_2px_var(--litt-danger),var(--litt-focus-ring)] \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border forced-colors:has-[input:focus-visible]:outline-2 forced-colors:has-[input:focus-visible]:outline-solid forced-colors:has-[input:focus-visible]:outline-offset-2\";\n\n/* The well's corners: the control radius, never past half the one-line height, so a field that wraps keeps the corners it had on one line. The glass's are the same less the 2px of bezel. */\nconst RADIUS = {\n  sm: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-sm)/2))] pointer-coarse:rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  md: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  lg: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-lg)/2))]\",\n};\nconst WELL_SIZE = {\n  sm: \"min-h-litt-control-sm pointer-coarse:min-h-litt-control-md\",\n  md: \"min-h-litt-control-md\",\n  lg: \"min-h-litt-control-lg\",\n};\n\n/* The glass, concentric with the well: its radius less the 2px of bezel. Its air plus a 24px row of cells fills the well. */\nconst GLASS_RADIUS = {\n  sm: \"calc(min(var(--litt-radius-control), calc(var(--litt-control-sm) / 2)) - 2px)\",\n  md: \"calc(min(var(--litt-radius-control), calc(var(--litt-control-md) / 2)) - 2px)\",\n  lg: \"calc(min(var(--litt-radius-control), calc(var(--litt-control-lg) / 2)) - 2px)\",\n};\nconst glassRadius = (size: \"sm\" | \"md\" | \"lg\", coarse: boolean): CSSProperties => ({ borderRadius: GLASS_RADIUS[size === \"sm\" && coarse ? \"md\" : size] });\nconst GLASS_SIZE = {\n  sm: \"px-1 py-0.5\",\n  md: \"px-2 py-2\",\n  lg: \"px-4 py-3.5\",\n};\nconst TEXT_SIZE = {\n  sm: \"text-litt-small\",\n  md: \"text-litt-body\",\n  lg: \"text-(length:--litt-control-text-lg)\",\n};\nconst GLASS =\n  \"flex min-w-0 flex-1 items-center bg-litt-field font-litt-field text-litt-field-ink shadow-(--litt-field-shade) forced-colors:border \" +\n  \"data-[disabled]:bg-transparent data-[disabled]:shadow-none\";\n\n/* The cell sits 8px in from the glass's walls (4px at sm), the key 2px in from the cell's. */\nconst CELL_RADIUS = \"rounded-[max(calc(var(--litt-radius-control)-4px),min(calc(var(--litt-radius-control)-2px),2px))]\";\nconst KEY_RADIUS = CELL_RADIUS;\n\nconst REMOVE =\n  \"relative grid size-5 shrink-0 cursor-pointer place-items-center select-none \" + KEY_RADIUS + \" bg-litt-block text-litt-fg shadow-(--litt-key-lift) \" +\n  \"hover:bg-(--litt-block-hover) active:shadow-(--litt-block-press) active:[transform:translateY(var(--litt-press-depth))] \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:text-litt-fg-disabled disabled:shadow-none disabled:hover:bg-litt-surface-2 \" +\n  \"forced-colors:border \" +\n  /* An invisible 44px target around the 20px key on a finger. */\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-3\";\n\n/** A 16px cross, drawn inside the 20px key. */\nfunction Cross() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M5.5 5.5l5 5M10.5 5.5l-5 5\" />\n    </svg>\n  );\n}\n\nconst SKIN: TokenSkin = {\n  Chip: ({ disabled, children }) => (\n    <span\n      className={\n        \"inline-flex h-6 max-w-full min-w-0 items-center gap-1 whitespace-nowrap pr-0.5 pl-2 text-litt-caption \" +\n        CELL_RADIUS +\n        (disabled ? \" bg-litt-surface-3 text-litt-fg-subtle\" : \" bg-litt-field-ink text-litt-field\") +\n        \" forced-colors:border\"\n      }\n      style={stepsKey}\n    >\n      {children}\n    </span>\n  ),\n  text: () => \"min-w-0 truncate\",\n  remove: REMOVE,\n  Cross,\n  more: \"inline-flex h-6 shrink-0 cursor-default items-center px-2 text-litt-caption text-litt-field-ink-subtle\",\n};\n\n/** Device, LCD glass in a bezel well, the tokens as lit cells with a key each. */\nexport function TokenInputDeviceBody({ size, invalid, disabled, className, label, labelId, inputId, note, noteId, field, list, input, hidden, said }: TokenInputBodyProps) {\n  const { coarse } = useDevice();\n  return (\n    <div data-body=\"device\" className={\"grid gap-2 \" + className}>\n      <label id={labelId} htmlFor={inputId} className={\"litt-legend text-litt-caption font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n        {label}\n      </label>\n\n      <div\n        ref={field.ref}\n        data-invalid={invalid ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={[WELL, RADIUS[size], WELL_SIZE[size]].join(\" \")}\n        style={steps}\n        onMouseDown={field.onMouseDown}\n        onFocus={field.onFocus}\n        onBlur={field.onBlur}\n      >\n        <div data-disabled={disabled ? \"\" : undefined} style={glassRadius(size, coarse)} className={[GLASS,GLASS_SIZE[size], TEXT_SIZE[size], \"pointer-coarse:text-(length:--litt-control-text-lg)\"].join(\" \")}>\n          <div className=\"relative flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n            {list(SKIN)}\n            {input}\n          </div>\n        </div>\n        {hidden}\n      </div>\n\n      {note && (\n        <p id={noteId} className=\"flex max-w-full items-center gap-2 font-litt-field text-litt-caption\">\n          {/* The error's lamp sits on the body beside the glass, clear of the glass's curve. */}\n          {invalid && (\n            <span aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-litt-pill bg-litt-danger shadow-[0_0_6px_var(--litt-danger)] forced-colors:bg-[Highlight]\" />\n          )}\n          <span\n            className={\n              \"w-fit max-w-full rounded-litt-control bg-litt-field px-2.5 py-1 shadow-(--litt-field-shade) forced-colors:border \" +\n              (invalid ? \"text-litt-field-ink\" : \"text-litt-field-ink-subtle\")\n            }\n          >\n            {note}\n          </span>\n        </p>\n      )}\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/token-input.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/token-input.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/token-input 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Token input, the Flat body (proposals/worlds.md, Bodies): ink on paper. Nothing stands and nothing is framed.\n *\n * - The field is a grey pill (--litt-cut, which is --litt-surface-3, at the control radius, never past half the one-line height,\n *   so a field that wraps keeps the corners it had on one line) with no outline and no shade, as the Input's.\n *   Focus is the ink ring, the one line it draws. An error draws no ring: its words are in --litt-danger and the fill\n *   tints toward --litt-danger on the color tier, as the Input's; aria-invalid and the words say it, never the tint alone.\n * - A token is a darker grey pill on it, the --litt-cut with 9% of --litt-fg mixed in, its words in ink at the label weight and\n *   a bare cross after them. Nothing is raised, lit or ruled. The cross is a real button named \"Remove @stripe.com\": a\n *   grey wash fills it on hover, it stays 24px, and it is a 44px target on coarse pointers through an invisible ::before.\n * - The \"+N\" stands for tokens and is not one, so it is bare words in --litt-fg-muted, no pill.\n * - The label is words above in ink at the title weight and the hint plain grey words below (--litt-fg-subtle), both set\n *   in from the edge by the pill's padding. No plate, no rule: space is the only divider.\n * - Disabled lies flat on --litt-surface-2, the tokens plain --litt-surface-3 pills with their words in --litt-fg-subtle, and the\n *   crosses off.\n * - Motion: a token fades in with a 0.98 scale on the responsive tier and its neighbors slide; the ring and the tint\n *   fade on the instant tier. Nothing else moves.\n * - Forced colors drops fills and shadows, so the pill and the tokens get real borders and focus an outline.\n * - The contract (the real input, the roving remove buttons, the live region) is token-input.tsx's.\n */\nimport type { TokenInputBodyProps, TokenSkin } from \"../token-input\";\n\nconst FIELD =\n  \"relative flex w-full min-w-0 cursor-text bg-litt-field font-litt-field font-normal text-litt-field-ink shadow-none \" +\n  \"transition-[box-shadow,background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[input:focus-visible]:shadow-(--litt-focus-ring) \" +\n  \"data-[invalid]:bg-[color-mix(in_oklab,var(--litt-danger)_12%,var(--litt-cut))] \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-none \" +\n  \"forced-colors:border forced-colors:has-[input:focus-visible]:outline-2 forced-colors:has-[input:focus-visible]:outline-solid forced-colors:has-[input:focus-visible]:outline-offset-2\";\n\n/* The corners: the control radius, never past half the one-line height. */\nconst RADIUS = {\n  sm: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-sm)/2))] pointer-coarse:rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  md: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  lg: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-lg)/2))]\",\n};\n\n/* Padding on the 4px grid: a 24px row of pills plus this air lands on 32, 44 (centered in the minimum) and 56. The\n   sides are wider than the other bodies', so the pills clear the round ends. */\nconst FIELD_SIZE = {\n  sm: \"min-h-litt-control-sm px-2 py-1 pointer-coarse:min-h-litt-control-md\",\n  md: \"min-h-litt-control-md px-3 py-2\",\n  lg: \"min-h-litt-control-lg px-4 py-4\",\n};\n\nconst TEXT_SIZE = {\n  sm: \"text-litt-small\",\n  md: \"text-litt-body\",\n  lg: \"text-(length:--litt-control-text-lg)\",\n};\n\nconst REMOVE =\n  \"relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-litt-pill text-litt-fg-muted \" +\n  \"transition-[color,background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"hover:bg-[color-mix(in_oklab,var(--litt-fg)_8%,transparent)] hover:text-litt-fg active:bg-[color-mix(in_oklab,var(--litt-fg)_14%,transparent)] \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"disabled:cursor-not-allowed disabled:bg-transparent disabled:text-litt-fg-disabled disabled:hover:bg-transparent disabled:hover:text-litt-fg-disabled \" +\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5\";\n\nfunction Cross() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M5.5 5.5l5 5M10.5 5.5l-5 5\" />\n    </svg>\n  );\n}\n\nconst SKIN: TokenSkin = {\n  Chip: ({ disabled, children }) => (\n    <span\n      className={\n        \"inline-flex h-6 max-w-full min-w-0 shrink-0 items-center whitespace-nowrap rounded-litt-pill pr-0 pl-3 text-litt-caption \" +\n        (disabled ? \"bg-litt-surface-3 text-litt-fg-subtle\" : \"bg-[color-mix(in_oklab,var(--litt-cut),var(--litt-fg)_9%)] text-litt-fg\") +\n        \" forced-colors:border\"\n      }\n    >\n      {children}\n    </span>\n  ),\n  text: () => \"min-w-0 truncate\",\n  remove: REMOVE,\n  Cross,\n  more: \"inline-flex h-6 shrink-0 cursor-default items-center px-2 text-litt-caption text-litt-fg-muted\",\n};\n\n/** Flat, a grey pill field with its tokens as darker grey pills, each with a cross. */\nexport function TokenInputFlatBody({ size, invalid, disabled, className, label, labelId, inputId, note, noteId, field, list, input, hidden, said }: TokenInputBodyProps) {\n  return (\n    <div data-body=\"flat\" className={\"grid content-start gap-1.5 \" + className}>\n      <label id={labelId} htmlFor={inputId} className={\"px-litt-control-pad-md text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n        {label}\n      </label>\n\n      <div\n        ref={field.ref}\n        data-invalid={invalid ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={[FIELD, RADIUS[size], FIELD_SIZE[size], TEXT_SIZE[size], \"pointer-coarse:text-(length:--litt-control-text-lg)\"].join(\" \")}\n        onMouseDown={field.onMouseDown}\n        onFocus={field.onFocus}\n        onBlur={field.onBlur}\n      >\n        <div className=\"relative flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n          {list(SKIN)}\n          {input}\n        </div>\n        {hidden}\n      </div>\n\n      {note && (\n        <p id={noteId} className={\"px-litt-control-pad-md text-litt-small \" + (invalid ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/token-input.flat.tsx"
    }
  ],
  "docs": "Import TokenInput from components/litt/token-input. Give it a label and hold the values in state with value and onValueChange, or pass a defaultValue. Add validate to reject a value with a message, max to cap the list, collapseAfter to fold it, and name to post every value with a form.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
