Accessibility

Why one real input beats six fake ones, how to label it, and what a screen reader actually announces.

Accessibility is the reason this library is built the way it is. Everything on this page follows from having one real input instead of six fake ones — which means most of it is about what you don't have to do.

What a screen reader hears#

With six inputs, a screen reader encounters six separate unlabelled text fields. Focus jumps between them programmatically after every keystroke, so the reader re-announces a new control mid-word, the user has no idea how many characters are left, and reviewing what they typed means tabbing through the set and listening to each box in isolation.

With one input there is one control, one accessible name, one value and one caret. and read out the characters. Select-all reads the whole code. Nothing is announced that the user didn't cause.

Labelling#

The one thing you must do. An unlabelled field is announced as "edit text" with no indication of what it wants.

Enter the 6-character code we sent to your phone.

// Best: a visible label, associated by id.
<label htmlFor="code">Verification code</label>
<OTPInput id="code" name="code" maxLength={6} />
 
// When the design has no room for one:
<OTPInput aria-label="Verification code" maxLength={6} />
 
// When a heading already says it:
<h2 id="mfa-title">Enter your verification code</h2>
<OTPInput aria-labelledby="mfa-title" maxLength={6} />

Because id is forwarded to the real input, a plain <label htmlFor> works — and clicking the label focuses the field, with the caret placed correctly.

Instructions and errors#

Say how long the code is and where it came from. Point aria-describedby at both the hint and the error; the ids are read in the order you list them, and a missing id is skipped rather than breaking.

<OTPInput
  id="code"
  maxLength={6}
  aria-describedby="code-hint code-error"
  aria-invalid={error !== null}
/>
 
<p id="code-hint">Enter the 6-digit code sent to •••• 4417.</p>
{error && <p id="code-error" role="alert">{error}</p>}

Don't re-label the slots#

The most common accessibility mistake made with this library is adding ARIA to the decoration. The slots are presentational; the input is the control. Giving a slot a role, a label or a tabIndex creates phantom controls that trap keyboard users in a field that isn't real.

{/* Don't do this. The slots are decoration; the input is the control. */}
<div role="textbox" aria-label={`Digit ${idx + 1}`} tabIndex={0}>
  {slot.char}
</div>

Separators are the same story — visual only, and aria-hidden so they aren't announced as part of a value that doesn't contain them:

{/* The value has no dash in it, so the dash must not be announced. */}
<div aria-hidden className="flex w-10 justify-center">
  <div className="h-1 w-3 rounded-full bg-border" />
</div>

Keyboard#

None of this is implemented by the library. It is what a text input does, and it keeps working because the input was never taken apart:

← →
Move between slots. The selection is widened to one character, so the slot you land on is the slot you edit.
⇧ ← ⇧ →
Extend a selection across several slots. All of them report isActive.
⌘A Ctrl A
Select the whole code. Typing replaces it.
⌫ Delete
Delete backwards or forwards, exactly as in a text field.
⌥⌫ Ctrl ⌫
Delete the whole code — it is one “word”.
⌘C ⌘X ⌘V
Copy, cut and paste, including a partial paste into the middle of a half-filled code.
⌘Z
Undo. Native input history, not a reimplementation.
Tab
Leave the field. There is exactly one tab stop, not maxLength of them.

Focus visibility#

The input's own outline is removed — a ring around an invisible box floating over your slots would look like a bug. So the visible focus state is your responsibility, and it must exist.

  • Style the active slot from isActive, and make it obvious: a 2px outline, not a subtle tint.
  • Consider a container-level ring as well, via has-[:focus-visible] — it tells the user the field is focused even when the active slot is off to one side.
  • Don't rely on the blinking caret alone. It is invisible to anyone using prefers-reduced-motion, since the animation should be behind motion-safe:.

Announcing completion#

When a code is autofilled from an SMS, every slot fills at once with no keystrokes — which a screen reader has no reason to mention. If your flow doesn't immediately submit, a small live region closes that gap:

{/* Optional: confirm arrival of an autofilled code. */}
<p role="status" className="sr-only">
  {value.length === maxLength ? 'Code complete' : ''}
</p>

Keep it role="status" (polite). An assertive region will interrupt whatever the user is listening to.

Right-to-left#

Codes are read left-to-right in every locale, so the slot row should keep its direction while the page around it flips. Set dir="ltr" on the field and let everything else inherit RTL:

أدخل الرمز المكون من ٦ أرقام

Zoom and reflow#

Six slots at a comfortable size will overflow a 320px viewport at 200% zoom. Because your slots are just elements, the fix is ordinary CSS — shrink the slots at small widths, or drop the separator. Avoid wrapping the row onto two lines: the selection is a single continuous range, and a wrapped row makes multi-slot selections read as two disconnected fragments.

A short audit#

Before shipping a field, check these. They are the ones that actually get missed:

  • The field has an accessible name — read it out loud from the label.
  • The hint states the code's length.
  • Focus is visible without motion, and visible on the container as well as the slot.
  • Errors are announced, associated with the field, and cleared when the user edits.
  • Nothing in the slot markup has a role, a label or a tab stop.
  • Separators are aria-hidden.
  • Tabbing through the form hits the field exactly once.

Mobile & platforms covers the rest of the real-world behaviour: SMS autofill, iOS paste, and what happens when JavaScript never arrives.