OTP Field
A segmented input for one-time passwords and verification codes. Built with Base UI and Tailwind CSS. Copy-paste ready.
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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</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>Examples
Large
Renders the OTP field with larger slot dimensions for touch-friendly or visually prominent verification UIs.
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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
);
}
With Separator
Adds an OTPFieldSeparator between groups of input slots to visually split a code into segments (e.g. 3+3 or 4+4).
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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
<OTPFieldSeparator />
{OTP_SLOT_KEYS.slice(GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + GROUP_LENGTH + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
);
}
With Label
Associates a Label with the OTP field for accessible identification without requiring a Field wrapper.
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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</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}
onValueChange={handleValueChange}
onValueInvalid={handleValueInvalid}
sanitizeValue={sanitizeTierCode}
validationType="none"
>
{OTP_SLOT_KEYS.map((slotKey, index) => {
const showInvalid = activeInvalidIndex === index && invalidPulse > 0;
return (
<OTPFieldInput
aria-invalid={showInvalid || undefined}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
onFocus={() => {
setFocusedIndex(index);
}}
/>
);
})}
</OTPField>
<FieldDescription>Digits 0-3 only.</FieldDescription>
<span aria-live="polite" className="sr-only">
{statusMessage}
</span>
</Field>
);
}
Auto Validation
Automatically validates and submits the code when all slots are filled, without requiring the user to press a button.
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
aria-invalid={invalid || undefined}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
className={cn(
"placeholder:text-muted-foreground focus-visible:placeholder:text-transparent",
)}
key={slotKey}
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
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
<FieldDescription>
Use <code className="font-mono text-foreground">mask</code> to obscure
the code on shared screens.
</FieldDescription>
</Field>
);
}
Email Verification Card
A bordered card with a mail icon header, recipient email display, the OTP slots, and an inline "Resend code" button — ready to drop into an email verification flow.
Check your email
We sent a 6-digit code to jane@example.com
Didn't receive it?
import { MailIcon } from "lucide-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}`,
);
export function Pattern() {
return (
<div className="w-full max-w-xs space-y-4 rounded-xl border p-6">
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
<MailIcon
aria-hidden="true"
className="size-5 text-muted-foreground"
/>
</div>
<h3 className="font-semibold text-sm">Check your email</h3>
<p className="text-muted-foreground text-xs">
We sent a 6-digit code to{" "}
<span className="font-medium text-foreground">jane@example.com</span>
</p>
</div>
<Field className="items-center">
<FieldLabel className="sr-only">Verification code</FieldLabel>
<OTPField aria-label="Email verification code" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
<FieldDescription className="text-center">
Didn't receive it?{" "}
<button
className="font-medium underline-offset-4 hover:underline"
type="button"
>
Resend code
</button>
</FieldDescription>
</Field>
</div>
);
}
2FA Authenticator App
A compact card with an authenticator app icon, contextual description, OTP input with inputMode="numeric", and a "Verify" submit button.
Authenticator app
Enter the 6-digit code shown in your app
import { SmartphoneIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
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 Pattern() {
return (
<div className="w-full max-w-xs space-y-4 rounded-xl border p-5">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<SmartphoneIcon
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
</div>
<div>
<p className="font-medium text-sm">Authenticator app</p>
<p className="text-muted-foreground text-xs">
Enter the 6-digit code shown in your app
</p>
</div>
</div>
<Field className="items-center">
<FieldLabel className="sr-only">Authentication code</FieldLabel>
<OTPField
aria-label="Two-factor authentication code"
inputMode="numeric"
length={OTP_LENGTH}
>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
</Field>
<Button className="w-full" size="sm">
Verify
</Button>
</div>
);
}
8-Character Invite Code
A two-group XXXX-XXXX layout using OTPFieldSeparator with validationType="alphanumeric" — suitable for invite, license, or access codes.
Enter the 8-character invite code in the format XXXX-XXXX.
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field";
import {
OTPField,
OTPFieldInput,
OTPFieldSeparator,
} from "@/components/ui/otp-field";
const OTP_LENGTH = 8;
const GROUP_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>Invite code</FieldLabel>
<OTPField length={OTP_LENGTH} validationType="alphanumeric">
{OTP_SLOT_KEYS.slice(0, GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
<OTPFieldSeparator />
{OTP_SLOT_KEYS.slice(GROUP_LENGTH).map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + GROUP_LENGTH + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
<FieldDescription>
Enter the 8-character invite code in the format{" "}
<code className="font-mono text-foreground">XXXX-XXXX</code>.
</FieldDescription>
</Field>
);
}
Success State
A controlled field that swaps the OTP slots for a green "Identity verified" confirmation once the correct code is entered, and shows a FieldError for wrong attempts.
Enter code 246810 to verify.
"use client";
import { CheckCircle2Icon } from "lucide-react";
import { useState } from "react";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const VALID_CODE = "246810";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from(
{ length: OTP_LENGTH },
(_, i) => `otp-slot-${i}`,
);
export function Pattern() {
const [value, setValue] = useState("");
const isComplete = value.length === OTP_LENGTH;
const isValid = isComplete && value === VALID_CODE;
const isInvalid = isComplete && value !== VALID_CODE;
return (
<Field className="items-center">
<FieldLabel>Verification code</FieldLabel>
{isValid ? (
<div className="flex items-center gap-2 py-1 text-green-600 text-sm">
<CheckCircle2Icon aria-hidden="true" className="size-4" />
Identity verified
</div>
) : (
<OTPField length={OTP_LENGTH} onValueChange={setValue} value={value}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
aria-invalid={isInvalid || undefined}
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
)}
{isInvalid && <FieldError>Incorrect code. Please try again.</FieldError>}
{!isComplete && !isValid && (
<FieldDescription>
Enter code{" "}
<code className="font-mono text-foreground">{VALID_CODE}</code> to
verify.
</FieldDescription>
)}
</Field>
);
}
Resend Countdown Timer
A useEffect-driven countdown shows "Resend code in Xs" while the timer runs, then reveals a clickable "Resend code" link that resets the clock.
Resend code in 30s
"use client";
import { useEffect, useState } from "react";
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field";
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
const OTP_LENGTH = 6;
const RESEND_SECONDS = 30;
const OTP_SLOT_KEYS = Array.from(
{ length: OTP_LENGTH },
(_, i) => `otp-slot-${i}`,
);
export function Pattern() {
const [seconds, setSeconds] = useState(RESEND_SECONDS);
useEffect(() => {
if (seconds <= 0) return;
const id = setTimeout(() => setSeconds((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [seconds]);
return (
<Field className="items-center">
<FieldLabel>Verification code</FieldLabel>
<OTPField aria-label="Verification code" length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
<FieldDescription className="text-center">
{seconds > 0 ? (
<>
Resend code in{" "}
<span className="font-medium text-foreground tabular-nums">
{seconds}s
</span>
</>
) : (
<button
className="font-medium underline-offset-4 hover:underline"
onClick={() => setSeconds(RESEND_SECONDS)}
type="button"
>
Resend code
</button>
)}
</FieldDescription>
</Field>
);
}

