@rxova/react-otp-input
v1.0.5
Published
A headless, accessible one-time-code input for React.
Readme
A headless, accessible one-time-code input for React. One real input underneath, real slots on top — including the tap-to-edit any slot that the incumbent can't do.
npm install @rxova/react-otp-input📖 Documentation & live examples → — guides, form recipes, theming, WebOTP, and migration guides from other OTP libraries.
- One real
<input>— paste, SMS autofill, IME, undo, native<form>submission and screen-reader semantics all come from the platform, not from hand-rolled JavaScript - Tap any slot to edit it — the input's characters sit at their true slot pitch, so a click or tap lands the caret where you touched, which a collapsed single-input field physically can't do
- Rapid typing stays ordered — if keyboard input begins before a click's caret placement has settled, the keyboard wins; a delayed pointer frame never moves the caret midway through the code
- WebOTP — programmatic SMS retrieval (
useWebOTP) that no other OTP library ships - Headless — zero runtime dependencies, no stylesheet to import, ~4.3 kB brotli
- Form-ready — string
onChange, nativename, and first-class React Hook Form / Formik / React Final Form / TanStack Form recipes - Correct on the seams — formatted paste, Chrome auto-translate, IME, RTL, alphanumeric, SSR/RSC
Basic usage
import { useState } from "react";
import { OtpInput } from "@rxova/react-otp-input";
function Verify() {
const [code, setCode] = useState("");
return (
<OtpInput
length={6}
value={code}
onChange={setCode}
onComplete={submit}
label="One-time code"
/>
);
}| default | grouped 123–456 | alphanumeric |
| :----------------------------------------------------------------------------------: | :---------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: |
| | | |
| masked | invalid | render prop |
| | | |
Four graduated tiers
One primitive, four levels of control. Simple stays a one-liner; complex stays possible without forking.
Tier 1 — the declarative default:
<OtpInput length={6} value={code} onChange={setCode} label="One-time code" />Tier 2 — compound composition, for grouping and separators:
<OtpInput length={6} value={code} onChange={setCode} label="One-time code">
<OtpGroup>
<OtpSlot index={0} />
<OtpSlot index={1} />
<OtpSlot index={2} />
</OtpGroup>
<OtpSeparator>–</OtpSeparator>
<OtpGroup>
<OtpSlot index={3} />
<OtpSlot index={4} />
<OtpSlot index={5} />
</OtpGroup>
</OtpInput>Tier 3 — a render prop, when you want your own slot component:
<OtpInput
length={6}
value={code}
onChange={setCode}
render={({ slots }) => slots.map((s) => <MySlot key={s.index} {...s} />)}
/>Tier 4 — the headless hook, to own the markup entirely:
const otp = useOtpInput({ length: 6, value: code, onChange: setCode });Forms
onChange emits the sanitized string, and the underlying input posts natively under name.
With a plain <form>, the single input is the field — just pass name:
<OtpInput name="code" length={6} label="Code" />With React Hook Form, Formik or React Final Form, bind value and onChange through a controlled
adapter:
<Controller
name="code"
control={control}
render={({ field }) => (
<OtpInput
length={6}
label="Code"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
inputRef={field.ref}
/>
)}
/>Full recipes for React Hook Form, Formik, React Final Form, TanStack Form and native forms are in the form-library guide.
Styling
No stylesheet to import. Layout-critical CSS is inlined; everything visual is a custom property or a
data-* hook.
| Property | Default | Applies to |
| --------------------------------------- | ------------------------- | ------------------------------ |
| --rx-otp-slot-size | 2.5rem | Slot width and height |
| --rx-otp-gap | 0.5rem | Space between slots |
| --rx-otp-font / -font-size | inherited / 1.125rem | Slot and separator typography |
| --rx-otp-color / -bg | inherited / transparent | Slot foreground and background |
| --rx-otp-border | 1px solid #d4d4d8 | Slot border |
| --rx-otp-radius | 0.5rem | Slot corners |
| --rx-otp-active-ring | 2px solid Highlight | Ring on the active slot |
| --rx-otp-caret-color / -caret-width | currentColor / 2px | The painted caret |
| --rx-otp-placeholder-color | inherited, dimmed | Per-slot placeholder |
| --rx-otp-separator-color | inherited | <OtpSeparator> |
| --rx-otp-transition | a short ease | Slot state changes |
data-* attributes
These are public API, covered by semver.
| Attribute | On | Meaning |
| -------------------------------------------------- | --------- | ------------------------------- |
| data-rx-otp-root | wrapper | Always present |
| data-rx-otp-input | <input> | The single real control |
| data-rx-otp-group | group | <OtpGroup> |
| data-rx-otp-slot | slot | One painted slot |
| data-rx-otp-separator | separator | <OtpSeparator> |
| data-rx-otp-caret | caret | The painted caret |
| data-state | slot | filled, active or empty |
| data-filled / data-active | slot | Convenience flags for the above |
| data-disabled / data-readonly / data-invalid | wrapper | Mirrors the props |
Keyboard
| Key | Effect |
| --------------------- | ------------------------------------------------------- |
| any allowed character | Fills the active slot and advances |
| Backspace | Clears the slot, or steps back when it is already empty |
| ← / → | Move between slots |
| Home / End | Jump to the first or last slot |
| Ctrl/Cmd + V | Paste and distribute, after pasteTransform |
| Tab | Leave the field — it is one tab stop, not one per slot |
Accessibility
One native text <input> — a screen reader announces a single "one-time code, edit text", not "1 of 6"
repeated. Slots are aria-hidden decoration. label / aria-label name the field; invalid +
aria-describedby wire error text. Verified with axe in CI.
iOS note: the default
slotInteraction="spatial"auto-degrades to"crush"on iOS, which cannot fully hide the native::selection. Tap-to-edit stays keyboard-plus-caret there; it is full spatial everywhere else. Force either mode explicitly with theslotInteractionprop.
UI-library recipes
The docs contain maintained examples for shadcn/ui, Radix Themes, Material UI, Chakra UI, Mantine and Ant Design. Copy a ready-labelled wrapper with:
npx shadcn@latest add https://rxova.dev/packages/react-inputs/r/otp-field.jsonPart of rxova
Part of the rxova headless React inputs suite —
install the whole set from
@rxova/react-inputs, or read the full
generated API reference for this
package.
Migrating? npx @rxova/codemod input-otp-to-otp --dry ./src handles most of the move from
input-otp; the
migration guide covers that and
react-otp-input.
License
MIT © rxova
