[RFC] OtpInput — one-time-code / verification-code input
- Dominant language
- TypeScript
- Stars
- 13k
- Forks
- 1.1k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 690
Description
### Problem Statement
Users are routinely asked to enter a short, fixed-length code — a 2FA/MFA challenge from an authenticator app, an SMS/email verification code during sign-up or login, or a numeric PIN for device pairing or a sensitive action (password reset, payment confirmation). The expected UX is a row of single-character cells that auto-advance as you type, let you paste the whole code at once, and support OS/SMS autofill.
Astryx has a full Data Input family (`TextInput`, `DateInput`, `DateRangeInput`, `FileInput`, `CheckboxInput`, `Field`) but no primitive for this pattern. Builders who need it today either drop to raw `` elements — losing the label/description/status/theming wiring and the accessibility contract — or misuse `TextInput`, which gives one free-form box with no segmentation, no auto-advance, and no `one-time-code` autofill affordance. Both are worse for humans and for AI-assisted authoring.
### Evidence of Demand
This is a near-universal pattern for any product with an authentication, verification, or confirmation step.
**Ships in every major ecosystem** (naming/shape varies, which is itself a signal the primitive is expected):
- shadcn `input-otp` — `maxLength` + render-prop slots
- `react-otp-input` (devfolioco) — `numInputs`, `renderInput`, `renderSeparator`
- PrimeReact `InputOtp` — `length`, `integerOnly`, `mask`
- Ant-community `antd-input-otp`, `otp-input-react`, `vue-input-otp`
- `react-native-otp-entry` — `numberOfDigits`, `secureTextEntry`, `onFilled`
**Frequency:** verification-code entry appears on sign-up, login (2FA), password reset, payment confirmation, and device pairing — i.e. most products hit it at least once, and auth-heavy products hit it on multiple surfaces.
### Why Existing Components Don't Cover This
I attempted to compose this from current Astryx primitives:
- **`TextInput`** — a single box. It has no notion of per-cell segmentation, auto-advance between cells, backspace-to-previous, or paste-distributing-across-cells. Styling one input to *look* like six does not give the interaction model.
- **`Field` + N × raw `` inside an `HStack`** — this is the closest composition and it *does* reuse the right chrome (`Field` already supports group controls via `inputID` + `labelID` + `isGroupLabel` + `aria-labelledby`, with `statusVariant="detached"`). But the consumer still has to hand-write: an array of refs, the `onChange`/auto-advance logic, `Backspace`/`Delete`/`Arrow`/`Home`/`End` key handling, paste parsing with sanitization and truncation, per-cell positional `aria-label`s, and `autoComplete="one-time-code"` + `inputMode` placement. That is ~100 lines of fiddly, easy-to-get-subtly-wrong interaction and a11y code that every consumer would re-implement (and most would get the accessibility parts wrong).
The chrome composes; the *interaction contract and accessibility* do not. That contract is exactly what a design system should own once and get right.
### Rough Approaches Considered
Not a design proposal — just to show the design space has been walked. Consumer code only.
**Option A — prop-driven component (preferred).** State is a single assembled string; the component owns all interaction/a11y internally.
```
const [code, setCode] = useState('');
// 80% case: 6-digit numeric, auto-submit on completion
// 4-digit masked PIN
// validation round-trip (no remount)
```
**Option B — `maxLength` + render-prop (shadcn `input-otp` shape).** Maximum flexibility, but pushes the cell loop, layout, and (critically) the a11y/autofill wiring back onto every consumer:
```
{slots.map((s, i) => )}} />
```
**Option C — compound components.** `` — adds concepts for no benefit over `Field` composition and inflates the export surface.
The naming questions worth resolving with data (via API Arbitration / vibe testing): `length` vs `maxLength` (an OTP length is *exact*, not a maximum, which argues against the native-attribute connotation of `maxLength`), and the completion callback name (`onComplete` vs `onFilled`; not a boolean `autoSubmit`, since a library shouldn't assume "complete ⇒ submit"). The six enumerated use cases (simple, configured, controlled, composed-in-Dialog, paste/truncation edge, mobile autofill) don't exercise the flexibility of B or C, which suggests A is the right default — but that's for the spec/arbitration phase, not this RFC.
### Accessibility Considerations
Maps to a **labelled group of text inputs** (WAI-ARIA text-input keyboard model applied per cell, wrapped in a named `role="group"`).
- **Group naming:** the cell row is `role="group"` named via `aria-labelledby` pointing at the field label. The label renders as a non-`` element because a `` can only name a single control — so it associates by id, not `htmlFor`.
- **Per-cell context:** each cell carries a positional `aria-label` ("{label}, character {n} of {length}") so screen-reader users know where they are in the code.
- **Keyboard (full operability, no pointer required):** type + auto-advance; overtype a full cell; `Backspace` clears the current cell or, if empty, clears and moves to the previous; `Delete` clears in place; `ArrowLeft`/`ArrowRight` move between cells; `Home`/`End` jump to first/last.
- **Paste:** pasting anywhere distributes across cells from the target, sanitizes to the accepted character set, and truncates to length.
- **Autofill & mobile:** `autoComplete="one-time-code"` on the first cell (so iOS/Android SMS autofill and password managers fill the whole code) and `inputMode="numeric"` for a numeric keypad.
- **Status & disabled:** validation flows through `Field`'s status (message announced); disabled uses `aria-disabled` on the group plus native `disabled` on cells.
### Performance Considerations
Renders a fixed, small number of DOM nodes (one `` per character — typically 4–8), so no virtualization or measurement strategy is needed.
### Pre-submission Checklist
- [x] I have read the Contributing guide
- [x] I have read the API Conventions
- [x] I have checked that existing Astryx components cannot compose to solve this
- [x] This is a general-purpose UI pattern (not specific to one product)
Contributor guide
Assessment
This issue has not been assessed yet.