Skip to content
inputWeek 02Added

OTP Code Input

A one-time-code input with a box per character. Typing, paste, SMS autofill, Backspace and arrow keys all work; it has numeric or alphanumeric modes, masking, grouping, loading, error and success states, and submits with forms.

Live preview
Verification code

Enter the 6-digit code we sent to •••• 4821. Demo code: 246810

4-digit PIN (masked)

Characters are hidden as you type.

Recovery key (letters and numbers)

Paste a key like AB12-CD34 into any box.

Disabled

Locked while the account is under review.

  • typing
  • paste
  • SMS autofill
  • Backspace
  • arrow keys
  • loading
  • error
  • success
  • disabled
  • masked

Quality checks

  • Chromium · light passed
  • Chromium · dark passed
  • Firefox · light passed
  • Firefox · dark passed
  • WebKit · light passed
  • WebKit · dark passed

Each run loads the page at 375, 768 and 1280 px (no horizontal overflow), fails on any console error and runs an axe WCAG 2.1 AA scan. Accessibility violations across all runs: 0 · last run .

Code

import { useState } from "react";
import { OtpInput, type OtpStatus } from "@/components/ui/otp-input";

export function VerifyStep() {
  const [status, setStatus] = useState<OtpStatus>("idle");

  return (
    <form action="/api/verify">
      <OtpInput
        name="otp"
        length={6}
        groupSize={3}
        status={status}
        message={status === "error" ? "That code didn't match." : "We sent a code to your phone."}
        onComplete={async (code) => {
          setStatus("loading");
          const ok = await verifyCode(code);
          setStatus(ok ? "success" : "error");
        }}
      />
    </form>
  );
}

Props

PropTypeDefaultDescription
lengthnumber6Number of boxes.
value / defaultValuestring—Controlled value, or the starting value when uncontrolled.
onChange(value: string) => void—Called on every change.
onComplete(value: string) => void—Called once when every box is filled.
mode"numeric" | "alphanumeric""numeric"Allowed characters (letters are upper-cased) and the mobile keyboard.
maskbooleanfalseHide characters like a password.
status"idle" | "loading" | "error" | "success""idle"Colours, ARIA and locking. Error shakes once.
labelstring"Verification code"Visible label, also names the group.
messagestring—Helper or status text, announced politely.
groupSizenumber—Insert a separator every N boxes (e.g. 3 → 123–456).
disabledbooleanfalseLock every box.
namestring—Adds a hidden input so the code submits with a form.
autoFocusbooleanfalseFocus the first box on mount.

Build prompt

The final prompt used to generate this component.

Build a reusable React + TypeScript + Tailwind v4 "OtpInput" (one-time code) for Next.js App Router.

- One <input> per character. Typed props: length (6), value / defaultValue (controlled or not), onChange, onComplete (fires once when full), mode ("numeric" | "alphanumeric", upper-case letters), mask, status ("idle" | "loading" | "error" | "success"), label, message, groupSize (separator every N), disabled, name (hidden input for forms), autoFocus.
- Typing fills and advances; invalid characters are ignored. Paste fills from that box (strip spaces/dashes). SMS autofill via autocomplete="one-time-code" on box 1 (whole code may land in one box). Backspace clears or steps back; Arrow keys, Home/End move. Never allow gaps: focusing past the first empty box jumps back to it. Select on focus. Must survive very fast typing.
- A11y: role="group" labelled by the label and described by the message (polite live region); each box aria-label "Digit 3 of 6"; aria-invalid on error; correct inputMode.
- States: loading (locked + spinner), error (red + one shake, reduced-motion safe), success (green), disabled, filled highlight.
- Boxes shrink to fit 320px screens; light/dark tokens; no dependencies.
Demo: verify flow with a fake check (code shown in the hint), resend countdown, clear button, plus a masked 4-digit PIN, an 8-char alphanumeric key grouped 4-4 and a disabled example.

Build notes

Problems found while building and testing it, how they were fixed, and the decisions that shaped it.

  • Bug caught by the Playwright typing test: typing "246810" quickly produced "480". Focus moved to the next box before React re-rendered, so that box still saw the old code, thought it was past the first empty box and sent focus back. Handlers now read the latest code from a ref.
  • The first fix wrote that ref during render, which the react-hooks/refs lint rule rejects. It is now synced in a layout effect, and commit() updates it immediately.
  • The SMS-autofill test failed only in WebKit because it filled the box before React hydrated. A trace showed the component handles a whole code in one box correctly; the tests now wait for the page to settle.
  • Firefox ignores clipboardData on synthetic paste events, so the paste test runs in Chromium and WebKit and is skipped (with a reason) in Firefox.