Input OTP
One-time-password / verification-code entry built on the input-otp library. Renders N character slots with active/caret state and auto-advance on type.
6-digit code (with separator)
Enter your one-time password.
4-digit PIN
Disabled
Installation
pnpm dlx @hex-core/cli add input-otpWith error state
Verification status surfaced via a small state machine. On submit, the code is validated; an invalid result paints each slot with a destructive ring and surfaces an inline error message that screen readers announce.
import { useState } from "react";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Button } from "@/components/ui/button";
type Verified = "idle" | "valid" | "invalid";
const EXPECTED = "123456";
export function VerifyOtp() {
const [value, setValue] = useState("");
const [verified, setVerified] = useState<Verified>("idle");
const isInvalid = verified === "invalid";
return (
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
setVerified(value === EXPECTED ? "valid" : "invalid");
}}
>
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
value={value}
onChange={(next) => {
setValue(next);
if (isInvalid) setVerified("idle");
}}
aria-invalid={isInvalid}
aria-describedby={isInvalid ? "otp-error" : undefined}
>
<InputOTPGroup>
{Array.from({ length: 6 }, (_, index) => (
<InputOTPSlot
key={index}
index={index}
className={isInvalid ? "border-destructive ring-destructive/50" : undefined}
/>
))}
</InputOTPGroup>
</InputOTP>
{verified === "invalid" && (
<p id="otp-error" role="alert" className="text-sm font-medium text-destructive">
That code is incorrect. Try again or request a new one.
</p>
)}
{verified === "valid" && (
<p className="text-sm font-medium text-emerald-600">Verified — redirecting…</p>
)}
<Button type="submit" disabled={value.length !== 6} className="justify-self-start">
Verify
</Button>
</form>
);
}Auto-submit on completion
Canonical 2FA UX — fire the verify call as soon as the user finishes typing the last digit using InputOTP's first-class onComplete prop. A small status row keeps the user informed during the network round-trip.
import { useState } from "react";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
type Status = "idle" | "verifying" | "success" | "error";
const MAX_LENGTH = 6;
export function AutoSubmitOtp() {
const [value, setValue] = useState("");
const [status, setStatus] = useState<Status>("idle");
return (
<div className="grid gap-2">
<InputOTP
maxLength={MAX_LENGTH}
pattern={REGEXP_ONLY_DIGITS}
value={value}
onChange={setValue}
onComplete={async (code) => {
setStatus("verifying");
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
body: JSON.stringify({ code }),
});
setStatus(res.ok ? "success" : "error");
} catch {
setStatus("error");
}
}}
disabled={status === "verifying" || status === "success"}
>
<InputOTPGroup>
{Array.from({ length: MAX_LENGTH }, (_, index) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
</InputOTP>
<p className="text-sm text-muted-foreground" aria-live="polite">
{status === "verifying" && "Verifying…"}
{status === "success" && "Code accepted."}
{status === "error" && "That code didn't work. Try again."}
{status === "idle" && "Enter the 6-digit code from your authenticator."}
</p>
</div>
);
}API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
maxLengthrequired | number | — | Total number of slots (typically 4–8 for OTPs) |
value | string | — | Controlled value — the current entered string |
onChange | function | — | Callback fired as the user types: (value: string) => void |
onComplete | function | — | Called when all slots are filled (useful to auto-submit) |
pattern | string | — | Regex to restrict input (use REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS, or REGEXP_ONLY_DIGITS_AND_CHARS) |
disabled | boolean | false | Disable the whole input |
AI Guidance
When to use
Use for one-time password, email verification code, 2FA code, or any fixed-length code entry. Auto-advances on type, supports paste of the full code, and supports regex validation.
When not to use
Don't use for variable-length codes (use a plain Input). Don't use for passwords (use Input type='password'). Don't use for open-ended short text — the slot UI implies a code.
Common mistakes
- Forgetting to render maxLength slots — the underlying input's maxLength won't match the visible UI
- Using pattern without importing one of the REGEXP_ONLY_* constants from 'input-otp'
- Wrapping the whole thing in a <form> without a submit handler — onComplete is often a better auto-submit hook
- Overriding slot className in a way that removes the first/last border-radius rules
Accessibility
input-otp manages a single hidden <input> so screen readers hear one field of N characters. Each slot is a visual representation. The active slot gets a focus ring via the ring token.
Token budget: 700
Verified against @hex-core/components@1.12.0