- Accordion
- Alert
- Alert Dialog
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Button
- Calendar
- Card
- Checkbox
- Checkbox Group
- Collapsible
- Combobox
- Command
- Date Picker
- Dialog
- DrawerNew
- Empty
- Field
- Fieldset
- Form
- Frame
- Group
- Input
- Input Group
- Kbd
- Label
- Menu
- Meter
- Number Field
- OTP FieldNew
- Pagination
- Popover
- Preview Card
- Progress
- Radio Group
- Scroll Area
- Select
- Separator
- Sheet
- Skeleton
- Slider
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Toggle Group
- Toolbar
- Tooltip
OTP Field
A segmented input for one-time passwords and verification codes.
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 default function Particle() {
return (
<OTPField aria-label="One-time password" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
);
}
Note: This component wraps Base UI OTP Field (
OTPFieldPreview), which is currently in preview and may change before it becomes stable.
Installation
pnpm dlx shadcn@latest add @coss/otp-field
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>API Reference
This component is built on Base UI OTP Field. Each slot is a real <input>; order in the tree must match length on the root.
OTPField
Root component. Accepts the same props as OTPFieldPreview.Root; className is merged with the default layout styles.
Use length (required) for the number of characters. The previous maxLength prop from the legacy input-otp package is not used.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "default" | "lg" | "default" | Size applied to all slots in the field |
validationType | "numeric" | "alpha" | "alphanumeric" | "none" | numeric | Which characters are accepted; see Base UI OTP Field |
mask | boolean | false | When true, masks entered characters in each slot |
OTPFieldInput
Renders one OTP character input (Base UI OTPFieldPreview.Input). Slots are ordered by DOM order in the tree (no index prop).
| Prop | Type | Description |
|---|---|---|
className | string | Merged with the default slot styles |
placeholder | string | Native placeholder; pair with focus-visible:placeholder:text-transparent if hints should hide when typing |
Add aria-label on each slot (e.g. Character 2 of 6) when you are not wrapping the field in Field with FieldLabel (otherwise association is automatic).
OTPFieldSeparator
Visual separator between slot groups. Uses the design-system Separator inside OTPFieldPreview.Separator for layout and accessibility.
Examples
Large
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 4;
const OTP_SLOT_KEYS = Array.from(
{ length: OTP_LENGTH },
(_, i) => `otp-slot-${i}`,
);
export default function Particle() {
return (
<OTPField aria-label="One-time password" length={OTP_LENGTH} size="lg">
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
);
}
With 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 default function Particle() {
return (
<OTPField aria-label="Verification code" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.slice(0, GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
<OTPFieldSeparator />
{OTP_SLOT_KEYS.slice(GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + GROUP_LENGTH + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
);
}
With Label
Enter the 4-digit code sent to your email.
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 4;
const OTP_SLOT_KEYS = Array.from(
{ length: OTP_LENGTH },
(_, i) => `otp-slot-${i}`,
);
export default function Particle() {
return (
<Field className="items-center">
<FieldLabel>Verification code</FieldLabel>
<OTPField length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
<FieldDescription>
Enter the {OTP_LENGTH}-digit code sent to your email.
</FieldDescription>
</Field>
);
}
Custom sanitization
Set validationType="none" with sanitizeValue when you need to normalize pasted input before it reaches state, or to enforce custom character rules. Use inputMode for the virtual keyboard hint, and onValueInvalid when you want to react to characters that were rejected after sanitization.
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 sanitizeTierCode(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}
sanitizeValue={sanitizeTierCode}
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>
);
}
Auto Validation
Enter `123456` to pass validation.
"use client";
import { useState } from "react";
import {
Field,
FieldDescription,
FieldError,
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 default function Particle() {
const [value, setValue] = useState("");
const [invalid, setInvalid] = useState(false);
const valid = value.length === OTP_LENGTH && value === "123456";
return (
<Field className="items-center">
<FieldLabel>Verification code</FieldLabel>
<OTPField
length={OTP_LENGTH}
onValueChange={(nextValue) => {
setValue(nextValue);
setInvalid(
nextValue.length === OTP_LENGTH ? nextValue !== "123456" : false,
);
}}
value={value}
>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-invalid={invalid || undefined}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
{!valid && !invalid && (
<FieldDescription>Enter `123456` to pass validation.</FieldDescription>
)}
{invalid && <FieldError>Code must be 123456.</FieldError>}
{valid && <FieldDescription>Code verified.</FieldDescription>}
</Field>
);
}
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 default function Particle() {
return (
<Field className="items-center">
<FieldLabel>Recovery code</FieldLabel>
<OTPField length={OTP_LENGTH} validationType="alphanumeric">
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
<FieldDescription>
Accept letters and numbers for backup codes such as{" "}
<code className="font-mono text-foreground">A7C9XZ</code>.
</FieldDescription>
</Field>
);
}
Placeholder hints
Each slot is a real <input>, so placeholder and CSS behave as usual. Hide the placeholder on focus when the active slot should not show a hint.
Placeholder hints stay visible until the focused slot is active.
import { cn } from "@/lib/utils";
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 default function Particle() {
return (
<Field className="items-center">
<FieldLabel>Verification code</FieldLabel>
<OTPField length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
className={cn(
"placeholder:text-muted-foreground focus-visible:placeholder:text-transparent",
)}
placeholder="•"
/>
))}
</OTPField>
<FieldDescription>
Placeholder hints stay visible until the focused slot is active.
</FieldDescription>
</Field>
);
}
Masked entry
Pass mask on the root when the code should be obscured while it is typed (e.g. shared screens).
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 default function Particle() {
return (
<Field className="items-center">
<FieldLabel>Access code</FieldLabel>
<OTPField length={OTP_LENGTH} mask>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
key={slotKey}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField>
<FieldDescription>
Use <code className="font-mono text-foreground">mask</code> to obscure
the code on shared screens.
</FieldDescription>
</Field>
);
}
Changelog
- Apr 14, 2026 —
input-otp.tsxremoved in favor ofotp-field.tsx(@coss/otp-field)