OTP Input
A 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#
import { OTPInput } from "@/components/ui/otp-input"
export function Example() {
return (
<OTPInput
length={6}
onComplete={(code) => verify(code)}
/>
)
}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.