- AccordionNew
- Alert
- Alert DialogNew
- AutocompleteNew
- AvatarNew
- Badge
- Breadcrumb
- Button
- CalendarNew
- CardNew
- Checkbox
- Checkbox GroupNew
- CollapsibleNew
- ComboboxNew
- CommandNew
- Context MenuNew
- Date PickerNew
- DialogNew
- DrawerNew
- EmptyNew
- FieldNew
- FieldsetNew
- FormNew
- FrameNew
- GroupNew
- Input
- Input GroupNew
- KbdNew
- Label
- MenuNew
- MeterNew
- Number Field
- OTP FieldNew
- Pagination
- PopoverNew
- Preview CardNew
- Progress
- Radio Group
- Scroll AreaNew
- Select
- Separator
- SheetNew
- SkeletonNew
- Slider
- Spinner
- StepperNew
- Switch
- Table
- TabsNew
- Textarea
- ToastNew
- ToggleNew
- Toggle GroupNew
- ToolbarNew
- Tooltip
OTP Field
A segmented input for one-time passwords and verification codes.
import { Field, FieldLabel } from "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/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>One-time password</FieldLabel>
<OTPField length={OTP_LENGTH}>
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
</Field>
);
}
Usage
import {
OTPField,
OTPFieldInput,
OTPFieldSeparator,
} from "mako-ui/components/otp-field"<OTPField aria-label="Verification code" length={6}>
<OTPFieldInput />
<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
Each slot is a real <input>; order in the tree must match length on the root.
OTPField
Root component. 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 |
mask | boolean | false | When true, masks entered characters in each slot |
OTPFieldInput
Renders one OTP character 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 |
Label the OTP control on the root (OTPField) with aria-label or a visible <label>/FieldLabel. The first OTPFieldInput should inherit that field label, while subsequent slots can use aria-label (for example, Character 2 of 6).
OTPFieldSeparator
Visual separator between slot groups. Uses the design-system Separator for layout and accessibility.
Examples
Large
import { Field, FieldLabel } from "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/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>One-time password</FieldLabel>
<OTPField length={OTP_LENGTH} size="lg">
{OTP_SLOT_KEYS.map((slotKey, index) => (
<OTPFieldInput
aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
key={slotKey}
/>
))}
</OTPField>
</Field>
);
}
With Separator
import { Field, FieldLabel } from "mako-ui/components/field";
import {
OTPField,
OTPFieldInput,
OTPFieldSeparator,
} from "mako-ui/components/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 (
<Field className="items-center">
<FieldLabel>Verification code</FieldLabel>
<OTPField 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>
</Field>
);
}
With Label
Enter the 4-digit code sent to your email.
import { Field, FieldDescription, FieldLabel } from "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/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 normalization
Set validationType="none" with normalizeValue 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 normalization.
Digits 0-3 only.
"use client";
import { Field, FieldDescription, FieldLabel } from "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/otp-field";
import { useEffect, useRef, useState } from "react";
const OTP_LENGTH = 6;
const OTP_SLOT_KEYS = Array.from(
{ length: OTP_LENGTH },
(_, i) => `otp-slot-${i}`,
);
function normalizeTierCode(value: string) {
return value.replaceAll(/[^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>
);
}
Auto Validation
Enter `123456` to pass validation.
"use client";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/otp-field";
import { useState } from "react";
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 "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/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 { Field, FieldDescription, FieldLabel } from "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/otp-field";
import { cn } from "mako-ui/lib/utils";
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 "mako-ui/components/field";
import { OTPField, OTPFieldInput } from "mako-ui/components/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>
);
}