OTP input
When to use
Section titled “When to use”- A verification code sent by text message or email
- A code from an authenticator app during two-factor sign-in
- A short PIN or a backup code of fixed length
Not for free text or a number of any length — use an Input.
Choosing between entering text:
| Component | Use it for | For example |
|---|---|---|
| Input | One line of text: a name, an email, a search | Email address, search box |
| OTP input | A code of fixed length, typed or pasted one character per box | SMS code, two-factor code, PIN |
| Textarea | Several lines of free text that may wrap | Comment, description, message |
| Combobox | Typing to find and pick from a list of known options | Choose a city, add existing tags |
| Field | Not a control: wraps any of these to tie a label, a hint and an error to it | Any form control with validation |
import { OtpInput } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)' };
export default function OtpInputBasics() { return ( <> {/* The <label> names the one <input> underneath the boxes. */} <div style={field}> <label htmlFor="otp-sms">Code from the text message</label> <OtpInput id="otp-sms" /> </div>
<div style={field}> <label htmlFor="otp-backup">Backup code (letters and digits)</label> <OtpInput id="otp-backup" length={8} validationType="alphanumeric" /> </div>
<div style={field}> <label htmlFor="otp-pin">Four-digit PIN</label> <OtpInput id="otp-pin" length={4} defaultValue="2468" disabled /> </div> </> );}OtpInput is one real <input> with the boxes drawn over it. The phone’s code suggestion
(autocomplete="one-time-code"), paste, the numeric keypad and password managers all reach that
one input, so none of them needs special handling.
Verify a code
Section titled “Verify a code”import { useState } from 'react';import { Button, Field, OtpInput } from '@arun-dev/ui';
const LENGTH = 6;// Complete means every box holds a digit. A box emptied in place is a space, so the length// alone is not enough: "123 56" has six characters. Only the Verify button needs this;// onComplete already knows.const COMPLETE = /^\d{6}$/;
export default function OtpInputVerify() { const [code, setCode] = useState(''); const [error, setError] = useState(''); const [verified, setVerified] = useState(false);
function verify(value: string) { // Your server checks the code. Here, 123456 is the right one. if (value === '123456') { setVerified(true); setError(''); } else { setError(COMPLETE.test(value) ? 'That code is not right.' : `Enter all ${LENGTH} digits.`); } }
return ( <form noValidate style={{ display: 'grid', gap: 'var(--space-sm)', justifyItems: 'start' }} onSubmit={(event) => { event.preventDefault(); verify(code); }} > <Field.Root invalid={error !== ''}> <Field.Label>Verification code</Field.Label> <Field.Control render={ <OtpInput length={LENGTH} name="code" value={code} onValueChange={(next) => { setCode(next); setError(''); setVerified(false); }} // Checks as soon as every box is filled, so there is nothing more to press. onComplete={verify} /> } /> <Field.Description> We sent it to •••• 0142. Try 123456, or paste “123 456”. </Field.Description> <Field.Error>{error}</Field.Error> </Field.Root> <Button type="submit" variant="primary"> Verify </Button> <p role="status" style={{ margin: 0, minBlockSize: '1.5em' }}> {verified ? 'Verified.' : ''} </p> </form> );}Checking the code is your server’s job; the component only collects it. onComplete fires
with the code whenever a change leaves every box filled — the last digit typed, a whole code
pasted, or an emptied box filled again — so pass your check to it to verify with nothing more to
press. To test a value yourself, use a pattern such as /^\d{6}$/, not value.length === 6: a
box emptied in the middle is a space in the value, so "123 56" has six characters too.
Typing and pasting
Section titled “Typing and pasting”| Action | Result |
|---|---|
| Type a character | Fills the active box and moves to the next. A full code replaces the last box. |
| Type something else | Ignored: letters in a numeric code, symbols in an alphanumeric one. |
| Paste a whole code | Replaces the code, with spaces and dashes dropped: “123 456” and “123-456” both work. |
| Paste part of a code | Fills boxes from the caret on, over what they held. |
| Left / Right | Move one box. |
| Delete | Empties the active box. The boxes after it keep their digits, and the caret stays. |
| Backspace | Empties the active box too. On an empty box, moves back and empties the one before. |
| Press a box | Moves the caret there, or to the first empty box if the code has not reached it yet. |
Deleting never shifts the other digits: delete the 4 in 123456 and the boxes read
1 2 3 _ 5 6, with the caret still on the empty box, so typing 8 gives 123856. Until it is
filled the code is incomplete: the value reports the empty box as a space, "123 56", which the
input’s pattern rejects. An empty box at the end is simply a shorter code, so deleting the
last digit of 123456 gives "12345".
Focus puts the caret on the first empty box, not over the whole code, so the next key never wipes what is there.
Where props go
Section titled “Where props go”Like Input, className goes on the row of boxes and every other prop goes
on the <input>: id, name, required, autoFocus, aria-* and ref. So a
<label htmlFor> names the code, and a Field works through
<Field.Control render={<OtpInput />} />.
- Native forms: give it a
nameand the code submits with the form. It carriesmaxLengthand apatternof exactlylengthcharacters, so withrequiredthe browser stops a short code, andform.reset()puts the first value back. - react-hook-form: use
Controller, passingfield.valuetovalueandfield.onChangetoonValueChange. The boxes draw the value, so it lives in React, whichregister()cannot drive (as with Radio group). Passfield.reftoref, so focus-on-error lands on the input.
<Controller control={control} name="code" rules={{ pattern: { value: /^\d{6}$/, message: 'Enter all 6 digits' } }} render={({ field, fieldState }) => ( <OtpInput aria-label="Verification code" aria-invalid={fieldState.error ? true : undefined} ref={field.ref} value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} /> )}/>Accessibility
Section titled “Accessibility”- Screen readers meet one text field with the whole code, not six. The boxes are hidden from them.
- Give it a name: a
<label htmlFor>, a Field, oraria-label. - Set
aria-invalidto colour every box as an error. A Field does it for you.
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | How many characters the code has, and so how many boxes. |
value | string | Controlled code. Provide onValueChange alongside it. | |
defaultValue | string | '' | Initial code when uncontrolled. Also what form.reset() returns to. |
onValueChange | (value: string) => void | Called with the code after every change, partial or complete. A box emptied in the middle is a space, "123 56", so check completeness with a pattern, not the length. | |
onComplete | (value: string) => void | Called with the code after a change that leaves every box filled, with no gap. Verify here. | |
validationType | 'numeric' | 'alphanumeric' | 'numeric' | Which characters are kept. numeric also brings up the numeric keypad on phones. |
disabled | boolean | false | Disables the input and dims every box. |
className | string | Goes on the row of boxes, not on the <input>. Every other prop, ref included, goes on the <input>. |
Every other prop goes to the <input>.