Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Code input

New

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

code-input/code-input.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

Fig. 1, taken apart

The same component in 3D, its layers lifted apart. Hover or focus it to take it apart, pick a layer to read its dimensions, and drag to turn it.

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.

You can add this component with the shadcn CLI or by hand:

Into components/code-input of any React project, with no setup:

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

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

sign-in-step.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')}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
lengthnumber6How 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.
minVerifyingnumber600Verifying 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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

code-input.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
code-input.module.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;
use-autoplay.ts
// 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(
  • 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.