Input OTP
Enter and verify a one-time code, with a countdown before resending.
import { InputOTP, InputOTPResend } from '@youj/react'
Installation & usage
npm i @youj/reactFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Use it for short codes people copy from somewhere else: an authenticator app, an email, a text message, a recovery sheet. It's one real input, laid over 4 to 8 slots that only draw it, so SMS autofill and password managers work, paste fills the whole code, and a screen reader meets one labeled field instead of six. For a secret people choose themselves, use a Password Input. For a quantity, use a Number Input.
import { Field, FieldLabel, InputOTP, useOTPVerification } from '@youj/react'
export function VerifyCode() {
const otp = useOTPVerification(async (code) => {
const res = await fetch('/api/verify', { method: 'POST', body: code })
return res.ok
})
return (
<Field invalid={otp.status === 'error'}>
<FieldLabel>Authentication code</FieldLabel>
<InputOTP groups={[3, 3]} {...otp.field} />
</Field>
)
}useOTPVerification(verify) runs verify once the last slot fills and drives status through verifying to success or error. Resolve true, or nothing, for success. Resolve false, or throw, for an error: the row shakes, and the code clears once the shake has played so the next attempt starts empty. Spread its field onto InputOTP, and call reset() to start over.
Description
A two-factor card: a six-slot code in two groups of three, verified when the last digit is entered, with a resend button.
InputOTPResend holds "Resend code" behind a countdown ("Resend in 0:24") that starts straight away and restarts after each send. The send runs through AsyncButton's onAction, so it shows a spinner and then a check or an error in place. seconds sets the wait, 30 by default. While waiting, the button stays focusable, so keyboard users can read how long is left. useCountdown is exported for your own timers.
Codes of different lengths, groups and modes.
length sets 4 to 8 slots, 6 by default. groups splits a long code into readable chunks with a dash between them, such as [3, 3] or [4, 4]. mode="alphanumeric" accepts letters and digits and upper-cases them, for recovery and invite codes.
Pasting a code fills every slot at once. Spaces and dashes in the pasted text are dropped, so 424 242 and 424-242 both work.
The verifying, success and error statuses.
Drive status yourself when verification lives somewhere else. verifying makes the field read-only with a spinner beside it, success turns the slots green from the inline start and draws a check, and error shakes the row and marks it invalid. statusLabels renames what's announced for each.
A code that submits its form once it is complete.
Without verification in JavaScript, autoSubmit submits the owning form once the code is complete, and onComplete fires once each time the last slot fills.
Code
input-otp-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propslength, value, defaultValue… (28)
Props
| Prop | Type | |
|---|---|---|
length | numberNumber of characters, 4–8. default 6 | 6 |
value | string | – |
defaultValue | stringdefault '' | '' |
onValueChange | (value: string) => void | – |
onComplete | (value: string) => voidCalled once each time the last slot is filled. | – |
autoSubmit | booleanSubmit the owning form when the code is complete. default false | false |
mode | OTPMode'numeric''alphanumeric'Digits only (default), or letters and digits (upper-cased), e.g. recovery codes. default 'numeric' | 'numeric' |
groups | number[]Slot grouping, e.g. [3, 3]. Groups are split by a dash. Default: one group. | – |
status | OTPStatus'idle''verifying''success''error'verifying: read-only with a spinner. success: green slots and a drawn check. error: a shake. default 'idle' | 'idle' |
statusLabels | { verifying?: string; success?: string; error?: string }Announced for each status. | – |
className | stringCSS class applied to the element, or a function that returns a class based on the component's state. | – |
ref | Ref<HTMLInputElement>Allows getting a ref to the component instance.
Once the component unmounts, React will set | – |
Other props pass through: extends InputPrimitive.Props. | ||
Other Base UI Input props pass through, such as name, aria-label and onFocus.
InputOTPResend
| Prop | Type | |
|---|---|---|
onResendRequired | () => unknownSends a new code. Resolve for success; reject to show the error. | – |
seconds | numberSeconds to wait between sends. default 30 | 30 |
autoStart | booleanStart waiting straight away (a code was just sent). default true | true |
waitingLabel | (time: string) => stringThe label while waiting. default `Resend in 0:24` | `Resend in 0:24` |
children | string | – |
variant | ButtonVariant'default''danger''ghost''outline''link''secondary'
default 'ghost' | 'ghost' |
size | 'default' | 'sm' | 'lg' | 'xs' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'default 'sm' | 'sm' |
sound | booleanWith sound on ( default true | true |
pendingLabel | stringShown beside the spinner while the action runs, and announced. Announced as "Working…" when omitted. | – |
successLabel | string | ((result: TResult) => string)Shown beside the check and announced. A function gets what
default "Done" | "Done" |
errorLabel | string | ((error: unknown) => string)Shown beside the cross and announced assertively. A
function gets what default "Failed" | "Failed" |
resetAfter | numberHow long success / error hold before the label returns, in ms. Default: 3 × the deliberate token. | – |
onActionStateChange | (state: AsyncState) => voidFires on every state change: idle → pending → success | error → idle. | – |
hold | boolean | numberHold to confirm: the action runs only once a press is held (a pointer, or
Space or Enter held down) for this long. | – |
MotionCaret frame, character in, caret blink… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Caret frameone focus frame glides from slot to slot as you type or arrow (Sliding Indicator) | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 | |
| Character ineach character rises 4px into its slot | duration.fastease.enter140ms · cubic-bezier(0.16, 1, 0.3, 1) | 140mscubic-bezier(0.16, 1, 0.3, 1) | |
| Caret blinkin an empty active slot | duration.deliberateease.linear560ms · cubic-bezier(0, 0, 1, 1) · × 2 per blink | 560mscubic-bezier(0, 0, 1, 1)× 2 per blink | |
| Successslot borders turn green from the inline start, one tight stagger step apart | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
| Success checkdraws on beside the slots | duration.slowease.enter340ms · cubic-bezier(0.16, 1, 0.3, 1) | 340mscubic-bezier(0.16, 1, 0.3, 1) | |
| Error shake4, 3 and 2px, decaying | duration.slowease.standard340ms · cubic-bezier(0.2, 0, 0, 1) | 340mscubic-bezier(0.2, 0, 0, 1) | |
Clear after errorwith useOTPVerification, once the shake has played | duration.slow340ms | 340ms |
Reduced motion
The frame jumps between slots, characters appear in place, the caret holds still, success tints every slot at once and errors don't shake.
Reduced motion
The focus frame jumps to the active slot, characters appear with no rise, the caret doesn't blink, success tints every slot at once and the check appears drawn, and an error marks the field without a shake. With useOTPVerification, the code clears straight after an error.
AccessibilityKeyboard, ARIA, focus
Accessibility
- It's a single
<input>withautocomplete="one-time-code",inputmode="numeric"(ortextin alphanumeric mode) and a pattern. The slots are hidden from assistive technology, so the field has one label and one value. - Arrow keys move between slots (mirrored in right-to-left layouts), Home and ArrowUp go to the first, End and ArrowDown to the last, and typing over a filled slot replaces it.
- While verifying, the input is read-only and
aria-busy. An error setsaria-invalid, so a surrounding Field shows its message. - Verifying and success are announced politely, and errors assertively, through a live region beside the field.
- On touch screens the hidden text is 16px, so iOS doesn't zoom into the field on focus.