OTP Input
One box per digit for a verification code, with paste and SMS autofill support and a shake for a wrong code.
Try 2 4 6 8 1 0.
"use client";
import { useState } from "react";
import { Button } from "@/components/motion/button";
import { OtpInput } from "@/components/motion/otp-input";
const CORRECT_CODE = "246810";
const MIN_DELAY_MS = 2000;
const MAX_DELAY_MS = 4000;
/** Stands in for a network round trip: waits 2 to 4 seconds, then checks the code. */
function fakeVerify(code: string): Promise<boolean> {
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
return new Promise((resolve) => setTimeout(() => resolve(code === CORRECT_CODE), delay));
}
export function OtpInputPreview() {
const [key, setKey] = useState(0);
return (
<div className="flex flex-col items-center gap-4">
<p className="text-xs text-muted-foreground">Try 2 4 6 8 1 0.</p>
<OtpInput key={key} onVerify={fakeVerify} />
<Button variant="secondary" size="sm" onClick={() => setKey((k) => k + 1)}>
Reset
</Button>
</div>
);
}
"use client";
// easeui.dev/components/motion/otp-input
import { Check, Loader2 } from "lucide-react";
import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react";
import { EASE_OUT_CSS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface OtpInputProps {
/** How many digits. Default 6. */
length?: number;
/** Controlled value. */
value?: string;
/** Starting value when uncontrolled. Default "". */
defaultValue?: string;
onChange?: (value: string) => void;
/** Called once every box has a digit, before onVerify runs. */
onComplete?: (value: string) => void;
/**
* Runs once every box has a digit. Return (or resolve) true or false and the
* input shows a spinner below the boxes while it waits, then colors itself
* green or red from the result on its own. Leave it out to keep driving
* invalid/success yourself.
*/
onVerify?: (value: string) => boolean | Promise<boolean>;
/** Shakes the boxes once and switches the ring to the destructive color. Ignored while onVerify is set. */
invalid?: boolean;
/** Switches the ring to the success color and shows a check below the boxes. Ignored while onVerify is set. */
success?: boolean;
disabled?: boolean;
/** Name for a hidden input, so the code submits with a plain HTML form. */
name?: string;
className?: string;
}
// A gentle nudge, not a rattle.
const SHAKE: Keyframe[] = [
{ transform: "translateX(0)" },
{ transform: "translateX(-5px)" },
{ transform: "translateX(4px)" },
{ transform: "translateX(-2px)" },
{ transform: "translateX(0)" },
];
const POP: Keyframe[] = [
{ transform: "scale(0.9)", opacity: 0.6 },
{ transform: "scale(1)", opacity: 1 },
];
// Spinner and check share a slot, so success morphs in place.
const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none";
const SHOWN = "scale-100 opacity-100";
const HIDDEN = "scale-50 opacity-0";
function prefersReducedMotion() {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/**
* A verification code as one box per digit. Typing advances to the next box,
* Backspace steps back through empty ones, and pasting or an SMS autofill
* spreads its digits across the remaining boxes. Marking it invalid gives the
* row a short, gentle shake. Pass onVerify and the input runs the whole
* check-and-color cycle itself, morphing its own spinner into a check.
*/
export function OtpInput({
length = 6,
value,
defaultValue = "",
onChange,
onComplete,
onVerify,
invalid = false,
success = false,
disabled = false,
name,
className,
}: OtpInputProps) {
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const isControlled = value !== undefined;
const digits = (isControlled ? value : uncontrolled).slice(0, length);
const refs = useRef<(HTMLInputElement | null)[]>([]);
const rootRef = useRef<HTMLFieldSetElement>(null);
const statusRef = useRef<HTMLSpanElement>(null);
const prevDigits = useRef(digits);
const wasComplete = useRef(false);
const verifyToken = useRef(0);
// Auto status when onVerify is set; otherwise the props drive it.
const [status, setStatus] = useState<"idle" | "checking" | "success" | "invalid">("idle");
const checking = Boolean(onVerify) && status === "checking";
const effectiveInvalid = onVerify ? status === "invalid" : invalid;
const effectiveSuccess = onVerify ? status === "success" : success;
const showStatus = checking || effectiveSuccess;
const setValue = (next: string) => {
const clipped = next.slice(0, length);
if (!isControlled) setUncontrolled(clipped);
onChange?.(clipped);
// Editing after a result starts the next attempt fresh.
if (onVerify && status !== "idle") setStatus("idle");
const complete = clipped.length === length;
if (complete && !wasComplete.current) {
onComplete?.(clipped);
if (onVerify) {
verifyToken.current += 1;
const token = verifyToken.current;
setStatus("checking");
Promise.resolve(onVerify(clipped))
.then((ok) => {
if (verifyToken.current === token) setStatus(ok ? "success" : "invalid");
})
.catch(() => {
if (verifyToken.current === token) setStatus("invalid");
});
}
}
wasComplete.current = complete;
};
// Pop new digits, shake the row when invalid.
useEffect(() => {
if (prefersReducedMotion()) {
prevDigits.current = digits;
return;
}
const prev = prevDigits.current;
prevDigits.current = digits;
for (let index = 0; index < length; index += 1) {
if (digits[index] && digits[index] !== prev[index]) {
refs.current[index]?.animate(POP, { duration: 140, easing: EASE_OUT_CSS });
}
}
}, [digits, length]);
useEffect(() => {
const root = rootRef.current;
if (!effectiveInvalid || !root || prefersReducedMotion()) return;
// Wait for the last pop to settle first, so the two don't overlap.
root.animate(SHAKE, { duration: 260, delay: 80, easing: "ease-out" });
}, [effectiveInvalid]);
// Fades the status row in once, on its first appearance only.
useEffect(() => {
const el = statusRef.current;
if (!showStatus || !el || prefersReducedMotion()) return;
el.animate([{ opacity: 0, transform: "translateY(4px)" }, { opacity: 1, transform: "translateY(0)" }], {
duration: 150,
easing: EASE_OUT_CSS,
});
}, [showStatus]);
const focusInput = (index: number) => refs.current[index]?.focus();
const onInputChange = (index: number, raw: string) => {
const chars = raw.replace(/\D/g, "");
if (!chars) {
setValue(digits.slice(0, index) + digits.slice(index + 1));
return;
}
// One key replaces a box; a paste or autofill spreads across several.
const next = (digits.slice(0, index) + chars + digits.slice(index + 1)).slice(0, length);
setValue(next);
focusInput(Math.min(index + chars.length, length - 1));
};
const onKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && !digits[index] && index > 0) {
event.preventDefault();
setValue(digits.slice(0, index - 1) + digits.slice(index));
focusInput(index - 1);
} else if (event.key === "ArrowLeft" && index > 0) {
event.preventDefault();
focusInput(index - 1);
} else if (event.key === "ArrowRight" && index < length - 1) {
event.preventDefault();
focusInput(index + 1);
}
};
const onPaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {
event.preventDefault();
onInputChange(index, event.clipboardData.getData("text"));
};
return (
<div className="flex flex-col items-center gap-3">
<fieldset ref={rootRef} className={cn("m-0 inline-flex gap-2 border-0 p-0", className)}>
<legend className="sr-only">Verification code</legend>
{Array.from({ length }, (_, index) => index).map((index) => (
<input
key={index}
ref={(el) => {
refs.current[index] = el;
}}
type="text"
inputMode="numeric"
autoComplete={index === 0 ? "one-time-code" : "off"}
maxLength={length}
disabled={disabled || checking}
aria-invalid={effectiveInvalid}
value={digits[index] ?? ""}
onChange={(event) => onInputChange(index, event.target.value)}
onKeyDown={(event) => onKeyDown(index, event)}
onPaste={(event) => onPaste(index, event)}
onFocus={(event) => event.target.select()}
className={cn(
"h-12 w-10 rounded-xl bg-card text-center text-lg font-medium text-foreground shadow-[0_0_0_1px_var(--border-strong)] outline-none transition-shadow duration-150 ease-out",
"focus:shadow-[0_0_0_2px_var(--accent)]",
effectiveSuccess
? "shadow-[0_0_0_2px_var(--success)]"
: "aria-invalid:shadow-[0_0_0_2px_var(--destructive)]",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
))}
{name ? <input type="hidden" name={name} value={digits} /> : null}
</fieldset>
{showStatus ? (
<span ref={statusRef} role="status" className="inline-flex items-center gap-2 text-xs font-medium">
<span aria-hidden="true" className="grid h-3.5 w-3.5 place-items-center">
<Loader2
className={cn(SWAP, "h-3.5 w-3.5 animate-spin text-muted-foreground", checking ? SHOWN : HIDDEN)}
/>
<Check
strokeWidth={3}
className={cn(SWAP, "h-3.5 w-3.5 text-success", checking ? HIDDEN : SHOWN)}
/>
</span>
<span aria-hidden="true" className="grid">
<span className={cn(SWAP, "text-muted-foreground", checking ? SHOWN : HIDDEN)}>Verifying</span>
<span className={cn(SWAP, "text-success", checking ? HIDDEN : SHOWN)}>Verified</span>
</span>
<span className="sr-only">{checking ? "Verifying" : "Verified"}</span>
</span>
) : null}
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/otp-input
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/otp-input.tsx "use client"; // easeui.dev/components/motion/otp-input import { Check, Loader2 } from "lucide-react"; import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; import { EASE_OUT_CSS } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface OtpInputProps { /** How many digits. Default 6. */ length?: number; /** Controlled value. */ value?: string; /** Starting value when uncontrolled. Default "". */ defaultValue?: string; onChange?: (value: string) => void; /** Called once every box has a digit, before onVerify runs. */ onComplete?: (value: string) => void; /** * Runs once every box has a digit. Return (or resolve) true or false and the * input shows a spinner below the boxes while it waits, then colors itself * green or red from the result on its own. Leave it out to keep driving * invalid/success yourself. */ onVerify?: (value: string) => boolean | Promise<boolean>; /** Shakes the boxes once and switches the ring to the destructive color. Ignored while onVerify is set. */ invalid?: boolean; /** Switches the ring to the success color and shows a check below the boxes. Ignored while onVerify is set. */ success?: boolean; disabled?: boolean; /** Name for a hidden input, so the code submits with a plain HTML form. */ name?: string; className?: string; } // A gentle nudge, not a rattle. const SHAKE: Keyframe[] = [ { transform: "translateX(0)" }, { transform: "translateX(-5px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "translateX(0)" }, ]; const POP: Keyframe[] = [ { transform: "scale(0.9)", opacity: 0.6 }, { transform: "scale(1)", opacity: 1 }, ]; // Spinner and check share a slot, so success morphs in place. const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none"; const SHOWN = "scale-100 opacity-100"; const HIDDEN = "scale-50 opacity-0"; function prefersReducedMotion() { return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } /** * A verification code as one box per digit. Typing advances to the next box, * Backspace steps back through empty ones, and pasting or an SMS autofill * spreads its digits across the remaining boxes. Marking it invalid gives the * row a short, gentle shake. Pass onVerify and the input runs the whole * check-and-color cycle itself, morphing its own spinner into a check. */ export function OtpInput({ length = 6, value, defaultValue = "", onChange, onComplete, onVerify, invalid = false, success = false, disabled = false, name, className, }: OtpInputProps) { const [uncontrolled, setUncontrolled] = useState(defaultValue); const isControlled = value !== undefined; const digits = (isControlled ? value : uncontrolled).slice(0, length); const refs = useRef<(HTMLInputElement | null)[]>([]); const rootRef = useRef<HTMLFieldSetElement>(null); const statusRef = useRef<HTMLSpanElement>(null); const prevDigits = useRef(digits); const wasComplete = useRef(false); const verifyToken = useRef(0); // Auto status when onVerify is set; otherwise the props drive it. const [status, setStatus] = useState<"idle" | "checking" | "success" | "invalid">("idle"); const checking = Boolean(onVerify) && status === "checking"; const effectiveInvalid = onVerify ? status === "invalid" : invalid; const effectiveSuccess = onVerify ? status === "success" : success; const showStatus = checking || effectiveSuccess; const setValue = (next: string) => { const clipped = next.slice(0, length); if (!isControlled) setUncontrolled(clipped); onChange?.(clipped); // Editing after a result starts the next attempt fresh. if (onVerify && status !== "idle") setStatus("idle"); const complete = clipped.length === length; if (complete && !wasComplete.current) { onComplete?.(clipped); if (onVerify) { verifyToken.current += 1; const token = verifyToken.current; setStatus("checking"); Promise.resolve(onVerify(clipped)) .then((ok) => { if (verifyToken.current === token) setStatus(ok ? "success" : "invalid"); }) .catch(() => { if (verifyToken.current === token) setStatus("invalid"); }); } } wasComplete.current = complete; }; // Pop new digits, shake the row when invalid. useEffect(() => { if (prefersReducedMotion()) { prevDigits.current = digits; return; } const prev = prevDigits.current; prevDigits.current = digits; for (let index = 0; index < length; index += 1) { if (digits[index] && digits[index] !== prev[index]) { refs.current[index]?.animate(POP, { duration: 140, easing: EASE_OUT_CSS }); } } }, [digits, length]); useEffect(() => { const root = rootRef.current; if (!effectiveInvalid || !root || prefersReducedMotion()) return; // Wait for the last pop to settle first, so the two don't overlap. root.animate(SHAKE, { duration: 260, delay: 80, easing: "ease-out" }); }, [effectiveInvalid]); // Fades the status row in once, on its first appearance only. useEffect(() => { const el = statusRef.current; if (!showStatus || !el || prefersReducedMotion()) return; el.animate([{ opacity: 0, transform: "translateY(4px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 150, easing: EASE_OUT_CSS, }); }, [showStatus]); const focusInput = (index: number) => refs.current[index]?.focus(); const onInputChange = (index: number, raw: string) => { const chars = raw.replace(/\D/g, ""); if (!chars) { setValue(digits.slice(0, index) + digits.slice(index + 1)); return; } // One key replaces a box; a paste or autofill spreads across several. const next = (digits.slice(0, index) + chars + digits.slice(index + 1)).slice(0, length); setValue(next); focusInput(Math.min(index + chars.length, length - 1)); }; const onKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Backspace" && !digits[index] && index > 0) { event.preventDefault(); setValue(digits.slice(0, index - 1) + digits.slice(index)); focusInput(index - 1); } else if (event.key === "ArrowLeft" && index > 0) { event.preventDefault(); focusInput(index - 1); } else if (event.key === "ArrowRight" && index < length - 1) { event.preventDefault(); focusInput(index + 1); } }; const onPaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => { event.preventDefault(); onInputChange(index, event.clipboardData.getData("text")); }; return ( <div className="flex flex-col items-center gap-3"> <fieldset ref={rootRef} className={cn("m-0 inline-flex gap-2 border-0 p-0", className)}> <legend className="sr-only">Verification code</legend> {Array.from({ length }, (_, index) => index).map((index) => ( <input key={index} ref={(el) => { refs.current[index] = el; }} type="text" inputMode="numeric" autoComplete={index === 0 ? "one-time-code" : "off"} maxLength={length} disabled={disabled || checking} aria-invalid={effectiveInvalid} value={digits[index] ?? ""} onChange={(event) => onInputChange(index, event.target.value)} onKeyDown={(event) => onKeyDown(index, event)} onPaste={(event) => onPaste(index, event)} onFocus={(event) => event.target.select()} className={cn( "h-12 w-10 rounded-xl bg-card text-center text-lg font-medium text-foreground shadow-[0_0_0_1px_var(--border-strong)] outline-none transition-shadow duration-150 ease-out", "focus:shadow-[0_0_0_2px_var(--accent)]", effectiveSuccess ? "shadow-[0_0_0_2px_var(--success)]" : "aria-invalid:shadow-[0_0_0_2px_var(--destructive)]", "disabled:cursor-not-allowed disabled:opacity-50", )} /> ))} {name ? <input type="hidden" name={name} value={digits} /> : null} </fieldset> {showStatus ? ( <span ref={statusRef} role="status" className="inline-flex items-center gap-2 text-xs font-medium"> <span aria-hidden="true" className="grid h-3.5 w-3.5 place-items-center"> <Loader2 className={cn(SWAP, "h-3.5 w-3.5 animate-spin text-muted-foreground", checking ? SHOWN : HIDDEN)} /> <Check strokeWidth={3} className={cn(SWAP, "h-3.5 w-3.5 text-success", checking ? HIDDEN : SHOWN)} /> </span> <span aria-hidden="true" className="grid"> <span className={cn(SWAP, "text-muted-foreground", checking ? SHOWN : HIDDEN)}>Verifying</span> <span className={cn(SWAP, "text-success", checking ? HIDDEN : SHOWN)}>Verified</span> </span> <span className="sr-only">{checking ? "Verifying" : "Verified"}</span> </span> ) : null} </div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| length? | number | 6 | How many digits. Default 6. |
| value? | string | - | Controlled value. |
| defaultValue? | string | Starting value when uncontrolled. Default "". | |
| onChange? | ((value: string) => void) | - | - |
| onComplete? | ((value: string) => void) | - | Called once every box has a digit, before onVerify runs. |
| onVerify? | ((value: string) => any) | - | Runs once every box has a digit. Return (or resolve) true or false and the input shows a spinner below the boxes while it waits, then colors itself green or red from the result on its own. Leave it out to keep driving invalid/success yourself. |
| invalid? | boolean | false | Shakes the boxes once and switches the ring to the destructive color. Ignored while onVerify is set. |
| success? | boolean | false | Switches the ring to the success color and shows a check below the boxes. Ignored while onVerify is set. |
| disabled? | boolean | false | - |
| name? | string | - | Name for a hidden input, so the code submits with a plain HTML form. |
| className? | string | - | - |
Updated