# Passcode — Animated Input for React

> A segmented one-time-code input: a row of single-character cells backed by real inputs. Typing seats each digit with a soft pop and advances focus, Backspace retreats, arrow keys move, and paste or SMS autofill distributes across cells. The focused empty cell shows an animated caret in an accent ring; a complete code flashes success and fires onComplete. Fully keyboard and screen-reader accessible with a mobile numeric keypad.

- Page: https://crucibleui.com/components/inputs/passcode
- Category: [inputs](https://crucibleui.com/components/inputs)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/passcode
```

Registry JSON: https://crucibleui.com/r/passcode.json

Import from `@/components/crucible/passcode`.

## Props (`PasscodeProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `string` | — | Controlled value — a string of up to `length` characters. Omit for uncontrolled use. |
| `defaultValue?` | `string` | `""` | Initial value when uncontrolled. |
| `length?` | `number` | `6` | Number of single-character cells. |
| `mode?` | `"numeric" \| "alphanumeric"` | `"numeric"` | Accepted characters. `"numeric"` allows 0–9 (mobile number pad, one-time-code autofill); `"alphanumeric"` allows 0–9 and A–Z. |
| `masked?` | `boolean` | `false` | Hide entered characters behind dots (like a PIN). |
| `onChange?` | `(value: string) => void` | — | Fires on every change with the concatenated value. |
| `onComplete?` | `(value: string) => void` | — | Fires once every cell is filled, with the complete code. |
| `colors?` | `[string, string] \| string[]` | `indigo + emerald` | `[accent, success]`. Accent lights the focus ring + caret; success flashes the row when the code completes. |
| `speed?` | `number` | `1` | Animation-speed multiplier for the pop, caret blink and success flash. |
| `paused?` | `boolean` | `false` | Freeze the caret blink and skip the success flash (still seats instantly). |
| `disabled?` | `boolean` | `false` | Disable all cells. |
| `autoFocus?` | `boolean` | `false` | Focus the first cell on mount. |
| `name?` | `string` | — | Submit name — emits a hidden input carrying the concatenated value. |
| `size?` | `"sm" \| "md"` | `"md"` | Cell scale. `"md"` is the roomy standalone size; `"sm"` is a tighter cell for auth cards, dialogs and narrow containers. |
| `aria-label?` | `string` | `"Verification code"` | Accessible name for the group of cells. |
| `className?` | `string` | — | — |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Passcode } from "@/components/crucible/passcode";

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function PasscodeDemo() {
  const [code, setCode] = React.useState("");
  const [done, setDone] = React.useState<string | null>(null);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-md flex-col items-center gap-8 px-6">
        <div className="flex flex-col items-center gap-3">
          <span className={LABEL}>Enter the 6-digit code</span>
          <Passcode
            aria-label="Verification code"
            onChange={(value) => {
              setCode(value);
              if (value.length < 6) setDone(null);
            }}
            onComplete={(value) => setDone(value)}
          />
        </div>

        <p className="text-sm text-neutral-500">
          Type, paste, or use the arrow keys — filled cells pop and a full code flashes.
        </p>

        <span className="h-5 text-xs text-neutral-600">
          {done ? (
            <>
              Verified: <span className="text-emerald-400">“{done}”</span>
            </>
          ) : (
            <>
              Entered: <span className="text-neutral-300">{code.length}/6</span>
            </>
          )}
        </span>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`passcode.tsx`)

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export interface PasscodeProps {
  /** Controlled value — a string of up to `length` characters. Omit for uncontrolled use. */
  value?: string;
  /** Initial value when uncontrolled. @default "" */
  defaultValue?: string;
  /** Number of single-character cells. @default 6 */
  length?: number;
  /**
   * Accepted characters. `"numeric"` allows 0–9 (mobile number pad, one-time-code
   * autofill); `"alphanumeric"` allows 0–9 and A–Z. @default "numeric"
   */
  mode?: "numeric" | "alphanumeric";
  /** Hide entered characters behind dots (like a PIN). @default false */
  masked?: boolean;
  /** Fires on every change with the concatenated value. */
  onChange?: (value: string) => void;
  /** Fires once every cell is filled, with the complete code. */
  onComplete?: (value: string) => void;
  /**
   * `[accent, success]`. Accent lights the focus ring + caret; success flashes
   * the row when the code completes. @default indigo + emerald
   */
  colors?: [string, string] | string[];
  /** Animation-speed multiplier for the pop, caret blink and success flash. @default 1 */
  speed?: number;
  /** Freeze the caret blink and skip the success flash (still seats instantly). @default false */
  paused?: boolean;
  /** Disable all cells. @default false */
  disabled?: boolean;
  /** Focus the first cell on mount. @default false */
  autoFocus?: boolean;
  /** Submit name — emits a hidden input carrying the concatenated value. */
  name?: string;
  /**
   * Cell scale. `"md"` is the roomy standalone size; `"sm"` is a tighter
   * cell for auth cards, dialogs and narrow containers. @default "md"
   */
  size?: "sm" | "md";
  /** Accessible name for the group of cells. @default "Verification code" */
  "aria-label"?: string;
  className?: string;
}

