Input OTP
A one-time-code input with auto-advance, paste support, keyboard navigation and mask mode.
Installation
- $npx litefy@latest add input-otp
- $pnpm dlx litefy@latest add input-otp
- $yarn dlx litefy@latest add input-otp
- $bun --bun litefy@latest add input-otp
Usage
An OTP input with auto-advance, backspace navigation, arrow keys and clipboard paste.
"use client";import { InputOtp } from "@/ui";export default function InputOtpBasicDemo() { return <InputOtp length={6} aria-label="Verification code" />;}Disabled
disabled disables every slot — typing, paste and focus are all blocked while the current value stays visible.
"use client";
import { InputOtp } from "@/ui";
export default function InputOtpDisabledDemo() {
return (
<InputOtp
length={6}
defaultValue="123456"
disabled
aria-label="Disabled verification code"
/>
);
}
Invalid State
Set invalid to mark the group and every slot — the danger border applies automatically; validate on complete and clear once corrected.
"use client";import { useState } from "react";import { InputOtp } from "@/ui";const EXPECTED = "520520";export default function InputOtpInvalidDemo() { const [value, setValue] = useState(""); const [invalid, setInvalid] = useState(false); return ( <div className="flex max-w-md flex-col gap-3"> <InputOtp length={6} value={value} invalid={invalid} aria-label="Verification code" onValueChange={(next) => { setValue(next); setInvalid(next.length === 6 && next !== EXPECTED); }} /> {invalid && ( <span className="text-sm text-danger" role="alert"> Wrong code — the expected one is {EXPECTED} </span> )} </div> );}Mask
Render slots as password inputs for PIN-style codes.
"use client";import { InputOtp } from "@/ui";export default function InputOtpMaskDemo() { return ( <div className="flex flex-col items-start gap-3"> <InputOtp length={6} mask aria-label="Pin code" /> </div> );}Custom
Assemble the parts directly for layouts the high-level component can't express; value state, focus movement and paste handling are wired by the consumer.
Value: -
"use client";
import { Fragment, useRef, useState } from "react";
import { InputOtpGroup, InputOtpSlot } from "@/ui";
const LENGTH = 6;
export default function Demo() {
const [value, setValue] = useState("");
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const chars = Array.from({ length: LENGTH }, (_, i) => value[i] ?? "");
const focusInput = (index: number) => {
const input = inputsRef.current[Math.min(LENGTH - 1, Math.max(0, index))];
input?.focus();
input?.select();
};
const setChar = (index: number, char: string) => {
const next = [...chars];
next[index] = char;
setValue(next.join(""));
return next;
};
return (
<div className="flex flex-col items-start gap-3">
<div className="flex items-center gap-2">
{[0, 1].map((group) => (
<Fragment key={group}>
{group === 1 && <span className="text-sm text-muted-foreground">-</span>}
<InputOtpGroup>
{chars.slice(group * 3, group * 3 + 3).map((char, i) => {
const index = group * 3 + i;
return (
<InputOtpSlot
key={index}
ref={(el) => {
inputsRef.current[index] = el;
}}
value={char}
inputMode="numeric"
aria-label={`Digit ${index + 1}`}
className="size-10 rounded-full"
onChange={(e) => {
const next = setChar(index, e.target.value.slice(-1));
if (next[index]) focusInput(index + 1);
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
focusInput(index - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
focusInput(index + 1);
} else if (e.key === "Backspace" && !chars[index] && index > 0) {
e.preventDefault();
setChar(index - 1, "");
focusInput(index - 1);
}
}}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData("text").trim();
if (!text) return;
const next = [...chars];
for (let i = 0; i < text.length && index + i < LENGTH; i++) {
next[index + i] = text[i];
}
setValue(next.join(""));
focusInput(index + text.length);
}}
onFocus={(e) => e.target.select()}
/>
);
})}
</InputOtpGroup>
</Fragment>
))}
</div>
<p className="text-sm text-muted-foreground">Value: {value || "-"}</p>
</div>
);
}
API Reference
High-level Components
InputOtp
An OTP input built with the parts below, encapsulating value state and interaction logic.
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of slots |
value | string | - | Current value (controlled) |
defaultValue | string | "" | Initial value (uncontrolled) |
onValueChange | (value: string) => void | - | Called when the value changes |
disabled | boolean | false | Disables all slots |
invalid | boolean | - | Sets data-invalid on the group and aria-invalid on each slot |
mask | boolean | false | Renders slots as password inputs |
name | string | - | Renders a hidden input for form submission |
aria-label | string | - | Accessible label, suffixed with the slot index per slot |
className | ClassNameValue | - | Custom class for the group |
style | React.CSSProperties | - | Inline styles for the group |
classNames | { slot? } | - | Custom classes for the slot |
styles | { slot? } | - | Custom inline styles for the slot |
Composable Components
Pure display parts with no built-in state or interaction, for advanced custom assembly.
InputOtpGroup
The outermost container, rendered as a div with built-in flex items-center gap-2.
| Prop | Type | Default | Description |
|---|---|---|---|
className | ClassNameValue | - | Custom class name |
...props | React.ComponentProps<"div"> | - | Supports all native div props (except className) |
InputOtpSlot
A single slot rendered as an <input>; value, events and aria attributes are wired by the consumer.
| Prop | Type | Default | Description |
|---|---|---|---|
className | ClassNameValue | - | Custom class name |
ref | React.Ref<HTMLInputElement> | - | Forwarded to the native input element |
...props | React.ComponentProps<"input"> | - | Supports all native input props (except className and ref) |