Skip to content

One-time code

Single-character slots over one input, for verification and two-factor codes.

Status
Beta
Category
Inputs
Adoption
Not used yet
import { InputOTP } from "@oration/canon/components/input-otp";
packages/canon/src/components/input-otp.tsx

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

Codes arrive in email and SMS and get pasted. One real input underneath makes paste and one-time-code autofill work; don't break it with per-slot inputs.

The Tabular Figures Rule

Digits in slots are set in tabular figures so every slot holds its character at the same width and the row doesn't shimmer as it fills.

Anatomy#

4
8
2
9
  1. Container. InputOTP: a flex row that holds the groups and, on top of them, the transparent real input that receives typing and paste.
  2. Group. InputOTPGroup joins its slots into one shape with shared borders and 10px outer corners.
  3. Slot. InputOTPSlot index: a 32px square showing one character from the input's value.
  4. 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.
  5. Separator. InputOTPSeparator: a 16px minus sign with role="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.

4
8
1
9
2
0
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#

Empty
Active
4
8
Filled
4
8
2
9
1
3
Invalid
4
8
1
9
2
0
Disabled
4
8
2
9
1
3
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>    );}
States
StateTreatment
EmptyEvery slot shows its Field Stroke border.
ActiveWhile 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.
FilledSlots show their characters in Graphite Ink.
InvalidWith 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.
Disableddisabled dims the whole row to 50% and shows a not-allowed cursor.
VerifiedNo built-in style. Sign-in tints the slot borders Ledger Green at 60% while it moves on.

Behavior#

  • maxLength sets 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.
  • pattern filters 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. pasteTransformer can strip spaces or dashes before it lands.
  • inputMode defaults to numeric, so phones show the number pad.
  • Groups are only visual. Characters flow across them and the value never contains the separator.
  • containerClassName styles the row, for example w-full gap-2 with flex-1 groups to fill a card.

Do and don't#

4
8
1
9
2
0

That code didn't match. Check the latest email or send a new code.

Do. Mark every slot invalid and say what to do next under the row: check the latest email, or resend.
4
8
1
9
2
0

Error

Don't. Set aria-invalid only on the root, so the slots stay gray and the error floats unexplained.
Do. Verify with onComplete as soon as the last digit lands, and offer a way to send a new code.
4
8
2
9
1
Don't. Make people press Verify after the sixth digit when nothing else is left to do.

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-label or a <label htmlFor> pointing at its id.
  • 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-describedby on InputOTP.
  • 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).
Keyboard interactions
KeysAction
0–9Types into the active slot and moves to the next.
BackspaceDeletes the previous character.
←→Moves the caret between filled slots.
⌘VPastes a whole code into every slot.
EnterSubmits the surrounding form.

Design tokens#

Design tokens
TokenUsed for
--inputSlot borders; 30% fill in dark
--ringActive slot border and 3px ring at 50%
--destructiveInvalid borders and 3px ring at 20% (40% in dark)
--foregroundCharacters and the caret
--radius-lg10px outer corners on each group
animate-caret-blinkThe 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).

Props of InputOTP
PropTypeDefaultDescription
maxLengthRequirednumberNo defaultNumber of characters, and of slots you render.
valuestringNo defaultThe code, controlled.
onChange(newValue: string) => unknownNo defaultCalled with the whole code on every change.
onComplete(...args: any[]) => unknownNo defaultCalled with the code when the last slot fills.
patternstringNo defaultA regular expression source that each entry must match, such as ^\d+$.
inputModestring"numeric"The on-screen keyboard.
pasteTransformer(pasted: string) => stringNo defaultCleans pasted text before it is applied.
containerClassNamestringNo defaultClasses 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) => ReactNodeNo defaultRender slots from state instead of children.
disabledbooleanNo defaultDims 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>.

Props of InputOTPSlot
PropTypeDefaultDescription
indexRequirednumberNo defaultPosition in the code, from 0.
aria-invalidbooleanNo defaultPaints 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.