OTP field
Enter a one-time verification code across a row of single-character cells that behave as one input. Typing advances to the next cell, a pasted code fills the whole field at once, and the focused cell takes the accent ring.
Basic
Give OtpField.Root a length and render that many OtpField.Input cells inside. Label the field with a single <label htmlFor> pointed at the root's id — Base UI wires the first cell to it and names the group for assistive tech — then point aria-describedby at a short instruction so people know what code to enter and how many digits it is.
A filled cell firms its border so entered digits read as present while the empty cells stay quieter. The field stays neutral when it fills — a full field is not a verified code, so success is left to the surrounding form rather than signalled prematurely.
Enter the 6-digit code we sent to your phone.
"use client";
import { OtpField } from "@stridge/noctis";
const LENGTH = 6;
export default function OtpFieldBasic() {
return (
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-basic" className="text-small text-muted">
Verification code
</label>
<OtpField.Root id="otp-basic" length={LENGTH} aria-describedby="otp-basic-hint">
{Array.from({ length: LENGTH }, (_, index) => (
// Cell 1 inherits the group label; the rest announce their position, including the
// total, so a screen-reader user always knows "digit 3 of 6".
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `Digit ${index + 1} of ${LENGTH}`} />
))}
</OtpField.Root>
<p id="otp-basic-hint" className="text-small text-muted">
Enter the 6-digit code we sent to your phone.
</p>
</div>
);
}
Grouped
The canonical six-digit code is split into two groups of three — 123-456. Interleave an OtpField.Separator between the two runs of cells to teach that grouping; it renders a thin neutral divider and sits between the logical groups, so the split mirrors correctly under RTL. Grouping is composition, not a prop — render the separator wherever a break belongs.
"use client";
import { OtpField } from "@stridge/noctis";
import { Fragment } from "react";
const LENGTH = 6;
// Split a six-digit code down the middle — the canonical `123-456` grouping most codes are pasted in.
const SPLIT = LENGTH / 2;
export default function OtpFieldGrouped() {
return (
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-grouped" className="text-small text-muted">
Verification code
</label>
<OtpField.Root id="otp-grouped" length={LENGTH}>
{Array.from({ length: LENGTH }, (_, index) => (
<Fragment key={index}>
{index === SPLIT ? <OtpField.Separator /> : null}
<OtpField.Input aria-label={index === 0 ? undefined : `Digit ${index + 1} of ${LENGTH}`} />
</Fragment>
))}
</OtpField.Root>
</div>
);
}
Sizes
Two heights — medium and large — sharing the field surface and the control-height ladder. Each cell is a square 1:1 slot whose width tracks its height, and the character size tracks the cell.
"use client";
import { OtpField, type OtpFieldSize } from "@stridge/noctis";
const LENGTH = 4;
const SIZES: OtpFieldSize[] = ["md", "lg"];
export default function OtpFieldSizes() {
return (
<div className="flex flex-col items-center gap-4">
{SIZES.map((size, group) => (
<div key={size} className="flex flex-col items-center gap-2">
<label htmlFor={`otp-${size}`} className="text-small text-muted">
{size}
</label>
<OtpField.Root id={`otp-${size}`} size={size} length={LENGTH} defaultValue="12">
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input
key={index}
aria-label={index === 0 ? undefined : `Group ${group + 1} digit ${index + 1}`}
/>
))}
</OtpField.Root>
</div>
))}
</div>
);
}
Custom size
md and lg are the built-in steps, but OTP cells are often wanted bigger than either. The cell's size is a minted seam — set --noctis-otp-field-input-inline-size, --noctis-otp-field-input-block-size, and --noctis-otp-field-input-font-size on any ancestor (here a typed style object; a real consumer writes a CSS rule on a wrapper class) and every cell beneath retunes. Width and height are independent, so keep them equal for a square cell.
"use client";
import { OtpField } from "@stridge/noctis";
import type { CSSProperties } from "react";
const LENGTH = 6;
// `md` and `lg` cover the common cases, but OTP cells are often wanted bigger. The cell's size is a
// minted seam: set the width, height, and character-size tokens on any ancestor and every cell beneath
// retunes — the lint-clean way TSX sets a custom property. Width and height are independent knobs, so
// keep them equal for a square cell. A real consumer writes this as a CSS rule on a wrapper class.
const BIG_CELLS = {
"--noctis-otp-field-input-inline-size": "3.25rem",
"--noctis-otp-field-input-block-size": "3.25rem",
"--noctis-otp-field-input-font-size": "1.5rem",
} as CSSProperties;
export default function OtpFieldCustomSize() {
return (
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-custom" className="text-small text-muted">
Verification code
</label>
<OtpField.Root id="otp-custom" length={LENGTH} style={BIG_CELLS}>
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `Digit ${index + 1} of ${LENGTH}`} />
))}
</OtpField.Root>
</div>
);
}
Masked
Set mask on OtpField.Root to obscure entered characters — for a PIN or any secret that should not read on screen.
"use client";
import { OtpField } from "@stridge/noctis";
const LENGTH = 4;
export default function OtpFieldMasked() {
return (
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-masked" className="text-small text-muted">
PIN
</label>
<OtpField.Root id="otp-masked" length={LENGTH} mask defaultValue="1234">
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `PIN digit ${index + 1}`} />
))}
</OtpField.Root>
</div>
);
}
Controlled
Drive the value yourself with value and onValueChange on OtpField.Root — for validating, persisting, or reacting to each edit. onValueComplete fires when every cell is filled, the moment to verify the code. Omit them (use defaultValue) to let the component own its state.
Value: —
"use client";
import { OtpField } from "@stridge/noctis";
import { useState } from "react";
const LENGTH = 6;
export default function OtpFieldControlled() {
const [value, setValue] = useState("");
const [completed, setCompleted] = useState<string | null>(null);
return (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-controlled" className="text-small text-muted">
Verification code
</label>
<OtpField.Root
id="otp-controlled"
length={LENGTH}
value={value}
onValueChange={setValue}
onValueComplete={setCompleted}
>
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `Digit ${index + 1}`} />
))}
</OtpField.Root>
</div>
<p className="text-small text-muted">
Value: <span className="font-medium text-foreground">{value || "—"}</span>
{completed ? <span className="text-success"> · complete</span> : null}
</p>
</div>
);
}
Verify on complete
A verification field is a submit-on-complete control: validate the instant the last digit lands instead of asking for an extra tap. Set autoSubmit to submit the owning form the moment the field fills, and use onValueComplete to kick off the check and show a pending state.
Fills and verifies the moment all six digits are entered.
"use client";
import { OtpField } from "@stridge/noctis";
import { useState } from "react";
const LENGTH = 6;
type Status = "idle" | "verifying" | "done";
const MESSAGE: Record<Status, string> = {
idle: "Fills and verifies the moment all six digits are entered.",
verifying: "Verifying…",
done: "Verified ✓",
};
export default function OtpFieldAutoSubmit() {
const [status, setStatus] = useState<Status>("idle");
return (
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-auto" className="text-small text-muted">
Verification code
</label>
<OtpField.Root
id="otp-auto"
length={LENGTH}
// `autoSubmit` submits the owning form the instant the field fills; here we drive the
// demo straight from `onValueComplete`, the same completion signal.
autoSubmit
onValueComplete={() => {
setStatus("verifying");
setTimeout(() => setStatus("done"), 1200);
}}
>
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `Digit ${index + 1} of ${LENGTH}`} />
))}
</OtpField.Root>
<p className="text-small text-muted" aria-live="polite">
{MESSAGE[status]}
</p>
</div>
);
}
In a form
Wrap the field in a Field.Root for the full label, instruction, and error contract. Field.Label names the group, Field.Description wires the instruction through aria-describedby, and reporting the field invalid (here from a wrong code) flows data-invalid onto every cell — lighting the danger border — while Field.Error announces the message.
Enter the 6-digit code we sent to your phone.
"use client";
import { Field, OtpField } from "@stridge/noctis";
import { useState } from "react";
const LENGTH = 6;
// The code we pretend was sent — type anything else to see the error path.
const EXPECTED = "123456";
export default function OtpFieldValidation() {
const [value, setValue] = useState("");
const [invalid, setInvalid] = useState(false);
return (
<Field.Root invalid={invalid} className="flex w-full max-w-sm flex-col items-center gap-2">
<Field.Label>Verification code</Field.Label>
<OtpField.Root
length={LENGTH}
value={value}
onValueChange={(next) => {
setValue(next);
// Clear the error as soon as they start editing again.
setInvalid(false);
}}
// Check the moment the field fills — no extra submit tap.
onValueComplete={(next) => setInvalid(next !== EXPECTED)}
>
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `Digit ${index + 1} of ${LENGTH}`} />
))}
</OtpField.Root>
<Field.Description>Enter the 6-digit code we sent to your phone.</Field.Description>
<Field.Error match={invalid}>That code did not match. Check it and try again.</Field.Error>
</Field.Root>
);
}
Mobile & autofill
OTP UX lives or dies on the phone. Base UI sets the pieces up for you and exposes the knobs:
- SMS autofill. The first cell carries
autoComplete="one-time-code"(the default) plus a hidden validation input, so iOS and Android offer the code from the SMS and fill the whole field. This is why the field must stay one Base UIOtpField— splitting it into independent<input>s breaks one-time-code autofill and paste. - Numeric keyboard. With the default
validationType="numeric",inputModeresolves tonumeric, so phones raise the number pad. OverrideinputModewhen you accept letters (see below). - WebOTP. Browsers that support the WebOTP API can read the code from the SMS and submit it automatically — a progressive enhancement on top of the same markup; pair it with
autoSubmit. - Caveat. A password manager may overlay its badge on the first cell or two. It is harmless, but worth knowing when you size the field.
Validation & restriction
validationType decides which characters a cell accepts: numeric (default), alpha, alphanumeric, or none. Rejected keystrokes are dropped and reported through onValueInvalid. Use normalizeValue to canonicalize as the user types — uppercasing an alphanumeric coupon, say — and switch inputMode to text so the full keyboard shows.
"use client";
import { OtpField } from "@stridge/noctis";
const LENGTH = 6;
export default function OtpFieldAlphanumeric() {
return (
<div className="flex flex-col items-center gap-2">
<label htmlFor="otp-alnum" className="text-small text-muted">
Coupon code
</label>
<OtpField.Root
id="otp-alnum"
length={LENGTH}
// Accept letters and digits, normalize to uppercase, and ask for the full keyboard
// (`numeric` is the default for the digit-only case).
validationType="alphanumeric"
inputMode="text"
normalizeValue={(value) => value.toUpperCase()}
defaultValue="A1B2"
>
{Array.from({ length: LENGTH }, (_, index) => (
<OtpField.Input key={index} aria-label={index === 0 ? undefined : `Character ${index + 1} of ${LENGTH}`} />
))}
</OtpField.Root>
</div>
);
}
On a surface
The field sits on whatever surface hosts it. On an elevated card the focus ring, the firmed filled cells, and the native accent caret all read clearly against the raised background.
"use client";
import { OtpField, Surface } from "@stridge/noctis";
import { Fragment } from "react";
const LENGTH = 6;
const SPLIT = LENGTH / 2;
export default function OtpFieldOnSurface() {
// On an elevated card the field reads against a raised surface — the place reviewers can see the
// focus ring, the firmed filled cells, and the native accent caret most clearly.
return (
<Surface
elevation="elevated"
bordered
shadow="card"
className="flex w-full max-w-sm flex-col items-center gap-2 rounded-lg p-4"
>
<label htmlFor="otp-surface" className="text-small text-muted">
Verification code
</label>
<OtpField.Root id="otp-surface" length={LENGTH} defaultValue="123">
{Array.from({ length: LENGTH }, (_, index) => (
<Fragment key={index}>
{index === SPLIT ? <OtpField.Separator /> : null}
<OtpField.Input aria-label={index === 0 ? undefined : `Digit ${index + 1} of ${LENGTH}`} />
</Fragment>
))}
</OtpField.Root>
</Surface>
);
}
Keyboard
The row behaves as one field. Movement is RTL-aware — in a right-to-left layout the arrow keys mirror.
| Key | Action |
|---|---|
| Characters | Fill the active cell and advance to the next. |
| Backspace | Clear the active cell, or move to the previous cell when empty. |
| ← / → | Move to the previous / next cell (mirrored under RTL). |
| Home / End | Jump to the first / last cell. |
| Paste | Fill the whole field from the clipboard at once. |
Accessibility
- Name the group once. A single
<label htmlFor>on the root id (orField.Label) names the field; Base UI wires the first cell to it. Do not also label the first cell — give cells 2…n anaria-labelthat includes the total, likeDigit 3 of 6, so a screen-reader user always knows where they are. - Instruct with
aria-describedby. Point it at a short instruction ("Enter the 6-digit code we sent to your phone"). Inside aField.Root,Field.Descriptiondoes this for you. - Announce errors. Report the field invalid (or wrap in
Field.Rootand useField.Error) so each cell getsdata-invalidand the message is announced. - One field, not many. Keep the cells inside one
OtpFieldso paste, cut, select-all, and one-time-code autofill keep working — the accessible recommendation for split code inputs. - The caret is real. Each cell is a genuine
<input>, so the active empty cell shows the browser's own blinking caret (tinted to the accent); it honours the user's system caret and reduced-motion settings with no extra markup.
Anatomy
Compose the field from its parts. OtpField.Root owns the value (controlled via value / onValueChange, or uncontrolled via defaultValue), the length, and the shared size.
OtpField.Root— the container. Props:length(required),size(defaultmd),mask,autoSubmit,validationType(defaultnumeric),inputMode,autoComplete(defaultone-time-code),normalizeValue,required,readOnly,disabled, plus the Base UI value props (value,defaultValue,onValueChange,onValueComplete,onValueInvalid). Give it anidand pair it with a<label htmlFor>.OtpField.Input— one character cell. Renderlengthof them inside the root; Base UI manages focus, typing, and paste across the row.OtpField.Separator— the divider between cell groups. Render it between two runs of cells to teach the123-456grouping;orientationflows through to Base UI.
Every part carries a data-slot (otp-field, otp-field-input, otp-field-separator) for host-side styling — pair it with the Base UI state attributes (data-complete, data-filled, data-focused, data-disabled, data-readonly, data-invalid). The row is fully keyboard-operable and RTL-aware.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every OTP field in that region retunes — e.g. .narrow { --noctis-otp-field-input-inline-size: var(--noctis-size-control-sm); } slims the cells from their default square. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; Input and Separator list just the props they forward to Base UI.