One-time code
StableA one-time code field: one real input painted as six slots, so paste, autofill and screen readers just work.
Split boxes break paste and give screen readers six fields; this is one input with one name, one value and one tab stop, drawn as slots. Spaces, dashes and words are dropped from whatever is typed or pasted, a pasted whole code replaces any digits already typed, onComplete fires when the last digit lands, and focusing a complete code selects it so typing starts over.
Verify an email
import { useRef, useState } from 'react'import { OtpInput, messages } from '@rchursin/product/forms'import { Icon } from '@rchursin/product/icons/Icon'import { Field } from '@rchursin/product/ui'
/** The sample's right code. Any other code shows the error. */const SAMPLE_CODE = '424242'
/** * One input painted as six slots. Paste “424 242” or “Your code is 424-242”: only the digits * stay. The sixth digit checks the code at once; a wrong code is selected, so typing starts over. */export default function OtpVerify() { const input = useRef<HTMLInputElement>(null) const [code, setCode] = useState('') const [state, setState] = useState<'idle' | 'checking' | 'verified' | 'wrong'>('idle')
function check(value: string) { setState('checking') window.setTimeout(() => {Usage
import { OtpInput, digitsOnly, type OtpInputProps } from '@rchursin/product/forms'<Field label="Verification code" hint="You can paste the whole code."> {(p) => <OtpInput {...p} value={code} onChange={setCode} onComplete={verify} />}</Field>Examples
Four digits
length={4} in the medium size. The slots, the pattern and the paste clean-up all follow the length.
import { useState } from 'react'import { OtpInput } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** length={4} in the medium size. The pattern, the paste clean-up and onComplete follow the length. */export default function OtpFourDigits() { const [code, setCode] = useState('')
return ( <div className="w-full max-w-[15rem]"> <Field label="Text message code" hint="4 digits, sent to the number ending in 21."> {(p) => <OtpInput {...p} length={4} size="md" name="sms-code" value={code} onChange={setCode} />} </Field> </div> )}digitsOnly
The clean-up the field runs on every change, on its own: keep the digits, stop at the length.
import { useState } from 'react'import { digitsOnly } from '@rchursin/product/forms'import { Field, TextInput } from '@rchursin/product/ui'
/** The clean-up the code field runs on every change, on its own: keep the digits, stop at the length. */export default function OtpDigitsOnly() { const [text, setText] = useState('Your code is 424-242')
return ( <div className="flex w-full max-w-sm flex-col gap-4"> <Field label="Pasted text" hint="Type or paste anything."> {(p) => <TextInput {...p} value={text} onChange={(e) => setText(e.target.value)} />} </Field> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-[13px]"> <dt className="font-mono text-xs text-fg-3">digitsOnly(text)</dt> <dd className="font-mono text-fg tabular-nums">{digitsOnly(text) || '—'}</dd> <dt className="font-mono text-xs text-fg-3">digitsOnly(text, 4)</dt> <dd className="font-mono text-fg tabular-nums">{digitsOnly(text, 4) || '—'}</dd> </dl> </div> )}API
One-time code props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | – | The digits so far. |
onChangerequired | (value: string) => void | – | Called with the cleaned value: digits only, cut to length. |
onComplete | (value: string) => void | – | Called when the last digit is typed or pasted. Check the code here; there is no need for a button press. |
length | number | 6 | How many digits. Sets the slots, the pattern attribute and where the clean-up stops. |
name | string | 'code' | The form field name. |
size | 'md' | 'lg' | 'lg' | Slots 40 or 48 pixels tall. |
busy | boolean | – | While the code is being checked: the slots dim and the input stays focusable. |
disabled | boolean | – | Dims the slots and blocks input. |
autoFocus | boolean | – | Focus on mount, for a screen whose only job is the code. |
id | string | – | id of the input. Set by Field’s render prop ({...p}), along with aria-describedby and aria-invalid. |
aria-invalid | boolean | – | Draws a firmer ring around every slot. |
className | string | – | Classes for the wrapper (at most 22rem wide by default). |
digitsOnly(value, max)
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | – | Any text: “424 242”, “424-242”, “Your code is 424242”. |
max | number | 6 | Keep at most this many digits. Returns a string of digits. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| 0–9 | Fills the next slot. Anything that is not a digit is dropped. |
| CtrlV | Pastes a code; spaces, dashes and words are dropped. A whole code replaces any digits already in the field. |
| Backspace | Clears the last digit. |
| CtrlA | Selects the whole code, so the next digit starts over. |
| Left arrowRight arrow | The caret stays at the end: slots fill in order. |
- One <input> with type="text", inputmode="numeric" and autocomplete="one-time-code": phones show the number pad and offer the code from a text message. It is never type="number", which drops leading zeros.
- The painted slots are aria-hidden; screen readers meet one field with one label and hear the code as one value.
- Focus shows on the slot that takes the next digit, with the same ring as every other field. An error draws a firmer ring around every slot, and Field adds the error text.
- Paste always works (WCAG 2.2 success criterion 3.3.8).
Guidelines
Do
- Say where the code was sent and how long it lasts.
- Check the code in onComplete, and keep a submit button for people who expect one.
- After a wrong code, select it so typing replaces it.
Don’t
- Don’t split the code into separate inputs.
- Don’t clear the code on an error; people may want to check what they typed.
In use
Related
- Sign-in flowThe whole front door in one component: email first, password or passkey, GitHub or Google, sign-up with a code, and recovery.
- Form toolkituseForm for values, errors and validation timing, plus the checks and GOV.UK-style messages every form in the kit shares.
- FieldLabel above, hint under it, error in words between the hint and the control, with the ids and aria wiring done for you.
Source
The component’s own files, exactly as they run on this site.
/** * One-time code field: ONE real input, painted as six slots (the input-otp approach). * * Why one input: split boxes break paste (WCAG 3.3.8's own failure example), make * autocomplete="one-time-code" meaningless, and give screen readers six fields instead of one. * Here there is one control, one name, one value, one caret, one tab stop. * * <Field label="Code" hint="6 digits"> * {(p) => <OtpInput {...p} value={code} onChange={setCode} onComplete={verify} />} * </Field> * * - Paste "424 242", "424-242" or "Your code is 424242": spaces, dashes and words are dropped, * and a pasted whole code replaces any digits already typed. * - onComplete fires when the sixth digit lands (auto-submit). * - When the code is complete, focusing the field selects it, so typing starts over. * - type="text" + inputmode="numeric" (never type="number", which drops leading zeros). */import { forwardRef, useEffect, useId, useRef, useState } from "react"import { cx } from "../../components/cx"import { ringFocus, ringInvalid } from "./styles"