OTP Field
A one-time password input composed of individual character slots.
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<OTPField aria-label="One-time password" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
);
}Installation
import { OTPField as OTPFieldPrimitive } from "@base-ui/react/otp-field";
import { cn } from "@/lib/cn";
import type { Separator } from "@/components/ui/separator";
export function OTPField({
className,
size = "default",
...props
}: React.ComponentProps<typeof OTPFieldPrimitive.Root> & { size?: "default" | "lg" }) {
return (
<OTPFieldPrimitive.Root
data-slot="otp-field"
data-size={size}
className={cn(
"flex items-center gap-2 has-disabled:opacity-50 has-disabled:**:data-[slot=otp-field-input]:shadow-none has-disabled:**:data-[slot=otp-field-input]:before:shadow-none!",
className
)}
{...props}
/>
);
}
export function OTPFieldInput({
className,
...props
}: React.ComponentProps<typeof OTPFieldPrimitive.Input>) {
return (
<OTPFieldPrimitive.Input
data-slot="otp-field-input"
spellCheck={false}
className={cn(
"size-8 rounded-lg border border-input bg-transparent text-center text-foreground text-sm shadow-xs/5 outline-none ring-ring/30 transition-shadow focus-visible:border-ring focus-visible:shadow-none focus-visible:ring-3 focus-visible:ring-ring/30 dark:bg-input/30",
"in-[[data-slot=otp-field][data-size=lg]]:size-10 in-[[data-slot=otp-field][data-size=lg]]:text-base",
"aria-invalid:border-danger aria-invalid:shadow-none aria-invalid:ring-danger/30",
"placeholder:text-muted-foreground focus-visible:placeholder:text-transparent",
"selection:bg-primary/50 selection:text-primary-foreground",
className
)}
{...props}
/>
);
}
export function OTPFieldSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<OTPFieldPrimitive.Separator
data-slot="otp-field-separator"
className={cn(
"rounded-full bg-input data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:w-3",
className
)}
{...props}
/>
);
}
export { OTPFieldPrimitive };Usage
import { OTPField, OTPFieldInput, OTPFieldSeparator } from "@/components/ui/otp-field";<OTPField aria-label="Verification code" length={6}>
<OTPFieldInput aria-label="Character 1 of 6" />
<OTPFieldInput aria-label="Character 2 of 6" />
<OTPFieldInput aria-label="Character 3 of 6" />
<OTPFieldSeparator />
<OTPFieldInput aria-label="Character 4 of 6" />
<OTPFieldInput aria-label="Character 5 of 6" />
<OTPFieldInput aria-label="Character 6 of 6" />
</OTPField>Examples
Sizes
Pass size="lg" to increase the size of the OTP field. The default size is default.
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<OTPField aria-label="One-time password" length={OTP_LENGTH} size="lg">
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
);
}With label
import { Field, FieldLabel } from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<Field className="items-center">
<FieldLabel>Verification Code</FieldLabel>
<OTPField aria-label="One-time password" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
</Field>
);
}Grouped separator
import { OTPField, OTPFieldInput, OTPFieldSeparator } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const GROUP_LENGTH = 3;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<OTPField aria-label="One-time password" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.slice(0, GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
<OTPFieldSeparator />
{OTP_SLOT_KEYS.slice(GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={
index === 0 ? undefined : `Character ${index + 1 + GROUP_LENGTH} of ${OTP_LENGTH}`
}
/>
))}
</OTPField>
);
}Alphanumeric
Use validationType="alphanumeric" for recovery, backup, or invite codes that mix letters and numbers.
Accept letters and numbers for backup codes such as A7C9XZ.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<div>
<Field>
<FieldLabel>Recovery code</FieldLabel>
<OTPField aria-label="Recovery code" length={OTP_LENGTH} validationType="alphanumeric">
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
<FieldDescription>
Accept letters and numbers for backup codes such as A7C9XZ.
</FieldDescription>
</Field>
</div>
);
}Custom normalization
Use normalizeValue to normalize accepted values before state updates, such as converting alphanumeric codes to uppercase. It runs after validationType filtering, and the result is filtered against validationType again. Use validationType="none" when the normalizer should provide the full validation rule.
Pair custom rules with inputMode for keyboard hints and onValueInvalid for rejected characters.
Digits 0-3 only.
"use client";
import { useEffect, useRef, useState } from "react";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
function normalizeTierCode(value: string) {
return value.replace(/[^0-3]/g, "");
}
export default function Particle() {
const [focusedIndex, setFocusedIndex] = useState(0);
const [invalidPulse, setInvalidPulse] = useState(0);
const [statusMessage, setStatusMessage] = useState("");
const invalidTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipClearOnNextValueChangeRef = useRef(false);
useEffect(() => {
return () => {
if (invalidTimeoutRef.current != null) {
clearTimeout(invalidTimeoutRef.current);
}
};
}, []);
function clearInvalidFeedback() {
if (invalidTimeoutRef.current != null) {
clearTimeout(invalidTimeoutRef.current);
invalidTimeoutRef.current = null;
}
setInvalidPulse(0);
setStatusMessage("");
}
function handleValueChange() {
if (skipClearOnNextValueChangeRef.current) {
skipClearOnNextValueChangeRef.current = false;
return;
}
clearInvalidFeedback();
}
function handleValueInvalid(value: string) {
skipClearOnNextValueChangeRef.current = true;
setInvalidPulse((current) => current + 1);
setStatusMessage(`Unsupported characters were ignored from ${value}.`);
if (invalidTimeoutRef.current != null) {
clearTimeout(invalidTimeoutRef.current);
}
invalidTimeoutRef.current = setTimeout(() => {
invalidTimeoutRef.current = null;
setInvalidPulse(0);
}, 400);
}
const activeInvalidIndex = invalidPulse > 0 ? focusedIndex : -1;
return (
<Field className="items-center">
<FieldLabel>Tier code</FieldLabel>
<OTPField
inputMode="numeric"
length={OTP_LENGTH}
normalizeValue={normalizeTierCode}
validationType="none"
onValueChange={handleValueChange}
onValueInvalid={handleValueInvalid}
>
{OTP_SLOT_KEYS.map((slotKey, index) => {
const showInvalid = activeInvalidIndex === index && invalidPulse > 0;
return (
<OTPFieldInput
key={slotKey}
aria-invalid={showInvalid || undefined}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
onFocus={() => {
setFocusedIndex(index);
}}
/>
);
})}
</OTPField>
<FieldDescription>Digits 0-3 only.</FieldDescription>
<span aria-live="polite" className="sr-only">
{statusMessage}
</span>
</Field>
);
}Placeholder
<OTPFieldInput> is a real input, so native placeholder props and CSS work as usual.
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<OTPField aria-label="One-time password" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
placeholder="•"
/>
))}
</OTPField>
);
}Mask entry
Use mask when the code should be obscured while it is being typed.
Use mask to obscure the code on shared screens.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from({ length: OTP_LENGTH }, (_, i) => `otp-slot-${i}`);
export function OTPFieldDemo() {
return (
<div>
<Field>
<FieldLabel>Access code</FieldLabel>
<OTPField aria-label="Access code" length={OTP_LENGTH} mask>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
<FieldDescription>Use mask to obscure the code on shared screens.</FieldDescription>
</Field>
</div>
);
}