Step 1: one input, drawn as boxes
components/otp-field.tsx
"use client";
import { useRef, useState, type ChangeEvent } from "react";
type Props = {
length?: number;
onComplete?: (code: string) => void;
disabled?: boolean;
};
// One real input carries the whole code, so paste, iOS and Android SMS
// autofill and password managers all land in the same place. The boxes
// are only a drawing of its value.
export function OtpField({ length = 6, onComplete, disabled }: Props) {
const ref = useRef<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [focused, setFocused] = useState(false);
function onChange(e: ChangeEvent<HTMLInputElement>) {
// Pasted text can be "123 456", "123-456" or "Your code is 123456".
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setCode(next);
if (next.length === length) onComplete?.(next);
}
const active = focused ? Math.min(code.length, length - 1) : -1;
return (
<div className="relative w-fit" onClick={() => ref.current?.focus()}>
<input
ref={ref}
value={code}
onChange={onChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={disabled}
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d*"
maxLength={length}
aria-label={`Verification code, ${length} digits`}
className="absolute inset-0 h-full w-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none"
/>
<div aria-hidden="true" className="flex gap-2">
{Array.from({ length }, (_, i) => (
<span
key={i}
className={`grid size-12 place-items-center rounded-lg border text-xl font-medium tabular-nums transition-colors ${
i === active ? "border-zinc-900 ring-2 ring-zinc-900/15 dark:border-white" : "border-zinc-300 dark:border-zinc-700"
}`}
>
{code[i] ?? ""}
</span>
))}
</div>
</div>
);
}
Good to know
- One input is the whole trick. iOS puts the suggested code into whichever field has autocomplete="one-time-code", password managers fill one field, and a paste goes into one field. With a single input all three just set its value.
- The input sits on top of the boxes with opacity 0, so taps anywhere on the row focus it and the phone opens the number pad from inputMode="numeric".
- The onChange keeps digits only and cuts at the length, so "Your code is 123456" pasted from an email still works.
- The aria-label says how many digits to type. Screen readers read one field instead of six unlabeled boxes.
- onComplete fires when the last digit lands. If you submit there, also keep a normal submit button for people who type slowly and fix a digit.
- What we ran on October 27, 2026: every file on this page type-checks with TypeScript 5.9.3 against react 19.1.1 and next 15.5.28 in strict mode, and passing a string as length fails that check. spread() ran in Node: a full code pasted into box 4, one digit typed, "Code: 123 456" pasted, two digits in box 5 and a delete all gave the expected boxes and focus. We didn't test autofill on a phone for this page, and the Web OTP call is not run here.
Install
- Copy from the zipPut
react/src/components + react/src/data into src/, then none for this page. The paid piece uses framer-motion. - Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.
Props
- children
- the page that stays frosted
- onVerify
- check the code, resolve true
- length
- number of cells
- onResend
- resend button
From the blog: OTP Input in React: Paste, Autofill and one-time-code That Work
From the blog: Accessible Login Form in React: Labels, Errors, Motion