OTP input
One-time code boxes that handle paste and autofill, and shake on a wrong code.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/otp-input.jsonCheck your email
Enter the 6-digit code. Hint: it's 424242.
components/examples/otp-input-demo.tsx
"use client";
import * as React from "react";
import { OtpInput } from "@/components/ui/otp-input";
export default function OtpInputDemo() {
const [code, setCode] = React.useState("");
const [state, setState] = React.useState<"idle" | "error" | "ok">("idle");
return (
<div className="flex flex-col items-center gap-4 text-center">
<div>
<p className="font-medium">Check your email</p>
<p className="text-sm text-muted-foreground">Enter the 6-digit code. Hint: it's 424242.</p>
</div>
<OtpInput
value={code}
onValueChange={(v) => {
setCode(v);
setState("idle");
}}
onComplete={(v) => setState(v === "424242" ? "ok" : "error")}
error={state === "error"}
/>
<p className={state === "error" ? "text-sm text-destructive" : state === "ok" ? "text-sm text-emerald-500" : "h-5 text-sm"}>
{state === "error" ? "That code didn't match." : state === "ok" ? "Verified" : ""}
</p>
</div>
);
}When to use
- Email or SMS verification codes and 2FA.
When not to use
- Passwords or anything longer than ~8 characters.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/otp-input.json1Install the dependencies
npm install motion2Copy the source into your project
components/ui/otp-input.tsx"use client"; import * as React from "react"; import { motion, useAnimate } from "motion/react"; import { cn } from "@/lib/utils"; export interface OtpInputProps { length?: number; value?: string; onValueChange?: (value: string) => void; /** Called once every box is filled. */ onComplete?: (value: string) => void; /** Shakes the boxes and marks them invalid. Change the key to shake again. */ error?: boolean; disabled?: boolean; className?: string; "aria-label"?: string; } export function OtpInput({ length = 6, value, onValueChange, onComplete, error, disabled, className, ...aria }: OtpInputProps) { const [internal, setInternal] = React.useState(""); const code = (value ?? internal).slice(0, length); const [focused, setFocused] = React.useState(false); const inputRef = React.useRef<HTMLInputElement>(null); const [scope, animate] = useAnimate(); React.useEffect(() => { if (error && scope.current) animate(scope.current, { x: [0, -8, 8, -5, 5, 0] }, { duration: 0.4 }); }, [error, animate, scope]); const set = (raw: string) => { const next = raw.replace(/\D/g, "").slice(0, length); if (value === undefined) setInternal(next); onValueChange?.(next); if (next.length === length) onComplete?.(next); }; return ( <div ref={scope} className={cn("relative inline-flex gap-2", className)} onClick={() => inputRef.current?.focus()}> {/* One real input handles typing, paste and autofill; the boxes are visual. */} <input ref={inputRef} value={code} onChange={(e) => set(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} inputMode="numeric" autoComplete="one-time-code" maxLength={length} disabled={disabled} aria-label={aria["aria-label"] ?? "One-time code"} aria-invalid={error || undefined} className="absolute inset-0 z-10 cursor-text opacity-0" /> {Array.from({ length }, (_, i) => { const char = code[i]; const active = focused && (i === code.length || (i === length - 1 && code.length === length)); return ( <div key={i} className={cn( "relative grid h-12 w-10 place-items-center rounded-md border text-lg font-medium tabular-nums transition-colors", error ? "border-destructive" : active ? "border-foreground ring-2 ring-ring" : "border-input", disabled && "opacity-40", )} > {char && ( <motion.span initial={{ y: 8, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ type: "spring", stiffness: 500, damping: 30 }}> {char} </motion.span> )} {active && !char && <span className="h-5 w-px animate-pulse bg-foreground" />} </div> ); })} </div> ); }
Usage
Import it from @/components/ui/otp-input after installing.
example.tsx
import { OtpInput } from "@/components/ui/otp-input";
export function Verify({ onCode, error }: { onCode: (code: string) => void; error: boolean }) {
return <OtpInput onComplete={onCode} error={error} />;
}API reference
OtpInput
Code boxes backed by one real input, so paste and SMS autofill work.
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | Number of boxes. |
| value / onValueChange | string | – | Controlled value; non-digits are stripped. |
| onComplete | (code) => void | – | Called when every box is filled. |
| error | boolean | – | Shakes and marks invalid. |
Keyboard interactions
| Keys | Action |
|---|---|
| 0–9 | Type a digit |
| Backspace | Delete |
| ⌘VorCtrl+V | Paste a whole code |
Accessibility
- One input with inputMode="numeric" and autocomplete="one-time-code".
- aria-invalid on error.
Motion
- The row shakes on error (400ms).
Responsive behavior
- Boxes are 40px; six fit at 320px.
Performance
- No timers.
Notes for AI
- Change the key to reset after an error.
This page as Markdown, for assistants and coding agents:
/components/otp-input/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt