# Code input

> An Enter code pill that opens into six cells, checks the code and turns green, or red to try again.

A one-time code field in a single pill. Press Enter code and it pinches, then springs open into six cells with a dark outline on the one you are typing in. The sixth digit folds the row into Verifying; a right code turns it into a green Verified pill with a glint across it, a wrong one brings the row back blushed red, its digits dropping out one by one before you try again.

A React component by Bruno Delorence: https://brunodelorence.com/components/code-input. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Shell**: One pill holds the button, the row and the status in turn, and springs between their widths. A 4 px rim shows around each face.
2. **Cell**: Fixed in size and spread edge to edge, so they part as the shell overshoots and bunch as it pinches.
3. **Digit**: Each one rises into its cell as it is typed, and drops out last-first when a code is wrong.
4. **Active cell**: A dark outline that glides to wherever the caret is. Tap a cell or use the arrow keys to move it.
5. **Caret**: Blinks inside the active cell while the field has focus, so the place to type is never in doubt.
6. **Field**: One real `<input>` laid over the row with transparent text: numeric keyboard, paste fills every cell, screen readers hear one field of 6 digits.

## When to use

- A one-time code sent by text or email at sign-in. The field asks for `one-time-code`, so phones offer the code from the message.
- A second step that waits until it is wanted: the small button keeps the screen calm until the person has the code.
- Numeric codes of 4 to 8 digits, checked the moment the last digit lands, with no submit button.
- Checks whose result must read at a glance: Verifying, Verified, or the same row in red to try again.

## When not to use

- Passwords and PINs: the digits stay on screen. Use a password field.
- Codes with letters, dashes or more than 8 characters. A plain text field takes them, pasted.
- Showing a code the person must copy somewhere else. That is text to read, not a field.
- Checks that can take longer than a few seconds: the pill would sit on Verifying with nothing else to say.

## Installation

Install it with the shadcn CLI, into components/code-input of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/code-input.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { CodeInput } from './code-input';

export function SignInStep({ email }: { email: string }) {
  return (
    <CodeInput
      autoplay={false}
      verify={async (code) => {
        const response = await fetch('/api/verify', { method: 'POST', body: JSON.stringify({ email, code }) });
        return response.ok;
      }}
      onVerified={() => location.assign('/home')}
    />
  );
}
```

## States

- **Button**: At rest it is only a small pill: `Enter code`. Pressing it opens the row and focuses the field.
- **Typing**: Digits fill from the left; the outline and caret sit on the next empty cell.
- **Verifying**: The full code is sent at once. The word types itself in and stays at least 600 ms, so a quick answer still reads.
- **Wrong code**: The row comes back blushed with red digits, holds a moment, then empties itself for another try.
- **Verified**: Green, with a glint across it. Only the letters that differ from Verifying blur across.

## How it works

- One white shell shows three faces in turn: the button, the row of cells and the status pill. Changing face is one Web Animations keyframe list on its width: an ease-out pinch to a small pill (66 ms from a pill, 170 ms from the row), then a damped spring sampled at 60 Hz out to the new face’s width: 19% over at 260 ms for the row, 9% over at 170 ms for a pill.
- The old face blurs away during the pinch while the new one blurs in on the spring: cells from the middle outwards (35 ms apart), status letters from left to right (24 ms apart), the active cell’s outline last and slower.
- The cells keep their size and are spread from edge to edge of the shell, each one’s left a calc() of the shell’s width, so they part while the spring overshoots and bunch up while it pinches, with no measuring.
- Typing goes to one real input laid over the row with transparent text and caret: inputMode numeric and autocomplete one-time-code, so phones offer the code from a message and a pasted code fills every cell. The digit at the caret is kept selected, so typing over a cell replaces it; arrows, Home, End and Backspace move and delete as in any field.
- The outline glides to the active cell in 240 ms and rounds into a D on the end cells; its caret stays hidden while it moves, then blinks. Each digit rises into its cell out of a blur.
- The status words are split into letters: on Verified only the ones that differ (ying, ied) blur across, the old over the new. The grey turns green in 160 ms, the spinner blurs into a tick, and 470 ms later a two-band glint crosses the pill.
- Every colour is a light-dark() token on the root: at night the stage is near-black, the shell and cells warm greys, the wrong cells a deep wine with coral digits. It follows the page’s color-scheme, or the colorScheme prop.

## API reference

- **length** (`number`, default `6`): How many digits the code has.
- **verify** (`(code: string) => Promise<boolean> | boolean`): Checks a complete code; false, or a rejection, counts as wrong. Without it, 123456 passes after 1.2 s, as a server would.
- **onVerified** (`(code: string) => void`): Called with the code once it is verified.
- **onComplete** (`(code: string) => void`): Called with every complete code, right or wrong, before it is checked.
- **labels** (`{ enter?, verifying?, verified?, field?, wrong?: string }`): The words: the button’s (Enter code), the two statuses, the input’s name (Verification code) and what is announced for a wrong code.
- **minVerifying** (`number`, default `600`): Verifying stays at least this long, in ms, so a quick answer still reads.
- **defaultState** (`'idle' | 'typing' | 'verifying' | 'wrong' | 'verified'`): Opens on one state and holds it, with no demo, until the reader acts: the button, three digits typed, verifying, a wrong code’s red row, or verified.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo types a wrong code, then the right one, and starts over. It checks its own codes and never calls verify or onVerified.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- Enter code is a real button; it opens the row and moves focus to a real text input named “Verification code, 6 digits”, with inputMode numeric and autocomplete one-time-code, so screen readers, keyboards, paste and phones’ code suggestions all work as in any field.
- The cells, the outline and the status pill are drawn for the eye and hidden from assistive technology; a polite live region says Verifying…, Verified, or Wrong code, try again, and the input is marked aria-invalid while a code is wrong.
- Escape in an empty row goes back to the button. The button shows a focus ring around the pill; in the row, the outline is the focus indicator.
- Under reduced motion the shell changes size at once, nothing blurs or glides, the caret holds still, there is no glint and the demo never plays. The demo also stops whenever the field has focus.

## Source

### code-input/code-input.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type ChangeEvent,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './code-input.module.css';

/*
 * Code input: a small "Enter code" pill that opens into a row of cells for a one-time code, checks
 * it, and says so: "Verifying", then "Verified", or the row again, red, to try once more.
 *
 * - One white shell holds three faces, only one of them current: the button, the row of cells and
 *   the status pill. Changing face pinches the shell (its width eases down to a small pill while
 *   the old face blurs away), then springs it open to the new face's width while the new one
 *   blurs in. The width is a single Web Animations keyframe list: the pinch, then a damped spring
 *   sampled at 60 Hz.
 * - The cells sit at fixed sizes, spread from edge to edge of the shell (each one's left is a
 *   calc() of the shell's width), so while the spring overshoots they part, and while the shell
 *   pinches they bunch up. They blur in from the middle outwards and out from the ends inwards.
 * - Typing goes to one real <input> laid over the row, with transparent text: inputMode numeric,
 *   autocomplete one-time-code, so phones offer the code from a message and a pasted code fills
 *   every cell. The caret's place picks the active cell; the dark outline glides there.
 * - Status words are split into letters. "Verifying" types itself in from left to right; on
 *   "Verified" only the letters that differ ("ying" for "ied") blur across, and a glint crosses
 *   the green pill.
 * - While on screen and untouched, a demo types a wrong code, then the right one, then starts over.
 * - defaultState opens it on one state (the row half typed, "Verifying", a wrong code…) and holds
 *   it there, with no demo, until the reader acts: for documentation and tests.
 * - Light or dark: every colour is a light-dark() token in the CSS.
 */

type Face = 'button' | 'row' | 'status';
type Phase = 'idle' | 'entry' | 'error' | 'verifying' | 'verified';
/** A state to open on (defaultState). */
export type CodeInputState = 'idle' | 'typing' | 'verifying' | 'wrong' | 'verified';
type Labels = {
  /** The button's words. */
  enter: string;
  verifying: string;
  verified: string;
  /** The input's accessible name. */
  field: string;
  /** Announced when a code is wrong. */
  wrong: string;
};

const LABELS: Labels = {
  enter: 'Enter code',
  verifying: 'Verifying',
  verified: 'Verified',
  field: 'Verification code',
  wrong: 'Wrong code, try again',
};

/** The code the default check accepts, and the demo types in the end: 123456 for six digits. */
const rightCode = (length: number) => '1234567890'.repeat(Math.ceil(length / 10)).slice(0, length);
/** The demo's first, wrong try. */
const WRONG_CODE = '213212';
/** The demo's typing rhythm, in ms between digits: the wrong try, then the right one. */
const RHYTHM = [300, 350, 380, 220, 200];
const RHYTHM_RIGHT = [350, 280, 350, 200, 200];
/** The wrong code's digits leave this many ms apart, the last first. */
const STAGGER = 32;

const faceOf = (phase: Phase): Face =>
  phase === 'idle' ? 'button' : phase === 'entry' || phase === 'error' ? 'row' : 'status';

/* Sizes in em of the 12 px type, as in the CSS: the shell's white rim, a cell and the gap. */
const RIM = 1 / 3;
const CELL = 2.729;
const GAP = 0.4167;
/** Padding of the button and the status pill around their icon and words. */
const PAD = 1.5 + 1.4167;
/** How narrow the shell pinches between two faces: from the row, and from a pill. */
const PINCH_ROW = 5.1;
const PINCH_PILL = 6;
/** The blur that faces come and go through. */
const BLUR = 'blur(0.45em)';

/** Where each defaultState opens: the phase, and the digits already in. */
const OPENINGS: Record<CodeInputState, { phase: Phase; code: (length: number) => string }> = {
  idle: { phase: 'idle', code: () => '' },
  typing: { phase: 'entry', code: (length) => rightCode(length).slice(0, Math.min(3, length - 1)) },
  verifying: { phase: 'verifying', code: () => '' },
  wrong: { phase: 'error', code: (length) => WRONG_CODE.slice(0, length).padEnd(length, '2') },
  verified: { phase: 'verified', code: () => '' },
};

/** Wait out a promise-less pause. */
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

/** The default check: accepts 123456 (for six digits) after a short wait, as a server would. */
async function checkDemo(code: string) {
  await wait(1200);
  return code === rightCode(code.length);
}

/**
 * A damped spring's progress from 0 to 1, sampled at 60 Hz until it settles. `ratio` is its
 * damping ratio (under 1 it overshoots), `omega` its natural frequency in rad/s.
 */
function spring(ratio: number, omega: number) {
  const damped = omega * Math.sqrt(1 - ratio * ratio);
  const samples: number[] = [];
  for (let t = 0; ; t += 1 / 60) {
    const decay = Math.exp(-ratio * omega * t);
    samples.push(
      1 - decay * (Math.cos(damped * t) + ((ratio * omega) / damped) * Math.sin(damped * t)),
    );
    if (decay < 0.003) break;
  }
  samples[samples.length - 1] = 1;
  return samples;
}
/** Opening into the row: slow, with a 19% overshoot at 260 ms. */
const ROW_SPRING = spring(0.47, 13.7);
/** Into a pill: quicker, 9% over at 170 ms. */
const PILL_SPRING = spring(0.61, 23.7);

export function CodeInput({
  length = 6,
  verify = checkDemo,
  onVerified,
  onComplete,
  labels: labelProp,
  minVerifying = 600,
  defaultState,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** How many digits the code has. */
  length?: number;
  /**
   * Checks a complete code. Without it, the code 123456 passes after 900 ms. Rejecting counts
   * as a wrong code.
   */
  verify?: (code: string) => Promise<boolean> | boolean;
  /** Called with the code once it is verified. */
  onVerified?: (code: string) => void;
  /** Called with every complete code, right or wrong, before it is checked. */
  onComplete?: (code: string) => void;
  /** The words: enter, verifying, verified, field (the input's name) and wrong (announced). */
  labels?: Partial<Labels>;
  /** "Verifying" stays at least this long, in ms, so a quick answer still reads. */
  minVerifying?: number;
  /**
   * Opens on one state and holds it until the reader acts, with no demo: the button ('idle'), the
   * row with three digits typed ('typing'), 'verifying', the red row of a wrong code ('wrong') or
   * 'verified'. For documentation and tests.
   */
  defaultState?: CodeInputState;
  /** While on screen and untouched, a demo types a wrong code, then the right one. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const labels = { ...LABELS, ...labelProp };
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);

  const opening = OPENINGS[defaultState ?? 'idle'];
  const [phase, setPhase] = useState<Phase>(opening.phase);
  const [value, setValue] = useState(() => opening.code(length));
  const [caret, setCaret] = useState(() => Math.min(opening.code(length).length, length - 1));
  const [focused, setFocused] = useState(false);
  /** A 'typing' defaultState shows its active cell until the reader comes in. */
  const [held, setHeld] = useState(defaultState === 'typing');
  /** The demo never plays while the reader is in the field, nor on a state held for the docs. */
  const demo = autoplay && playing && !focused && !defaultState;
  /** The wrong code's digits leaving, last first. */
  const [clearing, setClearing] = useState(false);
  /** The face fading out while the next comes in; it stays drawn until then. */
  const [leaving, setLeaving] = useState<Face | null>(null);
  const [message, setMessage] = useState('');
  /** Status words, and the ones before them, whose differing letters blur across. */
  const statusLabel = phase === 'verified' ? labels.verified : labels.verifying;
  const [words, setWords] = useState({ now: statusLabel, was: '' });
  const face = faceOf(phase);
  if (words.now !== statusLabel)
    setWords({ now: statusLabel, was: face === 'status' ? words.now : '' });

  const shell = useRef<HTMLDivElement>(null);
  const faces = useRef<Record<Face, HTMLDivElement | null>>({
    button: null,
    row: null,
    status: null,
  });
  const input = useRef<HTMLInputElement>(null);
  const button = useRef<HTMLButtonElement>(null);
  const statusContent = useRef<HTMLSpanElement>(null);
  const buttonContent = useRef<HTMLSpanElement>(null);
  /** Bumped on every reset, so pending timers of an earlier attempt do nothing. */
  const attempt = useRef(0);
  /** The demo plays without focus: it shows the active cell as if focused. */
  const demoOn = useRef(false);
  const shownFace = useRef<Face>(faceOf(opening.phase));
  /** Stops drawing the face that left, once it has blurred away. */
  const leaveTimer = useRef(0);
  const phaseRef = useRef(phase);
  phaseRef.current = phase;

  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));
  const reduced = () =>
    typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  /** The shell's natural width for a face, in em. */
  const widthOf = (target: Face) => {
    if (target === 'row') return 2 * RIM + length * CELL + (length - 1) * GAP;
    const content = target === 'button' ? buttonContent.current : statusContent.current;
    const size = parseFloat(getComputedStyle(shell.current!).fontSize) || 12;
    return 2 * RIM + PAD + (content ? content.offsetWidth / size : 6);
  };

  // Moves the shell to the current face: pinch and spring, or straight there under reduced motion.
  // Runs before paint, so the new face never shows at the wrong width.
  useLayoutEffect(() => {
    const element = shell.current;
    if (!element) return;
    const from = shownFace.current;
    shownFace.current = face;
    const size = parseFloat(getComputedStyle(element).fontSize) || 12;
    const start = element.offsetWidth / size;
    const target = widthOf(face);
    for (const animation of element.getAnimations()) animation.cancel();
    element.style.width = `${target}em`;
    if (from === face && words.was === '') return;
    if (reduced()) {
      setLeaving(null);
      return;
    }

    const frames: Keyframe[] = [];
    let pinch = 0;
    const narrow = Math.min(start, start > 15 ? PINCH_ROW : PINCH_PILL);
    if (from !== face) {
      // The pinch: an ease-out from the current width down to a small pill.
      pinch = start > 15 ? 170 : 66;
      const steps = Math.round(pinch / (1000 / 60));
      for (let i = 0; i < steps; i++)
        frames.push({
          width: `${start + (narrow - start) * Math.sin(((i / steps) * Math.PI) / 2)}em`,
        });
    }
    const base = from !== face ? narrow : start;
    for (const x of face === 'row' ? ROW_SPRING : PILL_SPRING)
      frames.push({ width: `${base + (target - base) * x}em` });
    element.animate(frames, { duration: (frames.length - 1) * (1000 / 60), easing: 'linear' });
    if (from === face) return;

    // The old face blurs away during the pinch; the cells from the ends inwards.
    const old = faces.current[from];
    if (old) {
      if (from === 'row') {
        const cells = [...old.querySelectorAll<HTMLElement>('[data-cell]')];
        const middle = (cells.length - 1) / 2;
        cells.forEach((cell, i) =>
          cell.animate([{}, { filter: BLUR, opacity: 0 }], {
            duration: pinch + 60,
            delay: (middle - Math.abs(i - middle)) * 18,
            easing: 'cubic-bezier(0.2, 0.6, 0.4, 1)',
            fill: 'forwards',
          }),
        );
      } else
        old.animate([{}, { filter: BLUR, opacity: 0 }], {
          duration: pinch + 90,
          easing: 'cubic-bezier(0.2, 0.6, 0.4, 1)',
          fill: 'forwards',
        });
    }
    window.clearTimeout(leaveTimer.current);
    leaveTimer.current = window.setTimeout(() => setLeaving(null), pinch + 160);

    // The new one blurs in as the spring opens: cells from the middle out, letters left to right.
    const next = faces.current[face];
    if (next) {
      // Its own leftovers from leaving (not the caret's blink or the digits' CSS animations).
      for (const animation of next.getAnimations({ subtree: true }))
        if (!(animation instanceof CSSAnimation)) animation.cancel();
      const blurIn = (part: Element, delay: number, duration = 240) =>
        part.animate(
          [
            { filter: BLUR, opacity: 0 },
            { filter: 'blur(0)', opacity: 1 },
          ],
          {
            duration,
            delay: pinch + delay,
            easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)',
            fill: 'backwards',
          },
        );
      if (face === 'row') {
        const cells = [...next.querySelectorAll('[data-cell]')];
        const middle = (cells.length - 1) / 2;
        cells.forEach((cell, i) => blurIn(cell, Math.abs(i - middle) * 35));
        // The outline comes last, and slower (when it shows: not on a wrong code).
        next.querySelectorAll('[data-indicator][data-on]').forEach((part) =>
          part.animate([{ filter: BLUR, opacity: 0 }, {}], {
            duration: 320,
            delay: pinch + 60,
            easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)',
            fill: 'backwards',
          }),
        );
      } else {
        next.querySelectorAll('[data-fill]').forEach((part) => blurIn(part, 0, 200));
        next.querySelectorAll('[data-icon]').forEach((part) => blurIn(part, 10));
        next.querySelectorAll('[data-letter]').forEach((part, i) => blurIn(part, 40 + i * 24));
      }
    }
  }, [face, words]);
  useEffect(() => () => window.clearTimeout(leaveTimer.current), []);

  // Keeps the shell's width right when the code's length changes.
  useLayoutEffect(() => {
    if (shell.current) shell.current.style.width = `${widthOf(shownFace.current)}em`;
  }, [length]);

  const changeFace = (next: Phase) => {
    const from = faceOf(phaseRef.current);
    if (from !== faceOf(next)) setLeaving(from);
    setPhase(next);
  };

  /** Back to the button, from anywhere. */
  const reset = (focusButton = false) => {
    attempt.current++;
    setClearing(false);
    setValue('');
    setCaret(0);
    changeFace('idle');
    if (focusButton) requestAnimationFrame(() => button.current?.focus({ preventScroll: true }));
  };

  /** The button opens the row, and focuses its input unless the demo pressed it. */
  const open = (focus = true) => {
    attempt.current++;
    setValue('');
    setCaret(0);
    changeFace('entry');
    if (focus) input.current?.focus({ preventScroll: true });
  };

  /**
   * A complete code: a beat for its last digit to land, then "Verifying" for as long as the check
   * takes (and at least minVerifying), then "Verified" or the red row.
   */
  const submit = async (code: string, check: typeof verify, report: boolean) => {
    const id = ++attempt.current;
    if (report) onComplete?.(code);
    await wait(reduced() ? 0 : 170);
    if (id !== attempt.current) return;
    changeFace('verifying');
    announce(`${labels.verifying}…`);
    let right = false;
    try {
      [right] = await Promise.all([Promise.resolve(check(code)), wait(minVerifying)]);
    } catch {
      right = false;
    }
    if (id !== attempt.current) return;
    if (right) {
      changeFace('verified');
      announce(labels.verified);
      if (report) onVerified?.(code);
      return;
    }
    changeFace('error');
    announce(labels.wrong);
    // The red row holds a moment, then its digits drop out, the last first.
    const quick = reduced();
    await wait(quick ? 900 : 330);
    if (id !== attempt.current) return;
    setClearing(true);
    setCaret(0);
    await wait(quick ? 0 : (length - 1) * STAGGER + 150);
    if (id !== attempt.current) return;
    setClearing(false);
    setValue('');
    setCaret(0);
    setPhase('entry');
  };

  /** Puts the code in, from typing, pasting or the demo; sets the caret and submits when full. */
  const enter = (digits: string, at: number, report = true) => {
    const code = digits.replace(/\D/g, '').slice(0, length);
    setValue(code);
    setCaret(Math.min(at, length - 1, code.length));
    if (code.length === length) submit(code, report ? verify : checkDemo, report);
  };

  /** Selects the digit at the caret, so typing replaces it; at the end the caret is plain. */
  const place = (at: number, code = value) => {
    const field = input.current;
    const index = Math.max(0, Math.min(at, code.length, length - 1));
    setCaret(index);
    if (!field || document.activeElement !== field) return;
    const end = index < code.length ? index + 1 : index;
    field.setSelectionRange(index, end);
  };

  const onChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (phase !== 'entry') return;
    const raw = event.target.value;
    const code = raw.replace(/\D/g, '').slice(0, length);
    // The caret after the edit, in digits: what came before it, without anything else typed.
    const before = raw.slice(0, event.target.selectionStart ?? raw.length).replace(/\D/g, '');
    enter(code, before.length);
    requestAnimationFrame(() => place(before.length, code));
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    if (phase !== 'entry') return;
    const last = Math.min(value.length, length - 1);
    const moves: Record<string, number> = {
      ArrowLeft: caret - 1,
      ArrowRight: caret + 1,
      Home: 0,
      End: last,
      ArrowUp: 0,
      ArrowDown: last,
    };
    if (event.key in moves) {
      event.preventDefault();
      place(moves[event.key]);
    } else if (event.key === 'Backspace' && caret === value.length && caret > 0) {
      // At the empty cell after the digits: remove the last one.
      event.preventDefault();
      enter(value.slice(0, -1), caret - 1);
      requestAnimationFrame(() => place(caret - 1, value.slice(0, -1)));
    } else if (event.key === 'Escape' && !value) {
      event.preventDefault();
      reset(true);
    }
  };

  /** A click or tap picks the cell under it (or the first empty one, beyond the digits). */
  const onPointerUp = (event: ReactPointerEvent<HTMLInputElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const cell = Math.floor(((event.clientX - rect.left) / rect.width) * length);
    requestAnimationFrame(() => place(cell));
  };

  // While the demo plays: press the button, type a wrong code, then the right one, start over.
  useEffect(() => {
    if (!demo) return;
    demoOn.current = true;
    let stopped = false;
    const pause = (ms: number) =>
      new Promise<void>((resolve, reject) =>
        setTimeout(() => (stopped ? reject(new Error('stopped')) : resolve()), ms),
      );
    const until = async (test: () => boolean) => {
      while (!test()) await pause(50);
    };
    const type = async (code: string, rhythm: number[]) => {
      for (let i = 0; i < code.length; i++) {
        if (i) await pause(rhythm[(i - 1) % rhythm.length]);
        enter(code.slice(0, i + 1), i + 1, false);
      }
    };
    (async () => {
      for (let round = 0; ; round++) {
        reset();
        await pause(round ? 1300 : 900);
        open(false);
        await pause(800);
        await type(WRONG_CODE.slice(0, length).padEnd(length, '2'), RHYTHM);
        // Out of the row and back: "Verifying", then the red row clearing itself.
        await until(() => phaseRef.current !== 'entry');
        await until(() => phaseRef.current === 'entry');
        await pause(480);
        await type(rightCode(length), RHYTHM_RIGHT);
        await until(() => phaseRef.current === 'verified');
        await pause(2600);
      }
    })().catch(() => {});
    return () => {
      stopped = true;
      demoOn.current = false;
      // The reader takes over from wherever the demo was, except halfway through a code, or on
      // its status, which goes back to the button.
      if (phaseRef.current === 'entry') {
        setValue('');
        setCaret(0);
      } else if (faceOf(phaseRef.current) === 'status') reset();
    };
  }, [demo]);

  const showIndicator =
    (phase === 'entry' && value.length < length && (focused || demo || held)) ||
    (clearing && (focused || demo));
  const active = Math.min(caret, length - 1);
  const prefix = words.was ? commonPrefix(words.now, words.was) : 0;
  const shown = (target: Face) => face === target || leaving === target;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onInputCapture={take}
      onFocusCapture={take}
    >
      <div
        ref={shell}
        className={styles.shell}
        data-part="shell"
        data-phase={phase}
        style={{ '--n': length } as CSSProperties}
      >
        <div
          ref={(element) => {
            faces.current.button = element;
          }}
          className={styles.face}
          data-shown={shown('button')}
          // While the demo shows its status, Tab can still reach the button: focus stops the demo.
          inert={face !== 'button' && !(demo && face === 'status')}
        >
          <button
            ref={button}
            type="button"
            className={styles.button}
            data-part="button"
            onClick={() => open()}
          >
            <span className={styles.fill} data-fill aria-hidden="true" />
            <span ref={buttonContent} className={styles.content}>
              <svg
                className={styles.icon}
                data-part="lock"
                viewBox="0 0 14 14"
                aria-hidden="true"
                focusable="false"
              >
                <rect x="3" y="6.25" width="8" height="6" rx="1.6" />
                <path d="M4.9 6.25V4.6a2.1 2.1 0 0 1 4.2 0v1.65" />
              </svg>
              <span>{labels.enter}</span>
            </span>
          </button>
        </div>

        <div
          ref={(element) => {
            faces.current.row = element;
          }}
          className={styles.face}
          data-shown={shown('row')}
          aria-hidden={face !== 'row' || undefined}
        >
          <div
            className={styles.cells}
            data-part="cells"
            data-error={phase === 'error' || undefined}
            aria-hidden="true"
          >
            {Array.from({ length }, (_, i) => {
              const digit = value[i];
              return (
                <span
                  key={i}
                  data-cell
                  className={styles.cell}
                  data-edge={i === 0 ? 'first' : i === length - 1 ? 'last' : undefined}
                  style={{ '--i': i } as CSSProperties}
                >
                  {digit && (
                    <span
                      key={digit}
                      className={styles.digit}
                      data-part="digit"
                      data-out={clearing || undefined}
                      style={{ '--out': `${(length - 1 - i) * STAGGER}ms` } as CSSProperties}
                    >
                      {digit}
                    </span>
                  )}
                </span>
              );
            })}
            <span
              className={styles.indicator}
              data-indicator
              data-on={showIndicator || undefined}
              data-late={clearing || undefined}
              data-edge={active === 0 ? 'first' : active === length - 1 ? 'last' : undefined}
              style={{ '--i': active } as CSSProperties}
            >
              {showIndicator && <span key={`${active}-${value.length}`} className={styles.caret} />}
            </span>
          </div>
          <input
            ref={input}
            className={styles.input}
            value={value}
            onChange={onChange}
            onKeyDown={onKeyDown}
            onPointerUp={onPointerUp}
            onSelect={(event) => {
              const at = event.currentTarget.selectionStart ?? 0;
              if (phase === 'entry' && at !== caret && event.currentTarget.selectionEnd === at)
                place(at);
            }}
            onFocus={() => {
              setFocused(true);
              setHeld(false);
              // A wrong code held for the docs clears as the reader comes in to try again.
              if (defaultState === 'wrong' && phaseRef.current === 'error') {
                setValue('');
                setCaret(0);
                setPhase('entry');
              }
              requestAnimationFrame(() => place(caret));
            }}
            onBlur={() => setFocused(false)}
            readOnly={phase !== 'entry'}
            tabIndex={face === 'row' ? 0 : -1}
            inputMode="numeric"
            autoComplete="one-time-code"
            pattern="[0-9]*"
            maxLength={length}
            spellCheck={false}
            aria-label={`${labels.field}, ${length} digits`}
            aria-invalid={phase === 'error' || undefined}
          />
        </div>

        <div
          ref={(element) => {
            faces.current.status = element;
          }}
          className={styles.face}
          data-shown={shown('status')}
          inert={face !== 'status'}
          aria-hidden="true"
        >
          <span
            className={styles.status}
            data-part="status"
            data-done={phase === 'verified' || undefined}
          >
            <span className={styles.fill} data-fill>
              <span className={styles.shine} />
            </span>
            <span ref={statusContent} className={styles.content}>
              <span className={styles.statusIcon} data-icon>
                <svg className={styles.spinner} viewBox="0 0 14 14" focusable="false">
                  <circle cx="7" cy="7" r="5" />
                </svg>
                <svg className={styles.check} viewBox="0 0 14 14" focusable="false">
                  <path d="M3.4 7.3 5.9 9.8 10.6 4.5" />
                </svg>
              </span>
              <span className={styles.words} data-part="words">
                {[...words.now.slice(0, prefix)].map((letter, i) => (
                  <span key={`${i}${letter}`} data-letter className={styles.letter}>
                    {letter}
                  </span>
                ))}
                <span className={styles.suffix}>
                  {[...words.now.slice(prefix)].map((letter, i) => (
                    <span
                      key={`${words.now}${i}`}
                      data-letter
                      className={styles.letter}
                      data-in={words.was ? true : undefined}
                      style={{ '--d': `${i * 30}ms` } as CSSProperties}
                    >
                      {letter}
                    </span>
                  ))}
                  {words.was && (
                    <span key={words.was} className={styles.ghost}>
                      {[...words.was.slice(prefix)].map((letter, i) => (
                        <span
                          key={i}
                          className={styles.letter}
                          style={{ '--d': `${i * 20}ms` } as CSSProperties}
                        >
                          {letter}
                        </span>
                      ))}
                    </span>
                  )}
                </span>
              </span>
            </span>
          </span>
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

function commonPrefix(a: string, b: string) {
  let i = 0;
  while (i < a.length && i < b.length && a[i] === b[i]) i++;
  return i;
}
```

