OTP Field
A one-time-code input — a row of single-character cells with paste, arrow-key
navigation, and auto-advance. Built on Base UI OTPField, it defaults to
numeric input and autocomplete="one-time-code" so a texted code can be filled
in one tap.
Import
import { OtpField } from "@photon-ai/pho-ui/components/otp-field";
Basic
Set length for the number of cells. Give the field an aria-label (or an
external <label>) — a bare row of boxes is otherwise unlabeled.
<OtpField length={6} aria-label="Verification code" />
Masked
mask hides the entered characters, like a password field — reach for it when
the code is sensitive and shouldn’t sit visible on screen.
<OtpField length={6} mask aria-label="Verification code" />
Controlled
Pass value with onValueChange to control the code. onValueChange fires on
every edit — typing, delete, and paste — so you can read the code as it fills.
For a “code is complete” moment, use onValueComplete, or set autoSubmit to
submit the owning form the instant every cell is filled.
Type a code
const [code, setCode] = useState("");
<OtpField
length={6}
value={code}
onValueChange={setCode}
aria-label="Verification code"
/>;
Props
- length —
number(required) — number of single-character cells - value / defaultValue —
string— the controlled / uncontrolled code - onValueChange —
(value, eventDetails) => void— fires on every value change; the reason (input-change,input-clear,input-paste,keyboard) is oneventDetails - onValueComplete —
(value, eventDetails) => void— fires when every cell is filled - onValueInvalid —
(value, eventDetails) => void— fires when entered text is rejected byvalidationTypeornormalizeValue - validationType —
"numeric"·"alpha"·"alphanumeric"·"none"(default"numeric") — which characters the cells accept - mask —
boolean(defaultfalse) — hide entered characters - autoSubmit —
boolean(defaultfalse) — submit the owning form when the code completes - required —
boolean(defaultfalse) — require a value before form submit - disabled —
boolean(defaultfalse) — ignore user interaction - readOnly —
boolean(defaultfalse) — prevent changes but keep the value focusable - name —
string— identifies the field on form submit - autoComplete —
string(default"one-time-code") — applied to the first cell and the hidden input - inputMode — the virtual-keyboard hint for the cells
- className —
string— class applied to the cell row
Accessibility
- Labelling — the field has no built-in label; pass
aria-labelor associate an external<label>. Each cell is a native text input, and the row shares theone-time-codeautocomplete so OS code-autofill works. - State —
disabledandreadOnlyare reflected on the cells (dimmed while disabled), and the root exposesdata-complete/data-filled/data-focusedfor styling. Wrapped in aField, cells pick updata-valid/data-invalid.
Keyboard:
- Type — enter a character and focus auto-advances to the next cell.
- Backspace / Delete — clear the current cell (Backspace steps back once empty).
- Left / Right arrows — move between cells (mirrored under RTL).
- Home / End — jump to the first / last cell.
- Paste — a full code pasted into any cell fills the cells across the row.
Focus shows a 2px brand ring on the active cell (:focus-visible).
Best practices
- Match
lengthto the real code length so paste fills exactly and completion fires at the right moment. - Provide an
aria-label(or an external<label>); a bare row of boxes is unlabeled. - Keep
validationTypematched to the code — leave itnumericfor digit codes so the cells reject stray letters and phones show a number pad. - Reach for
autoSubmit(oronValueComplete) instead of a separate submit button when the code length is fixed and known. - Don’t split the code into a text input plus formatting — the OTP field already handles paste of the full code across cells.