const SIZES = {
  md: {
    gap: "gap-2 sm:gap-3",
    cell: "h-14 w-11 sm:w-12",
    radius: "rounded-xl",
    glyph: "text-2xl",
    dot: "h-2.5 w-2.5",
    caret: "h-6",
  },
  sm: {
    gap: "gap-1.5 sm:gap-2",
    cell: "h-12 w-10",
    radius: "rounded-[10px]",
    glyph: "text-xl",
    dot: "h-2 w-2",
    caret: "h-5",
  },
} as const;

/** Indigo accent, emerald success — neutral-elegant, cool. */
const DEFAULT_COLORS: [string, string] = ["#818cf8", "#34d399"];

function sanitize(input: string, mode: "numeric" | "alphanumeric"): string {
  return mode === "numeric"
    ? input.replace(/[^0-9]/g, "")
    : input.replace(/[^0-9a-zA-Z]/g, "");
}

/**
 * Passcode — a row of `length` segmented single-character cells backed by real
 * inputs. Typing advances focus and seats each digit with a soft pop; Backspace
 * retreats, arrow/Home/End keys move, and a paste (or SMS one-time-code autofill)
 * distributes across the cells. The focused empty cell shows an animated caret
 * inside an accent focus ring; the moment every cell is filled the row flashes
 * a success hue and `onComplete` fires. Fully keyboard operable, every cell
 * carries its own aria-label, and mobile gets a numeric keypad. Reduced motion
 * drops the pop, caret blink and flash — cells seat instantly and success shows
 * as a static colour change.
 */
