A one-time code field has one job: get six digits from a message into a form. Many implementations make that harder than it needs to be. They reject a pasted code because it has a space in it, trap the cursor in the wrong box, or read six separate fields to a screen reader. Nexera's OTPInput is built to accept the code however it arrives. This guide shows how to use it and why it behaves the way it does.
The basic field
import { OTPInput } from "@nexera-ui/react";
<OTPInput
label="Verification code"
length={6}
description="Sent to +92 300 ••• 4567"
resend="Resend in 0:42"
onComplete={verify}
/>;length is 4 or 6, matching the Figma component. With six cells, a separator sits between the third and the fourth. size is lg (48 px cells) by default or md (40 px). A visible label, aria-label or aria-labelledby is required, and TypeScript reports an error without one.
One input under the cells
React Aria has no OTP primitive, so OTPInput is a React Aria TextField with a single real <input> lying under the cells. The cells are drawn with aria-hidden="true".
This choice drives most of the behaviour. A screen reader meets one labelled text field with one value. The field is one tab stop. A visually hidden progress text, linked with aria-describedby, says "3 of 6 digits entered", and you can translate it with the progressLabel prop. Browser and phone features that work on a normal input, like autofill and paste, work here too.
Accepting the code however it arrives
WCAG 2.2 added 3.3.8 Accessible Authentication, which asks that people can paste or autofill a code instead of transcribing it. OTPInput sets autoComplete="one-time-code" by default, so phones can offer the code from an SMS and password managers can fill it, and inputMode="numeric" to show the number pad.
Paste needed more care. Codes often arrive formatted as 123 456 or 123-456. A plain input with maxLength={6} cuts the pasted text to six characters before any cleanup runs, so 123-456 becomes 123-45 and then 12345. OTPInput handles the paste event itself: it keeps the digits, and if there are at least length of them it fills every cell, wherever the caret was. A shorter paste is inserted at the caret. Text without digits changes nothing.
Digits do not always arrive as ASCII either. Someone typing on an Arabic or Persian keyboard produces ٣ or ۳ instead of 3. The component recognises Arabic-Indic, Extended Arabic-Indic, Devanagari, Bengali and full-width digits and stores them as ASCII, so value and onComplete always see "123456". Letters are ignored and the caret stays where it was.
Moving between cells
Typing fills the next cell and Backspace clears the previous one. Arrow Left and Right move one cell, and Home, End, Arrow Up and Arrow Down jump to the ends. Typing on a filled cell replaces that digit and selects the next filled one, so you can correct a digit in the middle without retyping the rest. A click on a cell moves the caret to the nearest cell by distance.
In right-to-left layouts, the cells stay left-to-right, because codes are read that way in Arabic too. Arrow Left still moves to the previous digit.
Verifying in place
onComplete fires once when the code reaches length digits, whether typed, pasted or autofilled. It does not submit a form. Moving to another page on input alone would be a change of context under WCAG 3.2.2, so the component leaves the next step to you: verify in place and report the result with status.
"use client";
import { useState } from "react";
import { OTPInput } from "@nexera-ui/react";
export function VerifyCode({ verify }: { verify: (code: string) => Promise<boolean> }) {
const [code, setCode] = useState("");
const [status, setStatus] = useState<"verifying" | "success">();
const [error, setError] = useState<string | null>(null);
return (
<OTPInput
label="Verification code"
length={6}
value={code}
onChange={(next) => {
setCode(next);
setError(null);
}}
status={status}
isInvalid={error !== null}
errorMessage={error}
onComplete={async (next) => {
setStatus("verifying");
const ok = await verify(next);
setStatus(ok ? "success" : undefined);
if (!ok) setError("Incorrect code. Check the message and try again.");
}}
/>
);
}While status is set, the field is read-only, the resend text is hidden and a status line replaces the helper: "Verifying" with a spinner, then "Verified" with a check. When the field is invalid, the error replaces the helper with an icon and text. All three messages share one polite live region, so a screen reader announces each change once. Translate them with statusMessage and errorMessage.
Resend and lockouts
resend is plain text by default, which suits a countdown such as "Resend in 0:42". A countdown is not announced on every tick. Pass onResend and the same text becomes a button. When too many attempts lock the field, set isDisabled and explain why in description, for example "Too many attempts. Try again in 10:00". The disabled field leaves the tab order, and the reason stays visible.
What is left to you
The component handles input. The rest is product work: the wording of the label, helper and errors, the verification call, the countdown and when to re-enable resend, and the explanation of a locked field. The tests cover the parts the component owns, from pasting 123-456 with the caret in the middle to typing Arabic digits and running axe in light and dark for every size, length and state. Try them on the OTP input page.