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.

Enter the 6-digit code we sent to your phone.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add otp-input

Usage#

usage.tsxTSX
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.

Check your authenticator app.

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).

That code didn’t match. Try again.

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.

Enter any 6 digits to see the shake. The matching code is 424242.

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.

Fill the row with a wrong code, then do it again: it shakes every time.

Disabled#

FAQ#

`onChange` fires on every keystroke with the current value, so use it for keystroke-level state. `onComplete` fires once the moment the last slot fills, which is the right place to kick off verification.