Skip to main content

OTP Input

Capture a short, fixed-length one-time passcode (OTP), for two-factor codes, email verification codes, magic-link codes, and similar flows. Built on top of input-otp, it renders a single hidden input that handles paste, autofill, and IME correctly while displaying each character in its own styled slot.

Pair with Field.* for label/description/error wiring in forms:

Enter the 6-digit code we emailed you.

import { Field } from "@ngrok/mantle/field";import { OtpInput } from "@ngrok/mantle/otp-input"; <Field.Item name="otp">	<Field.Label>Verification code</Field.Label>	<Field.Control>		<OtpInput.Root maxLength={6}>			<OtpInput.Group>				<OtpInput.Slot index={0} />				<OtpInput.Slot index={1} />				<OtpInput.Slot index={2} />				<OtpInput.Slot index={3} />				<OtpInput.Slot index={4} />				<OtpInput.Slot index={5} />			</OtpInput.Group>		</OtpInput.Root>	</Field.Control>	<Field.Description>Enter the 6-digit code we emailed you.</Field.Description></Field.Item>;

Or render the control on its own:

import { OtpInput } from "@ngrok/mantle/otp-input"; <OtpInput.Root aria-label="Verification code" maxLength={6}>	<OtpInput.Group>		<OtpInput.Slot index={0} />		<OtpInput.Slot index={1} />		<OtpInput.Slot index={2} />	</OtpInput.Group>	<OtpInput.Separator />	<OtpInput.Group>		<OtpInput.Slot index={3} />		<OtpInput.Slot index={4} />		<OtpInput.Slot index={5} />	</OtpInput.Group></OtpInput.Root>;

With TanStack Form

Use @tanstack/react-form with zod to validate the hidden OTP input while Field.Control handles label, name, and validation ARIA.

