OTP Input
A monochrome one-time-code card topped with a dashed grid whose cells quietly shuffle digits, a wrong code shakes red.
Enter the code
We sent a code to your email, enter it to verify your address.
A monochrome one-time-code card topped with a dashed grid whose cells quietly shuffle digits on their own offbeat timers, the even dashes marching along each line. Type a digit and it resolves into its well: a soft scale and blur crossfade, no drop. The active well wears a neutral ring and a blinking caret. Fill the last slot and the row sweeps verified; a wrong code shakes and clears, red being the one color the design earns, because a wrong code is the one moment color carries meaning. Built with Motion.
Just start typing, or paste a code, and on mobile the SMS autofill suggestion
drops straight in. One hidden input drives the whole row, so the keyboard,
paste, and one-time-code autofill all work.
Installation
Complete the shared Setup first, then copy the component into
components/ui/otp-input.tsx.
bun add motion'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;export type OtpStatus = 'idle' | 'success' | 'error';export type OtpInputProps = { length?: number; value?: string; defaultValue?: string; onChange?: (value: string) => void; onComplete?: (value: string) => void; status?: OtpStatus; accent?: string; title?: string; description?: string; disabled?: boolean; autoFocus?: boolean; 'aria-label'?: string; className?: string;};export function OtpInput({ length = 6, value: controlled, defaultValue = '', onChange, onComplete, status = 'idle', accent = '#a1a1aa', title = 'Enter the code', description, disabled = false, autoFocus = false, 'aria-label': ariaLabel = 'Verification code', className,}: OtpInputProps) { const reduce = useReducedMotion(); const inputRef = React.useRef<HTMLInputElement>(null); const sanitize = React.useCallback( (raw: string) => raw.replace(/\D/g, '').slice(0, length), [length], ); const isControlled = controlled !== undefined; const [uncontrolled, setUncontrolled] = React.useState(() => sanitize(defaultValue), ); const value = isControlled ? sanitize(controlled as string) : uncontrolled; const [focused, setFocused] = React.useState(false); const [shakeKey, setShakeKey] = React.useState(0); React.useEffect(() => { if (status === 'error') setShakeKey((k) => k + 1); }, [status]); const pinCaret = React.useCallback(() => { const el = inputRef.current; if (!el) return; const end = el.value.length; requestAnimationFrame(() => el.setSelectionRange(end, end)); }, []); const handleChange = (raw: string) => { const next = sanitize(raw); if (!isControlled) setUncontrolled(next); onChange?.(next); if (next.length === length) onComplete?.(next); pinCaret(); }; const focus = () => inputRef.current?.focus(); const activeIndex = value.length; const success = status === 'success'; const error = status === 'error'; const ring = error ? '#f0463a' : accent; return ( <div style={{ '--otp-ring': ring } as React.CSSProperties} className={cn( 'bloom-edge relative w-[300px] overflow-hidden rounded-[14px] px-6 pb-6 pt-0', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_14px_36px_-12px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_18px_44px_-14px_rgba(0,0,0,0.7)]', disabled && 'opacity-60', className, )} > <NumberGrid reduce={!!reduce} /> <div className="relative -mt-5 text-center"> <h3 className="text-[15px] font-semibold tracking-[-0.02em]">{title}</h3> {description && ( <p className="mx-auto mt-1.5 max-w-[240px] text-[12.5px] font-medium leading-snug tracking-[-0.01em] text-neutral-500 dark:text-[#929292]"> {description} </p> )} </div> <div className="relative mt-6"> <input ref={inputRef} value={value} onChange={(e) => handleChange(e.target.value)} onFocus={() => { setFocused(true); pinCaret(); }} onBlur={() => setFocused(false)} onClick={pinCaret} disabled={disabled} autoFocus={autoFocus} inputMode="numeric" autoComplete="one-time-code" pattern="\d*" maxLength={length} aria-label={ariaLabel} className="absolute inset-0 z-10 w-full cursor-text bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed" /> <motion.div key={shakeKey} animate={ error && !reduce ? { x: [0, -7, 7, -5, 5, -3, 3, 0] } : { x: 0 } } transition={{ duration: 0.4, ease: EASE_OUT }} className="flex justify-center gap-2" > {Array.from({ length }).map((_, i) => { const char = value[i]; const isActive = focused && i === activeIndex && !disabled; return ( <Slot key={i} index={i} char={char} active={isActive} success={success} reduce={!!reduce} onPointerDown={(e) => { e.preventDefault(); focus(); }} /> ); })} </motion.div> </div> </div> );}const COLS = 7;const ROWS = 4;const CELLS = COLS * ROWS;const DASH = '4 4';const base = (o: number | undefined) => o ?? 0.2;const flash = (o: number | undefined) => Math.min(0.85, (o ?? 0.2) * 2.4);function NumberGrid({ reduce }: { reduce: boolean }) { const [digits, setDigits] = React.useState<number[]>([]); const opacity = React.useRef<number[]>([]); React.useEffect(() => { opacity.current = Array.from( { length: CELLS }, () => 0.1 + Math.pow(Math.random(), 1.4) * 0.42, ); setDigits(Array.from({ length: CELLS }, () => Math.floor(Math.random() * 10))); }, []); React.useEffect(() => { if (reduce) return; let timer: ReturnType<typeof setTimeout>; const tick = () => { setDigits((d) => { if (d.length === 0) return d; const next = [...d]; for (let k = 0; k < 2; k++) { const i = Math.floor(Math.random() * next.length); next[i] = Math.floor(Math.random() * 10); } return next; }); timer = setTimeout(tick, 90 + Math.random() * 190); }; timer = setTimeout(tick, 200); return () => clearTimeout(timer); }, [reduce]); const vLines = Array.from({ length: COLS - 1 }, (_, i) => ((i + 1) / COLS) * 100); const hLines = Array.from({ length: ROWS - 1 }, (_, i) => ((i + 1) / ROWS) * 100); return ( <div aria-hidden className="pointer-events-none relative -ml-6 h-44 w-[calc(100%+3rem)] select-none text-neutral-900 dark:text-white [mask-image:linear-gradient(to_bottom,black_0%,black_50%,transparent_92%)]" > <svg className="absolute inset-0 h-full w-full text-neutral-400 dark:text-neutral-600" preserveAspectRatio="none" > {vLines.map((x, i) => ( <line key={`v${i}`} x1={`${x}%`} y1="0" x2={`${x}%`} y2="100%" stroke="currentColor" strokeWidth={1} strokeDasharray={DASH} vectorEffect="non-scaling-stroke" /> ))} {hLines.map((y, i) => ( <line key={`h${i}`} x1="0" y1={`${y}%`} x2="100%" y2={`${y}%`} stroke="currentColor" strokeWidth={1} strokeDasharray={DASH} vectorEffect="non-scaling-stroke" /> ))} </svg> <div className="grid h-full w-full" style={{ gridTemplateColumns: `repeat(${COLS}, 1fr)`, gridTemplateRows: `repeat(${ROWS}, 1fr)`, }} > {digits.map((d, i) => ( <div key={i} className="relative grid place-items-center"> <AnimatePresence mode="popLayout"> <motion.span key={d} initial={ reduce ? { opacity: base(opacity.current[i]) } : { opacity: 0, y: 9, filter: 'blur(3px)' } } animate={ reduce ? { opacity: base(opacity.current[i]) } : { opacity: [0, flash(opacity.current[i]), base(opacity.current[i])], y: [9, 0, 0], filter: ['blur(3px)', 'blur(0px)', 'blur(0px)'], } } exit={ reduce ? { opacity: 0 } : { opacity: 0, y: -9, filter: 'blur(3px)', transition: { duration: 0.16, ease: EASE_OUT } } } transition={{ duration: 0.34, ease: EASE_OUT, times: [0, 0.45, 1] }} className="absolute text-[13px] font-medium tabular-nums" > {d} </motion.span> </AnimatePresence> </div> ))} </div> </div> );}function Slot({ index, char, active, success, reduce, onPointerDown,}: { index: number; char: string | undefined; active: boolean; success: boolean; reduce: boolean; onPointerDown: (e: React.PointerEvent) => void;}) { const filled = char !== undefined; return ( <div onPointerDown={onPointerDown} className={cn( 'bloom-edge relative grid h-12 w-11 place-items-center overflow-hidden rounded-[11px]', 'bg-black/[0.02] dark:bg-black/25', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', 'transition-[box-shadow,background-color] duration-200', success && 'bg-white/[0.02] [box-shadow:0_0_0_1px_rgba(0,0,0,0.2),inset_0_1px_0_0_rgba(255,255,255,0.6)] dark:bg-white/[0.05] dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.3),inset_0_1px_0_0_rgba(255,255,255,0.08)]', active && !success && '[box-shadow:0_0_0_2px_var(--otp-ring),inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:[box-shadow:0_0_0_2px_var(--otp-ring),inset_0_1px_3px_0_rgba(0,0,0,0.5)]', )} style={success && !reduce ? { transitionDelay: `${index * 45}ms` } : undefined} > <AnimatePresence mode="popLayout"> {filled && ( <motion.span key={char} aria-hidden initial={ reduce ? { opacity: 0 } : { opacity: 0, scale: 0.78, filter: 'blur(5px)' } } animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }} exit={ reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.9, filter: 'blur(4px)', transition: { duration: 0.12, ease: EASE_OUT }, } } transition={reduce ? { duration: 0.15 } : { duration: 0.2, ease: EASE_OUT }} className="relative text-sm font-medium tabular-nums tracking-[-0.01em] text-neutral-900 dark:text-white" > {char} </motion.span> )} </AnimatePresence> {active && !filled && ( <motion.span aria-hidden initial={{ opacity: 1 }} animate={reduce ? { opacity: 0.7 } : { opacity: [1, 1, 0, 0] }} transition={ reduce ? { duration: 0.2 } : { duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: 'linear' } } className="h-5 w-px rounded-full bg-[var(--otp-ring)]" /> )} </div> );}Usage
import { OtpInput } from '@/components/ui/otp-input';
export default function Example() {
const [value, setValue] = React.useState('');
return (
<OtpInput
value={value}
onChange={setValue}
onComplete={(code) => console.log(code)}
/>
);
}Examples
Drive status from your check. On "error" the row shakes red, clear the
value after the shake so the user can retry; on "success" it sweeps mint.
Two-factor
Hint: the code is 159847.
Set length for any number of slots and accent for the ring and digit
color.
PIN
Enter your 4-digit PIN.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of digit slots. |
value | string | - | Controlled value (digits only). Pair with onChange. |
defaultValue | string | "" | Uncontrolled starting value. |
onChange | (value: string) => void | - | Fired with the current digit string on every change. |
onComplete | (value: string) => void | - | Fired once the last slot fills, with the full code. |
status | 'idle' | 'success' | 'error' | 'idle' | Drives the accent to mint/red swap and the shake. |
accent | string | "#a1a1aa" | Active ring and filled-digit color. |
disabled | boolean | false | Disables the whole control. |
autoFocus | boolean | false | Auto-focus the field on mount. |
aria-label | string | "Verification code" | Accessible label for the field. |
className | string | - | Forwarded to the wrapper. |