export function Passcode({
  value,
  defaultValue = "",
  length = 6,
  mode = "numeric",
  masked = false,
  onChange,
  onComplete,
  colors = DEFAULT_COLORS,
  speed = 1,
  paused = false,
  disabled = false,
  autoFocus = false,
  name,
  size = "md",
  "aria-label": ariaLabel = "Verification code",
  className,
}: PasscodeProps) {
  const sz = SIZES[size] ?? SIZES.md;
  const reducedMotion = useReducedMotion();
  const animate = !reducedMotion && !paused;
  const count = Math.max(1, Math.floor(length));
  const spd = Math.max(0.1, speed);

  const [accent, success] = React.useMemo<[string, string]>(() => {
    const c = colors.length ? colors : DEFAULT_COLORS;
    return [c[0] ?? DEFAULT_COLORS[0], c[1] ?? DEFAULT_COLORS[1]];
  }, [colors]);

  const inputsRef = React.useRef<(HTMLInputElement | null)[]>([]);
  const groupRef = React.useRef<HTMLDivElement>(null);
  const completedRef = React.useRef(false);
  const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
  const [pulseKey, setPulseKey] = React.useState(0);

  const isControlled = value !== undefined;
  const [internal, setInternal] = React.useState<string[]>(() =>
    Array.from({ length: count }, (_, i) => defaultValue[i] ?? "")
  );

  // Derive the fixed-length cell array from whichever source of truth is active.
  const chars = React.useMemo<string[]>(() => {
    const source = isControlled ? (value ?? "") : internal.join("");
    return Array.from({ length: count }, (_, i) => source[i] ?? "");
  }, [isControlled, value, internal, count]);

  const complete = chars.every((c) => c !== "");

  const commit = React.useCallback(
    (next: string[]) => {
      if (!isControlled) setInternal(next);
      onChange?.(next.join(""));
    },
    [isControlled, onChange]
  );

  // Fire onComplete once per completion; re-arm when the code is edited back down.
  React.useEffect(() => {
    if (complete && !completedRef.current) {
      completedRef.current = true;
      if (animate) setPulseKey((k) => k + 1);
      onComplete?.(chars.join(""));
    } else if (!complete && completedRef.current) {
      completedRef.current = false;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [complete]);

  const focusCell = React.useCallback(
    (index: number) => {
      const clamped = Math.max(0, Math.min(count - 1, index));
      const el = inputsRef.current[clamped];
      if (el) {
        el.focus();
        el.select();
      }
    },
    [count]
  );

  const setCharAt = React.useCallback(
    (index: number, char: string) => {
      const next = [...chars];
      next[index] = char;
      commit(next);
    },
    [chars, commit]
  );

  const distribute = React.useCallback(
    (start: number, incoming: string) => {
      if (!incoming) return;
      const next = [...chars];
      let idx = start;
      for (const ch of incoming) {
        if (idx >= count) break;
        next[idx] = ch;
        idx += 1;
      }
      commit(next);
      focusCell(Math.min(idx, count - 1));
    },
    [chars, commit, count, focusCell]
  );

  const handleChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
    let incoming = sanitize(event.target.value, mode);
    if (!incoming) {
      setCharAt(index, "");
      return;
    }
    // Typing into an already-filled cell without the text selected (e.g. after
    // a click repositioned the caret) yields old+new — keep only the new char
    // instead of spilling the old one into the next cell.
    const existing = chars[index];
    if (existing && incoming.length === 2) {
      incoming = incoming[0] === existing ? incoming.slice(1) : incoming.slice(0, 1);
    }
    distribute(index, incoming);
  };

  const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
    switch (event.key) {
      case "Backspace":
        event.preventDefault();
        if (chars[index]) {
          setCharAt(index, "");
        } else if (index > 0) {
          setCharAt(index - 1, "");
          focusCell(index - 1);
        }
        break;
      case "Delete":
        event.preventDefault();
        setCharAt(index, "");
        break;
      case "ArrowLeft":
        event.preventDefault();
        focusCell(index - 1);
        break;
      case "ArrowRight":
        event.preventDefault();
        focusCell(index + 1);
        break;
      case "Home":
        event.preventDefault();
        focusCell(0);
        break;
      case "End":
        event.preventDefault();
        focusCell(count - 1);
        break;
      default:
        break;
    }
  };

  const handlePaste = (index: number, event: React.ClipboardEvent<HTMLInputElement>) => {
    event.preventDefault();
    const text = sanitize(event.clipboardData.getData("text"), mode);
    distribute(index, text);
  };

  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
    if (!groupRef.current?.contains(event.relatedTarget as Node | null)) {
      setFocusedIndex(null);
    }
  };

  const joined = chars.join("");
  // Spring time-scale ∝ 1/sqrt(stiffness): scale stiffness by spd² and damping
  // by spd so `speed` speeds the pop up/down while keeping the same overshoot.
  const popTransition = {
    type: "spring" as const,
    stiffness: 560 * spd * spd,
    damping: 22 * spd,
    mass: 0.6,
  };

  return (
    <div
      ref={groupRef}
      role="group"
      aria-label={ariaLabel}
      data-crucible="passcode"
      className={cn(
        "relative inline-flex items-center",
        sz.gap,
        disabled && "opacity-50",
        className
      )}
    >
      {chars.map((char, i) => {
        const isFocused = focusedIndex === i;
        const showCaret = isFocused && !char && !disabled;
        const cellColor = complete ? success : isFocused ? accent : undefined;

        return (
          <div
            key={i}
            className={cn("relative flex items-center justify-center", sz.cell)}
          >
            {/* Cell frame — neutral at rest, accent on focus, success on complete. */}
            <div
              aria-hidden
              className={cn(
                "pointer-events-none absolute inset-0 border bg-neutral-900/40 transition-colors",
                sz.radius,
                char ? "border-neutral-600" : "border-neutral-800",
                complete && "border-transparent"
              )}
              style={{
                borderColor: cellColor,
                boxShadow: isFocused && !complete ? `0 0 0 3px ${accent}33` : undefined,
                transitionDuration: reducedMotion ? "0ms" : "180ms",
              }}
            />

            {/* Success flash — a one-shot glow when the last cell fills. */}
            {animate && complete && (
              <motion.div
                key={pulseKey}
                aria-hidden
                className={cn("pointer-events-none absolute inset-0", sz.radius)}
                initial={{ opacity: 0.55, boxShadow: `0 0 0 4px ${success}66` }}
                animate={{ opacity: 0, boxShadow: `0 0 0 10px ${success}00` }}
                transition={{ duration: 0.5 / spd, ease: "easeOut" }}
              />
            )}

            {/* Visible glyph / caret. The real input above is transparent, so this
                layer owns every pixel the user sees. Decorative → aria-hidden. */}
            <div
              aria-hidden
              className="pointer-events-none relative z-10 flex h-full w-full items-center justify-center"
            >
              <AnimatePresence mode="popLayout" initial={false}>
                {char ? (
                  <motion.span
                    key={`char-${char}`}
                    className={cn("font-medium tabular-nums", sz.glyph)}
                    style={{ color: complete ? success : "#f5f5f5" }}
                    initial={animate ? { scale: 0.55, opacity: 0, y: 3 } : false}
                    animate={{ scale: 1, opacity: 1, y: 0 }}
                    exit={animate ? { scale: 0.55, opacity: 0 } : { opacity: 0 }}
                    transition={animate ? popTransition : { duration: 0 }}
                  >
                    {masked ? (
                      <span className={cn("inline-block rounded-full bg-current align-middle", sz.dot)} />
                    ) : (
                      char
                    )}
                  </motion.span>
                ) : showCaret ? (
                  <motion.span
                    key="caret"
                    className={cn("block w-[2px] rounded-full", sz.caret)}
                    style={{ backgroundColor: accent }}
                    initial={{ opacity: 1 }}
                    animate={animate ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}
                    transition={
                      animate
                        ? { duration: 1.06 / spd, repeat: Infinity, ease: "linear" }
                        : { duration: 0 }
                    }
                  />
                ) : null}
              </AnimatePresence>
            </div>

            {/* The real, accessible input — transparent text + caret so the layer
                above renders instead. Holds exactly one character of state. */}
            <input
              ref={(el) => {
                inputsRef.current[i] = el;
              }}
              type="text"
              inputMode={mode === "numeric" ? "numeric" : "text"}
              pattern={mode === "numeric" ? "[0-9]*" : undefined}
              autoComplete={i === 0 ? "one-time-code" : "off"}
              // eslint-disable-next-line jsx-a11y/no-autofocus
              autoFocus={autoFocus && i === 0}
              disabled={disabled}
              value={char}
              aria-label={`${mode === "numeric" ? "Digit" : "Character"} ${i + 1} of ${count}`}
              onChange={(e) => handleChange(i, e)}
              onKeyDown={(e) => handleKeyDown(i, e)}
              onPaste={(e) => handlePaste(i, e)}
              onFocus={(e) => {
                setFocusedIndex(i);
                e.target.select();
              }}
              onBlur={handleBlur}
              className={cn(
                "absolute inset-0 z-20 h-full w-full bg-transparent text-center",
                sz.radius,
                "text-transparent caret-transparent outline-none",
                "selection:bg-transparent selection:text-transparent",
                "disabled:cursor-not-allowed"
              )}
            />
          </div>
        );
      })}

      {name ? <input type="hidden" name={name} value={joined} /> : null}
    </div>
  );
}
```