import { Button } from "@ngrok/mantle/button";import { Field, toErrorMessages } from "@ngrok/mantle/field";import { OtpInput, REGEXP_ONLY_DIGITS } from "@ngrok/mantle/otp-input";import { useForm } from "@tanstack/react-form";import { z } from "zod"; export const formSchema = z.object({	code: z.string().length(6, "Enter the 6-digit code."),});function Example() {	const defaultValues = {		code: "",	};	const form = useForm({		defaultValues,		validators: {			onSubmit: formSchema,		},		onSubmit: ({ value }) => {			// Handle form submission here		},	}); 	return (		<form			onSubmit={(event) => {				event.preventDefault();				event.stopPropagation();				void form.handleSubmit();			}}		>			<form.Field name="code">				{(field) => (					<Field.Item name={field.name}>						<Field.Label>Verification code</Field.Label>						<Field.Control>							<OtpInput.Root								value={field.state.value}								onBlur={field.handleBlur}								onChange={field.handleChange}								maxLength={6}								pattern={REGEXP_ONLY_DIGITS}							>								<OtpInput.Group>									<OtpInput.Slot index={0} />									<OtpInput.Slot index={1} />									<OtpInput.Slot index={2} />									<OtpInput.Slot index={3} />									<OtpInput.Slot index={4} />									<OtpInput.Slot index={5} />								</OtpInput.Group>							</OtpInput.Root>						</Field.Control>						<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />					</Field.Item>				)}			</form.Field>			<Button type="submit" appearance="filled" intent="neutral">				Submit			</Button>		</form>	);}

Single group

Render all slots in a single group with no separator.

<OtpInput.Root aria-label="Verification code" maxLength={4}>	<OtpInput.Group>		<OtpInput.Slot index={0} />		<OtpInput.Slot index={1} />		<OtpInput.Slot index={2} />		<OtpInput.Slot index={3} />	</OtpInput.Group></OtpInput.Root>

Digits only

Restrict input to numeric characters using the pattern prop and the REGEXP_ONLY_DIGITS helper.

import { OtpInput, REGEXP_ONLY_DIGITS } from "@ngrok/mantle/otp-input"; <OtpInput.Root aria-label="Verification code" maxLength={6} pattern={REGEXP_ONLY_DIGITS}>	<OtpInput.Group>		<OtpInput.Slot index={0} />		<OtpInput.Slot index={1} />		<OtpInput.Slot index={2} />		<OtpInput.Slot index={3} />		<OtpInput.Slot index={4} />		<OtpInput.Slot index={5} />	</OtpInput.Group></OtpInput.Root>;

Disabled

Pass disabled to the root to disable the entire input.

<OtpInput.Root aria-label="Verification code" maxLength={6} disabled>	<OtpInput.Group>		<OtpInput.Slot index={0} />		<OtpInput.Slot index={1} />		<OtpInput.Slot index={2} />	</OtpInput.Group>	<OtpInput.Separator />	<OtpInput.Group>		<OtpInput.Slot index={3} />		<OtpInput.Slot index={4} />		<OtpInput.Slot index={5} />	</OtpInput.Group></OtpInput.Root>

Validation

Pass validation="error", "success", or "warning" on OtpInput.Root to recolor each group's outer borders and the active focus ring with the matching validation hue. validation="error" also sets aria-invalid on the underlying input so assistive tech announces the failure state.

validation may be a literal value or a function that returns a value. Since the prop also accepts false, form-library state can short-circuit validation styling, e.g. validation={field.state.meta.errors.length > 0 && "error"}.

// "success" and "warning" render the same structure with a different hue.<OtpInput.Root aria-label="Verification code" maxLength={6} validation="error">	<OtpInput.Group>		<OtpInput.Slot index={0} />		<OtpInput.Slot index={1} />		<OtpInput.Slot index={2} />	</OtpInput.Group>	<OtpInput.Separator />	<OtpInput.Group>		<OtpInput.Slot index={3} />		<OtpInput.Slot index={4} />		<OtpInput.Slot index={5} />	</OtpInput.Group></OtpInput.Root>

Composition

Compose the parts of an OtpInput together to build your own:

OtpInput.Root├── OtpInput.Group│   └── OtpInput.Slot├── OtpInput.Separator└── OtpInput.Group    └── OtpInput.Slot

Polymorphism

OtpInput.Group and OtpInput.Separator accept an asChild prop. When true, the part renders its single child instead of its default div, forwarding all class names, data-* attributes, and the ref onto that child. Use this when you need a different underlying element: for example, rendering the separator as a semantic span, or wrapping a group in a styled label.

<OtpInput.Separator asChild>	<span aria-hidden>·</span></OtpInput.Separator>

Accessibility

The hidden <input> carries the value and the focus, so it needs an accessible name. Inside a Field.Item, Field.Label supplies it through the Field.Item id. On its own, pass aria-label to OtpInput.Root. Every example above that renders the control without a Field.Label does so.

Translation

Every OtpInput.Slot carries translate="no", so a browser translation engine skips the character inside it. A translated passcode character is wrong, and the reader types it back anyway.

The attribute also protects the render. Google Translate wraps each text node in a <font>, which detaches the node React holds. The next keystroke then makes React remove a node its parent no longer owns, and the DOM raises NotFoundError. Without the guard, a backspace on a translated page tears down the root.

OtpInput.Slot omits the translate prop from its type, so no call site can turn the guard off. A passcode character is never translatable, unlike Code, which also styles terms that are not code and therefore takes the override.

Browser translation covers what an engine does to a React DOM, and the rule your own components need.

API Reference

OtpInput.Root

The root of the OTP input. Wraps the hidden input that captures keystrokes, paste, and autofill, and exposes per-slot state to descendant OtpInput.Slot parts via context.

All props from the underlying OTPInput component, including standard input props, plus:

PropTypeDefaultDescription
maxLengthnumber—The total number of character slots. Required.
value?string—Controlled value.
onChange?(value: string) => void—Fired when the value changes.
onComplete?(value: string) => void—Fired when the user fills the final slot.
pattern?string—Regex source restricting accepted characters (e.g. REGEXP_ONLY_DIGITS).
textAlign?"left" | "center" | "right""left"Caret alignment within the hidden input.
containerClassName?string—Class applied to the slot container element.
pasteTransformer?(pasted: string) => string—Transform pasted text before it is consumed.
pushPasswordManagerStrategy?"increase-width" | "none"—Strategy to push password-manager badges out of the way.
validation?Validation | () => Validation—Validation state. "error" also sets aria-invalid on the input.

OtpInput.Group

Groups one or more OtpInput.Slot parts into a visually-connected segment with shared rounded corners and joined borders.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseRender as a different element by passing a child.

OtpInput.Slot

A single character slot. Must be rendered inside an OtpInput.Root. Reads its character, active state, and fake caret position from the root via context. Always renders translate="no" — see Translation.

All props from div except translate, plus:

PropTypeDefaultDescription
indexnumber—Zero-based index of the slot. Required. Must be < maxLength.

OtpInput.Separator

A visual separator between two OtpInput.Group segments. Renders a minus icon by default; pass children to override.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseRender as a different element by passing a child.
semantic?booleanfalseIf true, renders with role="separator". Otherwise the separator is decorative and aria-hidden.

Pattern helpers

Re-exported from input-otp for convenience:

ExportPattern
REGEXP_ONLY_DIGITS^\d+$
REGEXP_ONLY_CHARS^[a-zA-Z]+$
REGEXP_ONLY_DIGITS_AND_CHARS^[a-zA-Z0-9]+$