{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"code-input","type":"registry:component","title":"Code input","description":"The six boxes, done properly: digits rise in, a paste cascades, a wrong code shakes and clears, the right one becomes a pill with a check.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/status-button.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/code-input.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CODE INPUT: the six boxes, done properly\n *\n *   typing     each digit rises into its box out of a blur; the\n *              box you're on carries a blinking caret; deleting\n *              lifts a digit away rather than blinking it out\n *   paste      a pasted or autofilled code drops in one box after\n *              another\n *   checking   with every box full, the row carries a sweep of\n *              light while onComplete runs\n *   wrong      the row shakes, the boxes turn red, the label\n *              says so, and the digits clear from the right\n *   right      the boxes close their gaps and become one pill, a\n *              check draws itself at its end, \"Verified\"\n *   resend     \"Resend in 0:28\", the seconds rolling down, then\n *              a Resend button that spins, ticks and starts the\n *              clock again\n *\n * One real input sits over the boxes (one-time-code autofill,\n * paste and the numeric keyboard all just work); the boxes are\n * only what you see.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CodeInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Runs when every box is filled. Resolve (or return true) to accept; throw or return false to reject. */\n  onComplete?: (code: string) => Promise<boolean | void> | boolean | void;\n  /** Shows the resend line; the clock starts on mount and after each resend. */\n  onResend?: () => Promise<unknown> | void;\n  /** Seconds before a code can be resent. */\n  resendAfter?: number;\n  label?: string;\n  /** Box groups, e.g. [3, 3] puts a dash between two threes. */\n  groups?: number[];\n  /** Letters as well as digits. */\n  alphanumeric?: boolean;\n  autoFocus?: boolean;\n}\n\ntype Phase = \"idle\" | \"checking\" | \"wrong\" | \"right\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst SHEEN =\n  \"[mask-image:linear-gradient(90deg,rgb(0_0_0/0.4)_35%,#000_50%,rgb(0_0_0/0.4)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none]\";\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\n/** One character: rises in out of a blur when it arrives, lifts away when it goes. */\nfunction Glyph({ char, delay, animate, reduced }: { char: string | undefined; delay: number; animate: boolean; reduced: boolean }) {\n  const [shown, setShown] = React.useState(char);\n  const [leaving, setLeaving] = React.useState(false);\n  const ref = React.useRef<HTMLSpanElement>(null);\n  if (char !== undefined && (char !== shown || leaving)) {\n    setShown(char);\n    setLeaving(false);\n  } else if (char === undefined && shown !== undefined && !leaving) {\n    setLeaving(true);\n  }\n\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || reduced) return;\n    if (leaving) {\n      const a = el.animate(\n        [\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n          { opacity: 0, transform: \"translateY(-35%)\", filter: \"blur(4px)\" },\n        ],\n        { duration: 200, delay, easing: EASE, fill: \"forwards\" },\n      );\n      a.onfinish = () => setShown(undefined);\n      return () => a.cancel();\n    }\n    if (!animate || shown === undefined) return;\n    const a = el.animate(\n      [\n        { opacity: 0, transform: \"translateY(45%)\", filter: \"blur(4px)\" },\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n      ],\n      { duration: 300, delay, easing: EASE, fill: \"backwards\" },\n    );\n    return () => a.cancel();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [shown, leaving]);\n\n  React.useEffect(() => {\n    if (leaving && reduced) setShown(undefined);\n  }, [leaving, reduced]);\n\n  if (shown === undefined) return null;\n  return (\n    <span ref={ref} className=\"inline-block\">\n      {shown}\n    </span>\n  );\n}\n\nexport function CodeInput({\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onComplete,\n  onResend,\n  resendAfter = 30,\n  label = `Enter the ${length}-digit code`,\n  groups,\n  alphanumeric = false,\n  autoFocus,\n  className = \"\",\n  ...props\n}: CodeInputProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState(defaultValue.slice(0, length));\n  const code = (value ?? own).slice(0, length);\n  const [phase, setPhase] = React.useState<Phase>(\"idle\");\n  const [focused, setFocused] = React.useState(false);\n  const [arrival, setArrival] = React.useState({ length: code.length, from: code.length });\n  const [clearing, setClearing] = React.useState(false);\n  const [left, setLeft] = React.useState<number | null>(null);\n  const [resend, setResend] = React.useState<ActionStatus>(\"idle\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const rowRef = React.useRef<HTMLDivElement>(null);\n  const mounted = React.useRef(false);\n  React.useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  // How the new characters arrived: one typed, or several at once (paste, autofill) to cascade in.\n  if (arrival.length !== code.length) setArrival({ length: code.length, from: arrival.length });\n  const arrivedFrom = arrival.from;\n\n  const setCode = (next: string) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  // Resend clock: whole seconds, started on mount and after each resend.\n  const startClock = React.useCallback(() => setLeft(resendAfter), [resendAfter]);\n  React.useEffect(() => {\n    if (onResend) startClock();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  React.useEffect(() => {\n    if (left === null || left <= 0) return;\n    const timer = window.setTimeout(() => setLeft((s) => (s === null ? s : s - 1)), 1000);\n    return () => window.clearTimeout(timer);\n  }, [left]);\n\n  const check = async (full: string) => {\n    if (!onComplete) return;\n    setPhase(\"checking\");\n    let ok = true;\n    try {\n      ok = (await onComplete(full)) !== false;\n    } catch {\n      ok = false;\n    }\n    if (ok) {\n      setPhase(\"right\");\n      inputRef.current?.blur();\n      return;\n    }\n    setPhase(\"wrong\");\n    if (!reduced) {\n      rowRef.current?.animate(\n        [{ transform: \"none\" }, { transform: \"translateX(-5px)\" }, { transform: \"translateX(5px)\" }, { transform: \"translateX(-3px)\" }, { transform: \"none\" }],\n        { duration: 380, easing: \"ease-out\" },\n      );\n    }\n    // Let the shake land, then clear from the right and start again at the first box.\n    window.setTimeout(() => {\n      setClearing(true);\n      setCode(\"\");\n      inputRef.current?.focus();\n      window.setTimeout(() => setClearing(false), 400);\n    }, reduced ? 0 : 650);\n  };\n\n  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    if (phase === \"checking\" || phase === \"right\") return;\n    const raw = e.target.value;\n    const clean = (alphanumeric ? raw.replace(/[^a-z0-9]/gi, \"\").toUpperCase() : raw.replace(/\\D/g, \"\")).slice(0, length);\n    if (phase === \"wrong\") setPhase(\"idle\");\n    setCode(clean);\n  };\n\n  // A full code, however it arrived (typed, pasted, autofilled or set from outside), gets checked once.\n  const lastLength = React.useRef(code.length);\n  React.useEffect(() => {\n    if (code.length === length && lastLength.current < length && (phase === \"idle\" || phase === \"wrong\")) check(code);\n    lastLength.current = code.length;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [code]);\n\n  const doResend = async () => {\n    if (!onResend || resend === \"pending\") return;\n    setResend(\"pending\");\n    try {\n      await onResend();\n      setResend(\"success\");\n      window.setTimeout(() => {\n        setResend(\"idle\");\n        startClock();\n      }, reduced ? 0 : 1100);\n    } catch {\n      setResend(\"error\");\n    }\n  };\n\n  // Where the dashes go between groups.\n  const breaks = new Set<number>();\n  if (groups) groups.reduce((at, size) => (breaks.add(at + size), at + size), 0);\n  const right = phase === \"right\";\n  const wrong = phase === \"wrong\";\n  const active = focused && phase !== \"checking\" && !right ? Math.min(code.length, length - 1) : -1;\n  const labelText = right ? \"Verified\" : wrong ? \"That code didn't work\" : phase === \"checking\" ? \"Checking the code\" : label;\n\n  return (\n    <div className={`@container w-full ${className}`} {...props}>\n      <label htmlFor={id} className={`block text-[12.5px] transition-colors duration-300 ${wrong ? \"text-red-600\" : \"text-muted-foreground\"}`}>\n        <TextMorph>{labelText}</TextMorph>\n      </label>\n\n      <div className=\"relative mt-2 w-fit\">\n        <input\n          ref={inputRef}\n          id={id}\n          value={code}\n          onChange={onChange}\n          onFocus={(e) => {\n            setFocused(true);\n            const end = e.currentTarget.value.length;\n            e.currentTarget.setSelectionRange(end, end);\n          }}\n          onBlur={() => setFocused(false)}\n          onSelect={(e) => {\n            // The caret always sits at the end; the boxes show where you are.\n            const el = e.currentTarget;\n            if (el.selectionStart !== el.value.length) el.setSelectionRange(el.value.length, el.value.length);\n          }}\n          readOnly={phase === \"checking\" || right}\n          autoFocus={autoFocus}\n          autoComplete=\"one-time-code\"\n          inputMode={alphanumeric ? \"text\" : \"numeric\"}\n          pattern={alphanumeric ? \"[A-Za-z0-9]*\" : \"[0-9]*\"}\n          maxLength={length}\n          spellCheck={false}\n          aria-invalid={wrong || undefined}\n          className=\"absolute inset-0 z-10 h-full w-full cursor-text bg-transparent text-[16px] text-transparent caret-transparent opacity-0 outline-none [&::selection]:bg-transparent\"\n        />\n\n        {/* The boxes, and the one pill they become when the code is right. */}\n        <div\n          ref={rowRef}\n          aria-hidden=\"true\"\n          className={`relative flex items-center rounded-[12px] transition-[gap,box-shadow,background-color] duration-500 ${phase === \"checking\" ? SHEEN : \"\"}`}\n          style={{\n            gap: right ? 0 : \"var(--code-gap, 8px)\",\n            boxShadow: right ? \"0 0 0 1px color-mix(in oklab, #10b981 55%, transparent)\" : \"0 0 0 1px transparent\",\n            background: right ? \"color-mix(in oklab, #10b981 6%, var(--background))\" : \"transparent\",\n            transitionTimingFunction: EASE,\n          }}\n        >\n          {Array.from({ length }, (_, i) => {\n            const char = code[i];\n            const isActive = i === active;\n            const cascade = !clearing && code.length - arrivedFrom > 1 && i >= arrivedFrom ? (i - arrivedFrom) * 45 : 0;\n            const exitDelay = clearing ? (length - 1 - i) * 30 : 0;\n            return (\n              <React.Fragment key={i}>\n                {breaks.has(i) && i > 0 && (\n                  <span\n                    className=\"block h-px bg-muted-foreground/40 transition-[width,margin,opacity] duration-500\"\n                    style={{ width: right ? 0 : 8, margin: right ? 0 : \"0 2px\", opacity: right ? 0 : 1, transitionTimingFunction: EASE }}\n                  />\n                )}\n                <span\n                  className=\"relative flex h-10 w-[34px] items-center justify-center text-[18px] font-medium tabular-nums text-foreground transition-[box-shadow,border-radius,background-color] duration-300 @min-[22rem]:h-11 @min-[22rem]:w-9 @min-[22rem]:text-[19px]\"\n                  style={{\n                    borderRadius: right ? 0 : 10,\n                    boxShadow: right\n                      ? \"inset 0 0 0 1px transparent\"\n                      : wrong\n                        ? \"inset 0 0 0 1px color-mix(in oklab, #ef4444 60%, transparent)\"\n                        : isActive\n                          ? \"inset 0 0 0 1.5px color-mix(in oklab, var(--ring) 70%, transparent)\"\n                          : \"inset 0 0 0 1px var(--border)\",\n                    background: right ? \"transparent\" : \"var(--background)\",\n                  }}\n                >\n                  <Glyph char={char} delay={char !== undefined ? cascade : exitDelay} animate={mounted.current} reduced={reduced} />\n                  {/* The caret, blinking only in the box you're on. */}\n                  {isActive && char === undefined && (\n                    <span className=\"absolute h-5 w-px bg-foreground animate-[ui-blink_1s_steps(1)_infinite] motion-reduce:animate-none\" />\n                  )}\n                </span>\n              </React.Fragment>\n            );\n          })}\n\n          {/* The check at the end of the pill. */}\n          <span\n            className=\"grid transition-[grid-template-columns,opacity] duration-500\"\n            style={{ gridTemplateColumns: right ? \"1fr\" : \"0fr\", opacity: right ? 1 : 0, transitionTimingFunction: EASE }}\n          >\n            <span className=\"flex min-w-0 items-center overflow-hidden\">\n              <svg viewBox=\"0 0 16 16\" className=\"mx-3 size-4 shrink-0 text-emerald-600\" fill=\"none\">\n                <path\n                  d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  pathLength={1}\n                  strokeDasharray={1}\n                  style={{ strokeDashoffset: right ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 420ms ${EASE} 260ms` }}\n                />\n              </svg>\n            </span>\n          </span>\n        </div>\n      </div>\n\n      {onResend && !right && (\n        <div className=\"mt-3 flex h-7 items-center gap-1.5 text-[12px] text-muted-foreground\">\n          <span>Didn’t get it?</span>\n          {/* The countdown and the button share one place; each fades through a blur into the other. */}\n          <span className=\"grid items-center\">\n            {/* The ticking digits stay out of screen readers; they hear that a resend is coming instead. */}\n            <span className=\"sr-only\">{left && left > 0 ? \"You can resend the code shortly.\" : \"\"}</span>\n            <span\n              className=\"col-start-1 row-start-1 flex items-baseline whitespace-nowrap tabular-nums\"\n              aria-hidden=\"true\"\n              style={{\n                opacity: left && left > 0 ? 1 : 0,\n                filter: left && left > 0 ? \"none\" : \"blur(3px)\",\n                transition: reduced ? \"none\" : `opacity 260ms ${EASE}, filter 260ms ${EASE}`,\n              }}\n            >\n              Resend in 0:\n              <NumberRoll value={left ?? resendAfter} format={{ minimumIntegerDigits: 2 }} direction=\"down\" duration={700} />\n            </span>\n            <span\n              className=\"col-start-1 row-start-1\"\n              inert={!!(left && left > 0)}\n              style={{\n                opacity: left && left > 0 ? 0 : 1,\n                filter: left && left > 0 ? \"blur(3px)\" : \"none\",\n                transition: reduced ? \"none\" : `opacity 260ms ${EASE}, filter 260ms ${EASE}`,\n              }}\n            >\n              <StatusButton\n                variant=\"ghost\"\n                size=\"sm\"\n                status={resend}\n                labels={{ idle: \"Resend code\", pending: \"Sending\", success: \"Sent\", error: \"Try again\" }}\n                onClick={doResend}\n                className=\"-ml-2\"\n              />\n            </span>\n          </span>\n        </div>\n      )}\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {right ? \"Code verified\" : wrong ? \"That code didn't work. Enter it again.\" : phase === \"checking\" ? \"Checking the code\" : \"\"}\n      </p>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-blink":{"0%, 100%":{"opacity":"1"},"50%":{"opacity":"0"}},"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/code-input"}