### code-input/code-input.module.css

```css
/* Code input (code-input.tsx): a warm off-white stage with one white pill in its middle, which is
   by turns the button, the row of cells and the status. Every size is in em of the pill's 12 px
   type, and the stage draws it larger from its own width (a container query).

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Memory. The stage is one layout boundary, sized from outside, so the pill's width animation
   never lays out the page. Blurs run on small elements (a cell, a letter) and stop when done. */

.root {
  /* The stage, the white pill on it and the grey of its cells. */
  --stage: #f5f4f0;
  --shell: #fdfdfd;
  --cell: #ebe9e4;
  /* The words, the digits, the active cell's outline and the caret. */
  --ink: #181711;
  /* The pill's edge and its shadow. */
  --edge: rgb(0 0 0 / 0.08);
  --shadow: rgb(0 0 0 / 0.075);
  /* A wrong code: blushed cells, red digits. */
  --wrong: #fae2e4;
  --wrong-ink: #d02e46;
  /* Verified: green, white words, and the glint that crosses it. */
  --green: #25bc4d;
  --on-green: #fdfdfd;
  --glint: rgb(255 255 255 / 0.5);
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  contain: size layout;
  display: grid;
  place-items: center;
  width: 100%;
  aspect-ratio: 3 / 2;
  min-height: 260px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f5f4f0, #141412);
    --shell: light-dark(#fdfdfd, #262522);
    --cell: light-dark(#ebe9e4, #363530);
    --ink: light-dark(#181711, #f2f1ec);
    --edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));
    --shadow: light-dark(rgb(0 0 0 / 0.075), rgb(0 0 0 / 0.5));
    --wrong: light-dark(#fae2e4, #47232a);
    --wrong-ink: light-dark(#d02e46, #ff7a8e);
    --green: light-dark(#25bc4d, #22b049);
    --on-green: light-dark(#fdfdfd, #fdfdfd);
    --glint: light-dark(rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.4));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The white pill. Its width is set (and animated) by the script; this is the button's, for the
   first paint. Its rim is 4 px of white around whatever face it shows. */
.shell {
  --rim: 0.3333em;
  --cell-w: 2.729em;
  position: relative;
  /* 12 px on a phone, growing with the stage to about half its width for the row. */
  font-size: clamp(12px, 2.6cqi, 26px);
  width: 10.3em;
  height: 3.6667em;
  border-radius: 999px;
  background: var(--shell);
  box-shadow:
    0 0 0 0.0833em var(--edge),
    0 0.5em 1.6em -0.2em var(--shadow);
  overflow: hidden;
  overflow: clip;
  color: var(--ink);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.012em;
  contain: layout;
}
.shell:has(.button:focus-visible) {
  box-shadow:
    0 0 0 0.0833em var(--edge),
    0 0.5em 1.6em -0.2em var(--shadow),
    0 0 0 0.25em var(--focus);
}

/* One face: the button, the row or the status, inside the rim. */
.face {
  position: absolute;
  inset: var(--rim);
}
.face[data-shown='false'] {
  opacity: 0;
  pointer-events: none;
}

/* The grey of the button and of the status, its whole width. */
.fill {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 999px;
  background: var(--cell);
  transition: background-color 160ms ease-out;
}

/* Icon and words, centred: 18 px before, 17 after (the script adds them to its width). */
.content {
  position: relative;
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 0.5833em;
  margin-left: 0.0833em;
  white-space: nowrap;
}
.icon {
  width: 1.1667em;
  height: 1.1667em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.button {
  all: unset;
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  cursor: pointer;
}

/* The row: cells of a set size, spread from edge to edge of the shell whatever its width, so
   they part while it overshoots and bunch while it pinches. */
.cells {
  position: absolute;
  inset: 0;
}
.cell,
.indicator {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--i) * (100% - var(--cell-w)) / (var(--n) - 1));
  width: var(--cell-w);
  border-radius: 0.875em;
}
/* The ends follow the pill's curve: rounder, and taller than wide. */
.cell[data-edge='first'] {
  border-radius: 1em 0.875em 0.875em 1em / 1.5em 0.875em 0.875em 1.5em;
}
.cell[data-edge='last'] {
  border-radius: 0.875em 1em 1em 0.875em / 0.875em 1.5em 1.5em 0.875em;
}
/* The outline is rounder still at the ends: a D. */
.indicator[data-edge='first'] {
  border-radius: 1.3em 0.875em 0.875em 1.3em / 1.5em 0.875em 0.875em 1.5em;
}
.indicator[data-edge='last'] {
  border-radius: 0.875em 1.3em 1.3em 0.875em / 0.875em 1.5em 1.5em 0.875em;
}
.cell {
  display: grid;
  place-items: center;
  background: var(--cell);
  transition:
    background-color 160ms ease-out,
    color 160ms ease-out;
}
.cells[data-error] .cell {
  background: var(--wrong);
  color: var(--wrong-ink);
}

/* A digit rises into its cell out of a blur; a wrong one drops out of it. */
.digit {
  font-size: 1.25em;
  font-variant-numeric: tabular-nums;
  animation: digit-in 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
.digit[data-out] {
  animation: digit-out 150ms ease-in var(--out) forwards;
}
@keyframes digit-in {
  from {
    opacity: 0;
    filter: blur(0.3em);
    transform: translateY(0.4em);
  }
  to {
    filter: blur(0);
  }
}
@keyframes digit-out {
  from {
    filter: blur(0);
  }
  to {
    opacity: 0;
    filter: blur(0.3em);
    transform: translateY(0.45em);
  }
}

/* The active cell's outline, which glides from cell to cell, and its caret. */
.indicator {
  box-sizing: border-box;
  border: 0.1458em solid var(--ink);
  opacity: 0;
  filter: blur(0.3em);
  pointer-events: none;
  transition:
    left 240ms cubic-bezier(0.33, 1, 0.68, 1),
    border-radius 240ms cubic-bezier(0.33, 1, 0.68, 1),
    opacity 220ms ease-out,
    filter 220ms ease-out;
}
.indicator[data-on] {
  opacity: 1;
  filter: blur(0);
}
/* After a wrong code it comes back while the last digits are still leaving. */
.indicator[data-late] {
  transition-delay: 0s, 0s, 170ms, 170ms;
  transition-duration: 0s, 0s, 260ms, 260ms;
}
/* Hidden while the outline moves, then blinking. */
.caret {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0.125em;
  height: 1.1667em;
  margin: -0.5833em 0 0 -0.0625em;
  border-radius: 1px;
  background: var(--ink);
  opacity: 0;
  animation: blink 1.1s linear 0.24s infinite;
}
@keyframes blink {
  0%,
  54% {
    opacity: 1;
  }
  55%,
  100% {
    opacity: 0;
  }
}

/* The one real input, over the row, its text and caret invisible: the cells draw them. */
.input {
  all: unset;
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  /* 16 px, so phones don't zoom in on focus. */
  font-size: 16px;
  color: transparent;
  -webkit-text-fill-color: transparent;
  caret-color: transparent;
  background: transparent;
  cursor: text;
}
.input::selection {
  background: transparent;
}

/* The status: spinner or tick, and its words. */
.status {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.status .content {
  transition: color 160ms ease-out 30ms;
}
/* The tick and the new letters come first; the green follows a beat later. */
.status[data-done] .fill {
  background: var(--green);
  transition-delay: 70ms;
}
.status[data-done] .content {
  color: var(--on-green);
  transition-delay: 100ms;
}
.statusIcon {
  position: relative;
  width: 1.1667em;
  height: 1.1667em;
}
.spinner,
.check {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition:
    opacity 140ms ease-out,
    filter 140ms ease-out;
}
.spinner {
  stroke-width: 1.3;
  stroke-dasharray: 26.5 5;
  animation: spin 0.8s linear infinite;
}
.check {
  stroke-width: 1.6;
  opacity: 0;
  filter: blur(0.2em);
}
.status[data-done] .spinner {
  opacity: 0;
  filter: blur(0.2em);
  animation: none;
}
.status[data-done] .check {
  opacity: 1;
  filter: blur(0);
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}

/* Words as letters: the ones that change blur across, the old ones out over the new. */
.words,
.suffix,
.ghost {
  display: inline-flex;
}
/* Letters in their own boxes lose their kerning: a touch tighter makes up for it. */
.words {
  letter-spacing: -0.02em;
}
.suffix {
  position: relative;
}
.ghost {
  position: absolute;
  top: 0;
  left: 0;
}
.letter {
  display: inline-block;
  white-space: pre;
}
.letter[data-in] {
  animation: letter-in 220ms cubic-bezier(0.2, 0.7, 0.3, 1) var(--d) both;
}
.ghost .letter {
  animation: letter-out 160ms ease-in var(--d) forwards;
}
@keyframes letter-in {
  from {
    opacity: 0;
    filter: blur(0.3em);
  }
  to {
    filter: blur(0);
  }
}
@keyframes letter-out {
  from {
    filter: blur(0);
  }
  to {
    opacity: 0;
    filter: blur(0.3em);
  }
}

/* The glint that crosses the green pill, once. */
.shine {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    105deg,
    transparent 28%,
    var(--glint) 38%,
    transparent 46%,
    transparent 50%,
    var(--glint) 55%,
    transparent 60%
  );
  opacity: 0;
  transform: translateX(-100%);
}
.status[data-done] .shine {
  animation: shine 300ms cubic-bezier(0.4, 0, 0.4, 1) 470ms both;
}
@keyframes shine {
  from {
    opacity: 1;
    transform: translateX(-100%);
  }
  to {
    opacity: 1;
    transform: translateX(100%);
  }
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Reduced motion: no blur, no glide, no glint; the caret holds still. */
@media (prefers-reduced-motion: reduce) {
  .digit,
  .digit[data-out],
  .letter[data-in],
  .status[data-done] .shine {
    animation: none;
  }
  .ghost {
    display: none;
  }
  .caret {
    opacity: 1;
    animation: none;
  }
  .spinner {
    animation-duration: 2.4s;
  }
  .indicator,
  .fill,
  .cell,
  .spinner,
  .check,
  .status .content {
    transition: none;
  }
}
```

### code-input/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```