One-time code
Single-character slots over one input, for verification and two-factor codes.
Check your email
We sent a 6-digit code to maya.okafor@cedarline.io. It expires in 10 minutes.
import { Button } from "@oration/canon/components/button";import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { Label } from "@oration/canon/components/label";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import { MailIcon } from "lucide-react";import * as React from "react";export function Hero() { const id = React.useId(); const [code, setCode] = React.useState(""); const [verifying, setVerifying] = React.useState(false); const [wait, setWait] = React.useState(24); React.useEffect(() => { if (wait <= 0) return; const timer = window.setTimeout(() => setWait((s) => s - 1), 1000); return () => window.clearTimeout(timer); }, [wait]); const verify = (value: string) => { setVerifying(true); window.setTimeout(() => { setVerifying(false); setCode(""); toast.add({ type: "success", title: "Signed in as Maya Okafor", description: `Code ${value} verified.`, }); }, 900); }; return ( <div className="flex w-full max-w-sm flex-col gap-6 rounded-xl bg-card p-6 text-left shadow-border"> <span className="flex size-10 items-center justify-center rounded-xl bg-card text-muted-foreground shadow-border [&_svg]:size-5"> <MailIcon aria-hidden="true" /> </span> <div className="flex flex-col gap-1.5"> <p className="text-xl font-semibold tracking-[-0.015em] text-foreground"> Check your email </p> <p id={`${id}-hint`} className="text-sm text-muted-foreground"> We sent a 6-digit code to maya.okafor@cedarline.io. It expires in 10 minutes. </p> </div> <div className="flex flex-col gap-2"> <Label htmlFor={`${id}-code`} className="sr-only"> Verification code </Label> <InputOTP id={`${id}-code`} maxLength={6} pattern="^\d+$" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={setCode} onComplete={verify} disabled={verifying} aria-describedby={`${id}-hint`} containerClassName="w-full gap-2" > {[0, 3].map((start) => ( <React.Fragment key={start}> {start > 0 ? ( <InputOTPSeparator className="text-subtle-foreground" /> ) : null} <InputOTPGroup className="flex-1"> {[start, start + 1, start + 2].map((index) => ( <InputOTPSlot key={index} index={index} className="h-11 flex-1 text-lg font-medium tabular-nums" /> ))} </InputOTPGroup> </React.Fragment> ))} </InputOTP> </div> <div className="flex items-center justify-between gap-2"> <Button type="button" variant="ghost" size="sm" disabled={wait > 0} className="-ml-2 tabular-nums" onClick={() => { setWait(30); toast.add({ title: "Sent a new code", description: "Check maya.okafor@cedarline.io.", }); }} > {wait > 0 ? `Send a new code in ${wait}s` : "Send a new code"} </Button> {verifying ? ( <span className="flex items-center gap-1.5 text-13 text-muted-foreground"> <Spinner aria-hidden="true" className="size-3.5" /> Verifying </span> ) : null} </div> </div> );}Usage#
One-time code draws a verification code as a row of single-character slots over one real input, so paste, SMS autofill through autocomplete="one-time-code", Backspace and the caret all behave like a normal field. Oration uses it for sign-in verification and SMS two-factor setup: six digits, in one run or two groups of three. The mistake people make is setting aria-invalid only on InputOTP. The real input sits outside the slot groups, so the slots stay gray; pass aria-invalid to each InputOTPSlot as well.
When to use
- For a six-digit code sent by email or SMS to verify a sign-in or a new device.
- For confirming a phone number while turning on SMS two-factor.
- For an authenticator app code when enrolling or signing in.
- For short, fixed-length recovery codes, with a pattern that allows letters.
When not to use
- For a password or passphrase people remember. Use Password input
- For a long or variable-length code, such as a bank micro-deposit reference. Use Input
- For a PIN people set and reuse. Mask it and use a regular field. Use Password input
- For showing a code for people to copy, such as a backup code list. Use Copy row
Never block paste
The Tabular Figures Rule
Anatomy#
- Container.
InputOTP: a flex row that holds the groups and, on top of them, the transparent real input that receives typing and paste. - Group.
InputOTPGroupjoins its slots into one shape with shared borders and 10px outer corners. - Slot.
InputOTPSlot index: a 32px square showing one character from the input's value. - Active slot. The slot where the next character lands: an indigo border and a 3px ring at 50%, with a 1px blinking caret while empty.
- Separator.
InputOTPSeparator: a 16px minus sign withrole="separator"between groups.
Examples#
Joined or grouped
Six joined slots for an SMS code, as in two-factor setup, or two groups of three with a separator for an authenticator code. Either way it is one input; the sixth digit calls onComplete.
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Layouts() { const [sms, setSms] = React.useState(""); const [app, setApp] = React.useState(""); return ( <div className="flex flex-wrap items-start justify-center gap-10"> <div className="flex flex-col gap-2"> <Label htmlFor="otp-layout-sms">6-digit code</Label> <InputOTP id="otp-layout-sms" maxLength={6} pattern="^[0-9]+$" inputMode="numeric" autoComplete="one-time-code" value={sms} onChange={setSms} onComplete={(value) => toast.add({ type: "success", title: "Phone verified", description: `Code ${value} accepted.`, }) } > <InputOTPGroup> {[0, 1, 2, 3, 4, 5].map((index) => ( <InputOTPSlot key={index} index={index} className="size-10 text-base tabular-nums" /> ))} </InputOTPGroup> </InputOTP> </div> <div className="flex flex-col gap-2"> <Label htmlFor="otp-layout-app">Authenticator code</Label> <InputOTP id="otp-layout-app" maxLength={6} pattern="^[0-9]+$" inputMode="numeric" autoComplete="one-time-code" value={app} onChange={setApp} onComplete={(value) => toast.add({ type: "success", title: "Authenticator app added", description: `Code ${value} accepted.`, }) } containerClassName="gap-2" > <InputOTPGroup> <InputOTPSlot index={0} className="tabular-nums" /> <InputOTPSlot index={1} className="tabular-nums" /> <InputOTPSlot index={2} className="tabular-nums" /> </InputOTPGroup> <InputOTPSeparator className="text-subtle-foreground" /> <InputOTPGroup> <InputOTPSlot index={3} className="tabular-nums" /> <InputOTPSlot index={4} className="tabular-nums" /> <InputOTPSlot index={5} className="tabular-nums" /> </InputOTPGroup> </InputOTP> </div> </div> );}Invalid code
Set aria-invalid on each slot as well as the root, and put the next step under the row. Typing clears the error.
import { Button } from "@oration/canon/components/button";import { FieldError } from "@oration/canon/components/field";import { InputOTP, InputOTPGroup, InputOTPSlot } from "@oration/canon/components/input-otp";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Invalid() { const [code, setCode] = React.useState("481920"); const [error, setError] = React.useState<string | null>( "That code didn't match. Check the latest text or send a new code.", ); return ( <form className="flex flex-col items-center gap-3" onSubmit={(event) => { event.preventDefault(); if (code.length < 6) { setError("Enter all 6 digits."); return; } setError(null); toast.add({ type: "success", title: "Two-factor turned on", description: "Codes go to +1 (312) 555-0142.", }); }} > <Label htmlFor="otp-invalid" className="sr-only"> 6-digit code </Label> <InputOTP id="otp-invalid" maxLength={6} pattern="^\d+$" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(value) => { setCode(value); setError(null); }} aria-invalid={error ? true : undefined} aria-describedby={error ? "otp-invalid-error" : undefined} > <InputOTPGroup> {[0, 1, 2, 3, 4, 5].map((index) => ( <InputOTPSlot key={index} index={index} aria-invalid={error ? true : undefined} className="size-10 text-base tabular-nums" /> ))} </InputOTPGroup> </InputOTP> <FieldError id="otp-invalid-error" className="max-w-64 text-center text-[13px]" > {error} </FieldError> <Button type="submit" size="sm"> Verify </Button> </form> );}Letters and numbers
A backup code: eight characters in Geist Mono, a pattern that allows letters, uppercase on change and a pasteTransformer that drops the dash.
Letters and numbers. Paste it with or without the dash.
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function RecoveryCode() { const [code, setCode] = React.useState(""); return ( <div className="flex flex-col items-center gap-2"> <Label htmlFor="otp-recovery">Backup code</Label> <InputOTP id="otp-recovery" maxLength={8} pattern="^[a-zA-Z0-9]+$" inputMode="text" autoComplete="off" pasteTransformer={(pasted) => pasted.replace(/[\s-]/g, "")} value={code} onChange={(value) => setCode(value.toUpperCase())} onComplete={(value) => toast.add({ title: "Backup code used", description: `${value.toUpperCase()} can't be used again.`, }) } aria-describedby="otp-recovery-hint" containerClassName="gap-2" > <InputOTPGroup> {[0, 1, 2, 3].map((index) => ( <InputOTPSlot key={index} index={index} className="font-mono text-[13px]" /> ))} </InputOTPGroup> <InputOTPSeparator className="text-subtle-foreground" /> <InputOTPGroup> {[4, 5, 6, 7].map((index) => ( <InputOTPSlot key={index} index={index} className="font-mono text-[13px]" /> ))} </InputOTPGroup> </InputOTP> <p id="otp-recovery-hint" className="text-13 text-muted-foreground"> Letters and numbers. Paste it with or without the dash. </p> </div> );}States#
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@oration/canon/components/input-otp";import { cn } from "@oration/canon/lib/utils";export function StatesRow() { const states = [ { name: "Empty", value: "", active: -1, invalid: false, disabled: false, }, { name: "Active", value: "48", active: 2, invalid: false, disabled: false, }, { name: "Filled", value: "482913", active: -1, invalid: false, disabled: false, }, { name: "Invalid", value: "481920", active: -1, invalid: true, disabled: false, }, { name: "Disabled", value: "482913", active: -1, invalid: false, disabled: true, }, ]; return ( <div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> {states.map((state) => ( <div key={state.name} className="pointer-events-none flex min-w-0 flex-col gap-2" > <span className="text-xs text-muted-foreground"> {state.name} </span> <InputOTP maxLength={6} value={state.value} readOnly tabIndex={-1} disabled={state.disabled} aria-label={`Verification code, ${state.name}`} > <InputOTPGroup> {[0, 1, 2, 3, 4, 5].map((index) => ( <InputOTPSlot key={index} index={index} aria-invalid={state.invalid || undefined} className={cn( "tabular-nums", index === state.active && "z-10 border-ring ring-3 ring-ring/50", )} /> ))} </InputOTPGroup> </InputOTP> </div> ))} </div> );}| State | Treatment |
|---|---|
| Empty | Every slot shows its Field Stroke border. |
| Active | While focused, the slot at the caret takes the indigo border and ring and rises above its neighbors; an empty active slot shows the blinking caret. |
| Filled | Slots show their characters in Graphite Ink. |
| Invalid | With aria-invalid on each slot, borders turn Signal Red and the group takes a 3px red ring at 20%. The active slot keeps a red ring. |
| Disabled | disabled dims the whole row to 50% and shows a not-allowed cursor. |
| Verified | No built-in style. Sign-in tints the slot borders Ledger Green at 60% while it moves on. |
Behavior#
maxLengthsets how many characters the input takes; render the same number of slots, indexed from 0.onChange(value)receives the whole string;onComplete(value)fires once the last slot fills, including after a paste or autofill. Verify there.patternfilters what can be typed or pasted:^\d+$for digits,^[a-zA-Z0-9]+$for recovery codes. Non-matching input is ignored.- Pasting a code fills every slot at once.
pasteTransformercan strip spaces or dashes before it lands. inputModedefaults tonumeric, so phones show the number pad.- Groups are only visual. Characters flow across them and the value never contains the separator.
containerClassNamestyles the row, for examplew-full gap-2withflex-1groups to fill a card.
Do and don't#
That code didn't match. Check the latest email or send a new code.
Error
aria-invalid only on the root, so the slots stay gray and the error floats unexplained.onComplete as soon as the last digit lands, and offer a way to send a new code.Content#
- Say where the code went and how long it lasts: We sent a 6-digit code to maya.okafor@cedarline.io. It expires in 10 minutes.
- The label names the code: Verification code or 6-digit code.
- Errors give a next step: That code didn't match. Check the latest email or send a new code.
- Offer Send a new code with a countdown rather than a bare resend link that can be hammered.
Accessibility#
- There is one real input, so screen readers announce a single text field with its whole value. Name it with
aria-labelor a<label htmlFor>pointing at itsid. - Slots are presentational; don't give them roles or tab stops.
- Set
autoComplete="one-time-code"so iOS and Android offer the code from SMS. - Link the instructions and errors with
aria-describedbyonInputOTP. - The caret blinks with opacity only; it doesn't move, so it stays on under reduced motion.
- Default slots are 32px. On touch screens enlarge them with
className(the sign-in form uses 44 to 48px).
| Keys | Action |
|---|---|
| 0–9 | Types into the active slot and moves to the next. |
| Backspace | Deletes the previous character. |
| ←→ | Moves the caret between filled slots. |
| ⌘V | Pastes a whole code into every slot. |
| Enter | Submits the surrounding form. |
Design tokens#
| Token | Used for |
|---|---|
--input | Slot borders; 30% fill in dark |
--ring | Active slot border and 3px ring at 50% |
--destructive | Invalid borders and 3px ring at 20% (40% in dark) |
--foreground | Characters and the caret |
--radius-lg | 10px outer corners on each group |
animate-caret-blink | The 1.1s caret blink |
API reference#
InputOTP
The root and its real input, from the input-otp library. Other props go to the input.
Other props spread onto input-otp OTPInput (<input> attributes).
| Prop | Type | Default | Description |
|---|---|---|---|
maxLengthRequired | number | No default | Number of characters, and of slots you render. |
value | string | No default | The code, controlled. |
onChange | (newValue: string) => unknown | No default | Called with the whole code on every change. |
onComplete | (...args: any[]) => unknown | No default | Called with the code when the last slot fills. |
pattern | string | No default | A regular expression source that each entry must match, such as ^\d+$. |
inputMode | string | "numeric" | The on-screen keyboard. |
pasteTransformer | (pasted: string) => string | No default | Cleans pasted text before it is applied. |
containerClassName | string | No default | Classes for the row that holds the groups. |
textAlign | "left" | "center" | "right" | "left" | Where the hidden caret sits; affects which slot is active on click. |
pushPasswordManagerStrategy | "increase-width" | "none" | "increase-width" | Makes room for password manager badges. |
render | (props: RenderProps) => ReactNode | No default | Render slots from state instead of children. |
disabled | boolean | No default | Dims the row and blocks input. |
InputOTPGroup
Joins slots into one bordered shape.
Other props spread onto <div>.
No props of its own.
InputOTPSlot
One character cell, read from the root's context.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
indexRequired | number | No default | Position in the code, from 0. |
aria-invalid | boolean | No default | Paints the slot and its group red. Set it on every slot. |
InputOTPSeparator
A minus sign between groups.
Other props spread onto <div>.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
aria-invalid on InputOTP doesn't reach the slots: the library renders the input beside the groups, not inside them, so the group's has-aria-invalid and the slot's aria-invalid: styles only apply when each slot gets aria-invalid. Sign-in paints its own red classes instead.
Slots are 32px with 14px text and there's no size prop. Both product call sites override them: SMS setup to 40px at 16px, sign-in to 44 to 48px at 18px.
There's no success style; sign-in adds border-success/60 by hand.
REGEXP_ONLY_DIGITS and the other pattern constants from input-otp aren't re-exported, so call sites write the pattern strings themselves.
The separator's icon has no color of its own; call sites pass text-subtle-foreground.