# OtpInput

**When to use:** A one-time-passcode / verification-code input, rendered as one box per character. Typing a character auto-advances to the next box, Backspace on an empty box steps back to the previous one, arrow keys move freely between boxes, and pasting a full code (or an SMS autofill suggestion) fills every box at once instead of just the focused one.

**Keywords:** otp, 2fa, mfa, verification code, sms code, passcode, pin code, two-factor

**Import:** `import { OtpInput, OtpInputCharset } from '@ahrowe/ui'`

**Charset:** `OtpInputCharset.Numeric` (default) | `OtpInputCharset.Alphanumeric`

```tsx
import { useState } from 'react';
import { OtpInput, OtpInputCharset } from '@ahrowe/ui';

// Basic — 6 numeric boxes, controlled
const [code, setCode] = useState('');
<OtpInput value={code} onChange={setCode} />

// React once the code is fully entered — e.g. auto-submit for verification
<OtpInput
  onComplete={(code) => verifyCode(code)}
/>

// Labeled and required
<OtpInput label="Verification code" isRequired autoFocus />

// Fewer/more boxes, and a non-numeric charset (e.g. a backup/recovery code)
<OtpInput length={4} charset={OtpInputCharset.Alphanumeric} value={code} onChange={setCode} />

// Masked, like a password field
<OtpInput masked value={code} onChange={setCode} />

// Visually split into groups, e.g. 3 + 3
<OtpInput groupAfter={[3]} value={code} onChange={setCode} />

// Manual error state (e.g. after a failed verification call). The message alone puts the
// boxes in error, so it is passed only while there is one
<OtpInput
  value={code}
  onChange={setCode}
  errorMessage={wasRejected ? "That code didn't match. Try again." : undefined}
/>

// Auto-clear on error: once the error appears, the wrong code and the message stay visible
// briefly, then every box clears and the first takes the focus for a clean retry
<OtpInput
  value={code}
  onChange={(next) => {
    setCode(next);
    // Clearing calls onChange(''): keep the error through that, drop it once the user types,
    // so the next wrong code is a new error and clears again
    if (next) setWasRejected(false);
  }}
  onComplete={(entered) => setWasRejected(entered !== expected)}
  errorMessage={wasRejected ? "That code didn't match. Try again." : undefined}
  clearOnError
/>

// With FormValidator
import { FormValidator, Validators } from '@ahrowe/ui';
const codeValidator = new FormValidator('', [Validators.required(), Validators.minLength(6)]);
<OtpInput formValidator={codeValidator} />
```

**Interaction:** typing a character fills the focused box and jumps to the next one; on the last box, it blurs instead. Backspace on a filled box just clears it; on an already-empty box it clears and focuses the previous one. Left/Right arrow keys move focus without changing values. Pasting (or an SMS autofill suggestion landing in one box) distributes the pasted text across boxes starting from wherever it lands. Focusing a box selects its content, so typing immediately overwrites it rather than requiring a manual clear first.

**Error display:** like `Input`/`RadioGroup`, the boxes get a red border once errored, and the message itself appears in an error `Tooltip` on focus or hover rather than sitting inline under the boxes all the time. With a `formValidator`, this shows once the validator's `shouldShowError` is true: by default once the field has been *touched* (focus has left the group) **and** has an error, so a required-field error doesn't flash before the user has even had a chance to type (`showErrors`, see FormValidator.md). With manual `isValid`/`errorMessage`, there's no touched-gating: the consumer already controls when to pass the message (or `isValid={false}`), e.g. only after a failed verification call. Either way, the error persists across renders until whatever's driving it — `isValid`, or the `formValidator`'s own error state — actually changes; `OtpInput` never silently clears it on its own. See `clearOnError` below for the one built-in exception.

**`onComplete`:** fires once per genuinely new completion — i.e. the code going from fewer than `length` characters to exactly `length`. Editing individual boxes of an already-complete code (retyping a digit that was wrong) does **not** refire it on every keystroke, since the code never drops below `length` in between; it fires again only after the code is cleared (manually, or via `clearOnError` below) and refilled.

**Handling a failed verification (`clearOnError`):** when the code the user entered turns out to be wrong, don't leave it sitting there for them to correct digit-by-digit — set `clearOnError` so that once `isValid`/`formValidator` newly reports invalid, the boxes shake briefly (respecting `prefers-reduced-motion`) and, after `clearErrorDelay` (default `900`ms — long enough to read the error message first), every box clears and focus returns to the first one, ready for a clean retry. This is the standard pattern used by most OTP UIs (a wrong SMS/authenticator code almost always means the user misread the whole code, not mistyped one digit). Off by default, since the consumer's own error handling (e.g. a form-level retry flow) may prefer to leave the value in place instead.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `length` | `number` | Number of boxes (default `6`) |
| `value` | `string` | Controlled code, e.g. `"123456"` |
| `onChange` | `(value: string) => void` | Fires on every change with the current (possibly partial) code |
| `onComplete` | `(value: string) => void` | Fires once the code reaches `length` characters |
| `formValidator` | `FormValidator` | Connects to form validation |
| `charset` | `OtpInputCharset` | Restricts allowed characters and picks the mobile keyboard (default `Numeric`) |
| `masked` | `boolean` | Renders each box's value as a dot, like a password field |
| `groupAfter` | `number[]` | 1-based box indices after which a small visual gap is drawn, e.g. `[3]` for 3 + 3 |
| `label` | `string` | Label above the boxes |
| `isRequired` | `boolean` | Shows a required mark (`*`) next to the label |
| `isValid` | `boolean` | Manual valid state (default `true`) |
| `errorMessage` | `string` | Manual error message, shown in an error `Tooltip` while a box has the focus or the pointer (falls back to `formValidator`'s current error). With `clearOnError` the first box takes the focus back, so the message shows then too, on a touch screen as well. A non-empty message puts the field in error by itself, so pass it only while there is one |
| `clearOnError` | `boolean` | Once an error newly appears (from `isValid`, `errorMessage` or the validator), shake the boxes and, after `clearErrorDelay`, clear them and refocus the first one (default `false`). Clearing calls `onChange('')`, so a controlled `value` follows |
| `clearErrorDelay` | `number` | Delay in ms before `clearOnError` clears the boxes (default `900`) |
| `disabled` | `boolean` | |
| `autoFocus` | `boolean` | Focuses the first empty box (or the first box) on mount |
| `ref` | `Ref<HTMLDivElement>` | Ref to the root element |

**Slots:** `root` `label` `inputs` `input` `separator`

**Labels:** `field` `digit(position, length)` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `OtpInputLabels`.

## Sizing

The code boxes, the digit inside them, the gaps and the separator all scale with the theme's text, so the field stays in proportion with its label. In a container narrower than the boxes' full width, they shrink to fit, down to 70% of it; below that they run out of it. The field sits at the start of a wider container, like text, so centre it from outside (`display: flex; justify-content: center` on the container).

Without a `label`, the group is named "One-time code" (`labels.field`) for screen readers.
