OTP Input
ProA numeric one-time-passcode field. Auto-advances on entry, distributes pasted codes across slots, and supports SMS autofill on mobile. Configure the digit count, size, and error state with plain props.
Installation#
Usage#
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
Length#
length sets the number of digit slots, defaulting to 6. Four-digit PINs and longer codes use the same component; paste a full code into any slot and it spreads across the rest.
Label & hint#
Pass label to caption the field and hint for helper text below it. The label wires up as the group’s aria-labelledby (clicking it focuses the first slot) and the hint as its aria-describedby. Add required for a destructive asterisk.
Sizes#
Three sizes: sm (40 px slots), md (48 px, the default), and lg (56 px). Each stays at or above the 40 px minimum touch target.
Separators#
Split the slots into groups with separator: "dash" draws a short divider line, "dot" renders a centered ·, or pass any node for a custom mark. Groups default to two even halves; override with groupSize.
Digit entry#
Every digit you type rises into its slot from below, on the same roll AnimatedNumber uses: it travels up on duration-base with ease-out and reaches full opacity early, so the eye tracks motion rather than a fade. Typing over a digit, or deleting it, lifts the old one up and out as the new one comes in. Each slot keeps its own roll, so typing fast never cuts the previous digit short.
A pasted code fans in left to right, one --number-roll-stagger step apart, so it reads in the order you would say it. A code that is already there when the field mounts (defaultValue, a restored form) holds still. Under prefers-reduced-motion digits simply appear. The slots stay real inputs underneath, so the caret, selection, autofill and screen readers work as usual.
Validation & errors#
Input is numeric-only by construction: non-digit characters (and non-numeric pasted content) are stripped before they ever reach a slot, so you never validate the format yourself. For a wrong code, pass hasError to switch every slot to the destructive border and ring and set aria-invalid; pair it with a message below the field. Use onComplete to run verification the moment the last slot fills, and onChange for keystroke-level state. When hasError is set, the hint turns destructive so it doubles as the error message, and the row of slots shakes once (see below).
Wrong-code shake#
A rejected code doesn’t just recolor: the row of slots swings through a damped wobble and settles, so the refusal registers before you’ve read a word of the message. Each swing is weaker than the last and the motion runs on ease-sine, which leaves and arrives at zero velocity, so the throws join into one continuous arc rather than a buzz. Only the row moves. The label and the hint hold still, which keeps the error text readable the instant it lands, and the digits stay put so you can repair the one you got wrong instead of retyping all six.
It fires on its own every time hasError goes from false to true, including when the field mounts already in an error state (a form that came back rejected). No extra props: clear the error as the user edits and the next failure shakes again. The motion is decorative, so it is skipped entirely under prefers-reduced-motion, where the destructive border, aria-invalid, and the hint still carry the verdict.
If you keep hasError pinned true across attempts, there is no flip to key off and the second identical rejection would sit silent. Change errorKey instead: any new value re-arms the shake under a standing error. An attempt counter is the usual choice.
Disabled#
API reference#
OTPInput
A one-time-code field: one numeric input per slot, with paste spreading and SMS autofill. Forwards native <div> props to the wrapper; accepts className, merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | Number of digit slots. |
| size | "sm" | "md" | "lg" | "md" | Slot size: 40, 48 or 56px squares. |
| value | string | - | The concatenated digits, controlled. |
| defaultValue | string | "" | The initial digits, uncontrolled. |
| onChange | (value: string) => void | - | Called on every edit with the concatenated digits. |
| onComplete | (value: string) => void | - | Called once, when the last empty slot fills: start verification here. |
| hasError | boolean | false | Destructive slots, aria-invalid, a destructive hint, and one shake of the row. |
| errorKey | string | number | - | Re-arms the shake when it changes while hasError stays true. An attempt counter works. |
| disabled | boolean | false | Disables every slot. |
| label | React.ReactNode | - | Rendered above the slots and wired as the group's aria-labelledby. |
| hint | React.ReactNode | - | Helper text below, wired as aria-describedby; destructive with hasError. |
| required | boolean | false | Appends a destructive asterisk to the label. |
| separator | "dash" | "dot" | React.ReactNode | - | A mark between groups: a short line, a centred dot, or any node. Omit for an even row. |
| groupSize | number | - | Slots per group when separator is set. Defaults to two even halves. |
| placeholder | string | - | One character shown in empty, unfocused slots. |
| name | string | - | Renders a hidden input so the code posts with a native form. |
| autoFocus | boolean | false | Focuses the first slot on mount. |
| slotClassName | string | - | Merged onto every digit slot. |
| aria-label | string | "One-time passcode" | The group's name when there is no label. |
| Data attribute | Description |
|---|---|
| data-error | On the slot row while hasError. |
| data-disabled | On the slot row while disabled. |
| data-filled | On each slot holding a digit. |
| CSS variable | Description |
|---|---|
| --shake-distance | How far the row travels in the error shake: 2, 3 or 4px by size. |