{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "code-input",
  "type": "registry:ui",
  "title": "Code input",
  "description": "A short one-time code drawn as one cell per character but built as one real field, so paste, one-time-code autofill and screen readers work as in any field. The last character runs onComplete once.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/code-input.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/code-input 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Code input: a short one-time code, such as the six digits from a sign-in email or an authenticator app\n * (components.md, \"Code input\"). It looks like one box per character and behaves like one text field.\n *\n * - One real <input> lies over the cells, invisible, so everything a text field does still works: paste, the\n *   browser's one-time-code autofill from a text message, the password manager, undo, and a screen reader that hears\n *   one field with its label. The cells are drawn from its value and hidden from assistive tech.\n * - Anatomy: the Input's cut per cell (R3: the --litt-cut floor, the --litt-line-control line and the top wall's shade, at the\n *   control radius), square on\n *   the control scale (md 44, lg 56), narrowing to no less than 32px in a box too small for them, each character in the\n *   sans at the body size, in tabular figures. Six cells split three and three\n *   with a wider gap, so the code reads in halves. The label sits above at the title weight and a hint or an error\n *   below, as on a TextField.\n * - Press, the instrument: one raised plate (--litt-block, --litt-plate-lift) holds the cells, each a cut into it (--litt-cut,\n *   --litt-cut-shade); a typed character's cut catches the light on its lower wall (--litt-highlight turned over).\n * - Flat, the page (proposals/flat-pill.md): one grey pill per character parted by space, the empty ones the lighter\n *   grey (--litt-surface-2), a typed one --litt-block with its character in ink at the title weight, the ink ring on the current\n *   one and the danger ring on every cell with an error.\n * - Focus: the cell the next character goes into takes the focus line and ring, and an empty one shows a still 1px\n *   caret. Nothing blinks.\n * - Typing: only what the code can hold gets in (digits, or letters and digits in capitals); the rest is dropped, so a\n *   pasted \"123 456\" or \"123-456\" lands as 123456. When the last cell fills, onComplete runs once with the code.\n *   Backspace clears from the end.\n * - Error: every cell takes the --litt-danger line, and the message under them says what to do. Disabled lies flat:\n *   --litt-surface-2, the plain --litt-line hairline, no shade and --litt-fg-disabled, as the Input.\n * - Motion: the lines fade on the instant tier, and the box never moves.\n * - One contract, three bodies (proposals/worlds.md, Bodies). This file is the contract: the props, the one real input\n *   over the cells, the label and the note, the typing and the completion. It resolves the code into CodeInputBodyProps\n *   and renders the body the material picks (byWorld). PressBody, above, is the default; bodies/code-input.flat.tsx and\n *   bodies/code-input.device.tsx draw the cells for the other two.\n * - Device, bodies/code-input.device.tsx: one pane of LCD glass per character, showing the dim ghost of an eight on a\n *   seven-segment display until its character is typed, then the character lit in segments (digits; a letter of an\n *   alphanumeric code is set in the mono). A character only lights when typed (R4), and a change switches in place in\n *   steps on the instant tier.\n */\nimport { useId, useRef, useState, type ChangeEvent, type ReactNode } from \"react\";\nimport { byWorld, useMaterial } from \"../../lib/litt/motion\";\nimport { CodeInputDeviceBody } from \"./bodies/code-input.device\";\nimport { CodeInputFlatBody } from \"./bodies/code-input.flat\";\n\nexport type CodeInputSize = \"md\" | \"lg\";\n\nexport type CodeInputProps = {\n  /** The label above the cells. Required, because the cells say nothing about what the code is for. */\n  label: ReactNode;\n  /** How many characters the code has. */\n  length?: number;\n  /** \"numeric\" takes digits and brings up the number pad; \"alphanumeric\" also takes letters, in capitals. */\n  type?: \"numeric\" | \"alphanumeric\";\n  /** The code so far, for a controlled field. */\n  value?: string;\n  /** The starting code, for an uncontrolled field. */\n  defaultValue?: string;\n  /** Runs on every change with the cleaned code so far. */\n  onValueChange?: (value: string) => void;\n  /** Runs once when the last cell fills, with the whole code. Check it here. */\n  onComplete?: (code: string) => void;\n  /** A hint under the cells: where the code was sent, how long it lasts. An error replaces it. */\n  help?: ReactNode;\n  /** Draws the danger edge on every cell and shows this under them. Say what to do, such as \"Check the code, or send a new one.\" */\n  error?: ReactNode;\n  /** Height on the control scale: md is 44, lg is 56. */\n  size?: CodeInputSize;\n  disabled?: boolean;\n  /** The form field name, so a form submits the code. */\n  name?: string;\n  /** Focus the field when it mounts, as on a page whose only job is this code. */\n  autoFocus?: boolean;\n  className?: string;\n};\n\nconst cellHeights: Record<CodeInputSize, string> = {\n  md: \"h-litt-control-md\",\n  lg: \"h-litt-control-lg\",\n};\n/** What a body draws: the cells, from the code so far. The input, the label and the note are the contract's, above. */\nexport type CodeInputBodyProps = {\n  length: number;\n  /** The cell's height class on the control scale. */\n  cellHeight: string;\n  /** The cleaned code so far. */\n  code: string;\n  /** The cell the next character goes into. */\n  at: number;\n  focused: boolean;\n  disabled: boolean;\n  error: boolean;\n  type: \"numeric\" | \"alphanumeric\";\n};\n\nconst cellWidths: Record<CodeInputSize, string> = {\n  md: \"var(--litt-control-md)\",\n  lg: \"var(--litt-control-lg)\",\n};\n\n/** Press, the instrument: one raised plate (--litt-block, --litt-plate-lift) holds the cells, each a cut into it (--litt-cut,\n *  --litt-cut-shade). A typed character's cut catches the light on its lower wall: a lit top edge (--litt-highlight) turned over. */\nfunction PressBody({ length, cellHeight, code, at, focused, disabled, error }: CodeInputBodyProps) {\n  return (\n    <div\n      aria-hidden=\"true\"\n      data-body=\"press\"\n      className={\n        \"flex w-full gap-2 rounded-litt-surface p-1 forced-colors:border \" +\n        (disabled ? \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\" : \"bg-litt-block shadow-(--litt-plate-lift)\")\n      }\n    >\n      {Array.from({ length }, (_, i) => {\n        const char = code[i] ?? \"\";\n        const current = focused && !disabled && i === at;\n        return (\n          <span\n            key={i}\n            className={\n              \"relative grid min-w-7 flex-1 basis-0 place-items-center rounded-[calc(var(--litt-radius-surface)-4px)] text-litt-body tabular-nums font-normal \" +\n              \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt forced-colors:border \" +\n              cellHeight +\n              /* six cells read as two halves of three */\n              (length === 6 && i === 3 ? \" ml-2\" : \"\") +\n              /* Forced colors drop the ring (a box-shadow), so the current cell takes an outline there. */\n              (current ? \" forced-colors:outline-2 forced-colors:outline-offset-2 forced-colors:outline-[Highlight]\" : \"\") +\n              (disabled\n                ? \" bg-litt-surface-3 text-litt-fg-disabled\"\n                : \" bg-litt-cut font-litt-field text-litt-field-ink \" +\n                  (current\n                    ? error\n                      ? \"shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-cut-shade),var(--litt-focus-ring)]\"\n                      : \"shadow-[inset_0_0_0_1px_var(--litt-focus),var(--litt-cut-shade),var(--litt-focus-ring)]\"\n                    : error\n                      ? \"shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-cut-shade)]\"\n                      : \"shadow-(--litt-cut-shade)\"))\n            }\n          >\n            {char ||\n              (current ? <span className=\"h-5 w-px bg-litt-field-ink forced-colors:bg-[CanvasText] forced-colors:[forced-color-adjust:none]\" /> : null)}\n            {char && !disabled && (\n              <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rotate-180 rounded-[calc(var(--litt-radius-surface)-4px)] shadow-(--litt-highlight)\" />\n            )}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\n/** One field for a short code, drawn as one cell per character. Paste and one-time-code autofill work as in any field. */\nexport function CodeInput({\n  label,\n  length = 6,\n  type = \"numeric\",\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onComplete,\n  help,\n  error,\n  size = \"md\",\n  disabled = false,\n  name,\n  autoFocus,\n  className = \"\",\n}: CodeInputProps) {\n  const Body = byWorld(useMaterial(), { press: PressBody, flat: CodeInputFlatBody, device: CodeInputDeviceBody });\n  const id = useId();\n  const noteId = useId();\n  const input = useRef<HTMLInputElement>(null);\n  const clean = (s: string) => (type === \"numeric\" ? s.replace(/\\D/g, \"\") : s.replace(/[^a-z0-9]/gi, \"\").toUpperCase()).slice(0, length);\n  const [own, setOwn] = useState(() => clean(defaultValue));\n  const [focused, setFocused] = useState(false);\n  const code = value !== undefined ? clean(value) : own;\n  const note = error || help;\n  const at = Math.min(code.length, length - 1); // the cell the next character goes into\n  /* The row is as wide as the box allows, up to square cells; a box too narrow shares its width between them (never\n     below 32px each). A max-width, not a width, so the page never counts the full size as its minimum. */\n  const gaps = length - 1 + (length === 6 ? 1 : 0);\n  const full = `calc(${length} * ${cellWidths[size]} + ${gaps} * var(--litt-space-2))`;\n\n  const change = (e: ChangeEvent<HTMLInputElement>) => {\n    const next = clean(e.target.value);\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n    if (next.length === length && code.length < length) onComplete?.(next);\n  };\n  /* The caret always sits at the end, so a click on any cell carries on from the last character. */\n  const toEnd = () => {\n    const el = input.current;\n    if (el) el.setSelectionRange(el.value.length, el.value.length);\n  };\n\n  return (\n    <div className={\"grid justify-items-start gap-2 \" + className}>\n      <label htmlFor={id} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n        {label}\n      </label>\n      <div className=\"relative w-full\" style={{ maxWidth: full }}>\n        <Body length={length} cellHeight={cellHeights[size]} code={code} at={at} focused={focused} disabled={disabled} error={!!error} type={type} />\n        <input\n          ref={input}\n          id={id}\n          name={name}\n          value={code}\n          onChange={change}\n          onFocus={() => {\n            setFocused(true);\n            toEnd();\n          }}\n          onBlur={() => setFocused(false)}\n          onClick={toEnd}\n          onKeyUp={toEnd}\n          disabled={disabled}\n          autoFocus={autoFocus}\n          autoComplete=\"one-time-code\"\n          inputMode={type === \"numeric\" ? \"numeric\" : \"text\"}\n          pattern={type === \"numeric\" ? \"[0-9]*\" : undefined}\n          autoCapitalize={type === \"numeric\" ? undefined : \"characters\"}\n          spellCheck={false}\n          /* No maxLength: a pasted \"246 810\" is seven characters until the space is dropped. clean() keeps the length. */\n          aria-invalid={error ? true : undefined}\n          aria-describedby={note ? noteId : undefined}\n          /* Over the cells and invisible: it takes the clicks, the typing and the autofill. 16px, so iOS never zooms. */\n          className=\"absolute inset-0 size-full cursor-text appearance-none bg-transparent text-(length:--litt-control-text-lg) caret-transparent opacity-0 outline-none disabled:cursor-not-allowed\"\n        />\n      </div>\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/code-input.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/code-input.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/code-input 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Code input, the Device body (proposals/worlds.md, Bodies; material.md, 8): a row of seven-segment glass.\n *\n * - One pane of LCD glass per character (--litt-field, --litt-field-shade, the --litt-field-line edge) at the control radius. An empty\n *   pane shows the ghost of an eight: all seven segments dim, as an unlit display does. A typed digit lights its own\n *   segments in the glass's ink (--litt-field-ink) and the rest stay dim. A letter of an alphanumeric code has no\n *   seven-segment shape, so it is set in the mono on the glass instead.\n * - The figure is the Seven-segment part's (components/seven-segment.tsx): the same polygons, 43 by 66 units, slanted\n *   0.09, an unlit segment the ink at 10%. It is drawn here and not imported, because that part is a whole readout\n *   (its own glass, a phosphor loop per instance) and a cell is already the glass.\n * - Only a typed character lights (R4). A change switches in place in three steps on the instant tier, as the Switch's\n *   detent does, and nothing glides. Reduced motion zeroes the tier, so it switches at once. The next cell needs no\n *   caret: its pane takes the focus line and ring, and nothing blinks.\n * - Focus: the next cell takes the --litt-focus line and ring. An error puts the --litt-danger line on every pane. Disabled\n *   lies flat: --litt-surface-2, the --litt-line hairline, the segments in --litt-fg-disabled.\n * - Forced colors drops shadows, so each pane gets a real border, the current one an outline, and the segments follow\n *   the system text color with their dim ones still at 10%.\n */\nimport type { CSSProperties } from \"react\";\nimport type { CodeInputBodyProps } from \"../code-input\";\n\n/* The figure's numbers, in its own units (the same as the Seven-segment part's). */\nconst T = 6.6;\nconst GAP = 1;\nconst SLANT = 0.09;\nconst COL = [5.5, 31] as const;\nconst ROW = [5, 33, 61] as const;\nconst H = T / 2;\nconst GP = GAP / Math.SQRT2;\nconst XL = COL[0] + H;\nconst XR = COL[1] + H;\n\nconst pts = (list: [number, number][]) => list.map(([x, y]) => `${(x + (ROW[1] - y) * SLANT).toFixed(2)},${y.toFixed(2)}`).join(\" \");\nconst across = (y: number): [number, number][] => [[XL + GP, y], [XL + GP + H, y - H], [XR - GP - H, y - H], [XR - GP, y], [XR - GP - H, y + H], [XL + GP + H, y + H]];\nconst down = (x: number, y1: number, y2: number): [number, number][] => [[x, y1 + GP], [x + H, y1 + GP + H], [x + H, y2 - GP - H], [x, y2 - GP], [x - H, y2 - GP - H], [x - H, y1 + GP + H]];\n\n/** a b c d e f g, in that order. */\nconst SEGMENTS = [\n  pts(across(ROW[0])),\n  pts(down(XR, ROW[0], ROW[1])),\n  pts(down(XR, ROW[1], ROW[2])),\n  pts(across(ROW[2])),\n  pts(down(XL, ROW[1], ROW[2])),\n  pts(down(XL, ROW[0], ROW[1])),\n  pts(across(ROW[1])),\n];\nconst LIT: Record<string, string> = { \"0\": \"abcdef\", \"1\": \"bc\", \"2\": \"abdeg\", \"3\": \"abcdg\", \"4\": \"bcfg\", \"5\": \"acdfg\", \"6\": \"acdefg\", \"7\": \"abc\", \"8\": \"abcdefg\", \"9\": \"abcdfg\" };\n\n/* The change in three even steps on the instant tier, never a glide. */\nconst STEPS = \"steps(3, end)\";\nconst switching: CSSProperties = {\n  transitionProperty: \"opacity\",\n  transitionDuration: \"var(--litt-dur-instant)\",\n  transitionTimingFunction: `${STEPS}`,\n};\n\nfunction Figure({ char }: { char: string }) {\n  const on = LIT[char] ?? \"\";\n  return (\n    <svg viewBox=\"0 0 43 66\" aria-hidden=\"true\" className=\"h-6 w-auto shrink-0 fill-current\">\n      {SEGMENTS.map((points, i) => (\n        <polygon key={i} points={points} style={{ ...switching, opacity: on.includes(\"abcdefg\"[i]!) ? 1 : 0.1 }} />\n      ))}\n    </svg>\n  );\n}\n\nconst pane =\n  \"relative grid min-w-8 flex-1 basis-0 place-items-center rounded-litt-control \" +\n  \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt forced-colors:border\";\n\n/** Device, the segments: a pane of LCD glass per character, an unlit eight until its character is typed. */\nexport function CodeInputDeviceBody({ length, cellHeight, code, at, focused, disabled, error }: CodeInputBodyProps) {\n  return (\n    <div aria-hidden=\"true\" data-body=\"device\" className=\"flex w-full gap-2\">\n      {Array.from({ length }, (_, i) => {\n        const char = code[i] ?? \"\";\n        const current = focused && !disabled && i === at;\n        const figure = char === \"\" || char in LIT;\n        return (\n          <span\n            key={i}\n            className={\n              pane +\n              \" \" +\n              cellHeight +\n              /* six cells read as two halves of three */\n              (length === 6 && i === 3 ? \" ml-2\" : \"\") +\n              (current ? \" forced-colors:outline-2 forced-colors:outline-offset-2 forced-colors:outline-[Highlight]\" : \"\") +\n              (disabled\n                ? \" bg-litt-surface-2 text-litt-fg-disabled shadow-[inset_0_0_0_1px_var(--litt-line)]\"\n                : \" bg-litt-field text-litt-field-ink \" +\n                  (current\n                    ? error\n                      ? \"shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-field-shade),var(--litt-focus-ring)]\"\n                      : \"shadow-[inset_0_0_0_1px_var(--litt-focus),var(--litt-field-shade),var(--litt-focus-ring)]\"\n                    : error\n                      ? \"shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-field-shade)]\"\n                      : \"shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)]\"))\n            }\n          >\n            {figure ? <Figure char={char} /> : <span className=\"font-litt-field text-litt-body font-litt-title\">{char}</span>}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/code-input.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/code-input.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/code-input 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Code input, the Flat body (proposals/worlds.md, Bodies; proposals/flat-pill.md): ink on paper. One grey pill per\n * character, parted by space and nothing else. An empty pill is the lighter grey (--litt-surface-2); a typed one is the\n * object grey (--litt-block, --litt-surface-3) with its character in ink at the title weight. The ink ring marks the cell the\n * next character goes into, and the danger ring every cell of an error. Disabled pills are the light grey with\n * --litt-fg-disabled. No shade, no outline. The caret is a still 1px line.\n */\nimport type { CodeInputBodyProps } from \"../code-input\";\n\n/** Flat, the pills: grey by fill alone, ink by weight, a ring only for the current cell and for an error. */\nexport function CodeInputFlatBody({ length, cellHeight, code, at, focused, disabled, error }: CodeInputBodyProps) {\n  return (\n    <div aria-hidden=\"true\" data-body=\"flat\" className=\"flex w-full gap-3\">\n      {Array.from({ length }, (_, i) => {\n        const char = code[i] ?? \"\";\n        const current = focused && !disabled && i === at;\n        return (\n          <span\n            key={i}\n            className={\n              \"grid min-w-6 flex-1 basis-0 place-items-center rounded-litt-pill text-litt-body tabular-nums \" +\n              \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt forced-colors:border \" +\n              cellHeight +\n              /* six cells read as two halves of three, parted by space */\n              (length === 6 && i === 3 ? \" ml-3\" : \"\") +\n              /* Forced colors drop the ring (a box-shadow), so the current cell takes an outline there. */\n              (current ? \" forced-colors:outline-2 forced-colors:outline-offset-2 forced-colors:outline-[Highlight]\" : \"\") +\n              (disabled\n                ? \" shadow-none\"\n                : error\n                  ? \" shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)]\"\n                  : current\n                    ? \" shadow-(--litt-focus-ring)\"\n                    : \" shadow-none\") +\n              (disabled\n                ? \" bg-litt-surface-2 font-normal text-litt-fg-disabled\"\n                : char\n                  ? \" bg-litt-block font-litt-title text-litt-fg\"\n                  : \" bg-litt-surface-2 font-normal text-litt-fg\")\n            }\n          >\n            {char || (current ? <span className=\"h-5 w-px bg-litt-fg forced-colors:bg-[CanvasText] forced-colors:[forced-color-adjust:none]\" /> : null)}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/code-input.flat.tsx"
    }
  ],
  "docs": "Import CodeInput from components/litt/code-input. Give it a label and check the code in onComplete; show what was wrong in error.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
