Skip to content

OTP input

  • 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:

ComponentUse it forFor example
InputOne line of text: a name, an email, a searchEmail address, search box
OTP inputA code of fixed length, typed or pasted one character per boxSMS code, two-factor code, PIN
TextareaSeveral lines of free text that may wrapComment, description, message
ComboboxTyping to find and pick from a list of known optionsChoose a city, add existing tags
FieldNot a control: wraps any of these to tie a label, a hint and an error to itAny 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.

We sent it to •••• 0142. Try 123456, or paste “123 456”.

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 &ldquo;123 456&rdquo;.
</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.

ActionResult
Type a characterFills the active box and moves to the next. A full code replaces the last box.
Type something elseIgnored: letters in a numeric code, symbols in an alphanumeric one.
Paste a whole codeReplaces the code, with spaces and dashes dropped: “123 456” and “123-456” both work.
Paste part of a codeFills boxes from the caret on, over what they held.
Left / RightMove one box.
DeleteEmpties the active box. The boxes after it keep their digits, and the caret stays.
BackspaceEmpties the active box too. On an empty box, moves back and empties the one before.
Press a boxMoves 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.

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 name and the code submits with the form. It carries maxLength and a pattern of exactly length characters, so with required the browser stops a short code, and form.reset() puts the first value back.
  • react-hook-form: use Controller, passing field.value to value and field.onChange to onValueChange. The boxes draw the value, so it lives in React, which register() cannot drive (as with Radio group). Pass field.ref to ref, 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}
/>
)}
/>
  • 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, or aria-label.
  • Set aria-invalid to colour every box as an error. A Field does it for you.
OtpInput props
PropTypeDefaultDescription
lengthnumber6How many characters the code has, and so how many boxes.
valuestringControlled code. Provide onValueChange alongside it.
defaultValuestring''Initial code when uncontrolled. Also what form.reset() returns to.
onValueChange(value: string) => voidCalled 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) => voidCalled 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.
disabledbooleanfalseDisables the input and dims every box.
classNamestringGoes 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>.