Overview
A one-time passcode field built on a single real input laid over drawn slots, so SMS autofill, paste, password managers and screen readers all see one ordinary text field. Typing into a filled slot replaces just that digit; arrows select one slot at a time; backspace clears and steps back. Pasting strips spaces and dashes. A wrong code shakes, scrambles, shows a message and clears for the next try; a verified code locks in green.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/otp-field.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 useCallback,5 useEffect,6 useId,7 useLayoutEffect,8 useRef,9 useState,10} from "react";11import {12 AnimatePresence,13 motion,14 useAnimate,15 useReducedMotion,16} from "motion/react";17import { cn } from "@/lib/utils";18 19export type OtpFieldProps = {20 length?: number;21 value?: string;22 defaultValue?: string;23 onChange?: (value: string) => void;24 /** Fires when the last slot is filled (and again if a full code is edited). */25 onComplete?: (value: string) => void;26 /** Pulse true to trigger the error: shake, scramble, message. */27 invalid?: boolean;28 onInvalidChange?: (invalid: boolean) => void;29 /** Clear the code after an invalid attempt so the next try starts fresh. */30 clearOnInvalid?: boolean;31 /** Locks the field in a verified state. */32 success?: boolean;33 errorMessage?: string;34 /** Visual gap after every n slots. Defaults to 3 for a 6-digit code. */35 groupSize?: number;36 disabled?: boolean;37 autoFocus?: boolean;38 className?: string;39 "aria-label"?: string;40};41 42const SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };43const EASE_OUT = [0.23, 1, 0.32, 1] as const;44 45function onlyDigits(s: string) {46 return s.replace(/\D/g, "");47}48 49export function OtpField({50 length: lengthProp = 6,51 value: valueProp,52 defaultValue = "",53 onChange,54 onComplete,55 invalid = false,56 onInvalidChange,57 clearOnInvalid = true,58 success = false,59 errorMessage = "Invalid code. Try again.",60 groupSize,61 disabled,62 autoFocus,63 className,64 "aria-label": ariaLabel = "One-time passcode",65}: OtpFieldProps) {66 const length = Number.isFinite(lengthProp)67 ? Math.max(1, Math.floor(lengthProp))68 : 6;69 const group = groupSize ?? (length === 6 ? 3 : 0);70 const reduce = useReducedMotion();71 const baseId = useId();72 const errorId = `${baseId}-error`;73 74 const isControlled = valueProp !== undefined;75 const [uncontrolled, setUncontrolled] = useState(76 onlyDigits(defaultValue).slice(0, length),77 );78 const value = onlyDigits(isControlled ? valueProp : uncontrolled).slice(0, length);79 80 const inputRef = useRef<HTMLInputElement>(null);81 const slotRefs = useRef<(HTMLDivElement | null)[]>([]);82 const [trackRef, animateTrack] = useAnimate<HTMLDivElement>();83 84 const [focused, setFocused] = useState(false);85 const [selection, setSelection] = useState<[number, number]>([0, 0]);86 const [error, setError] = useState(false);87 const [scramble, setScramble] = useState<string | null>(null);88 // Where the caret should land after the next committed value.89 const pendingCaret = useRef<number | null>(null);90 const prevValue = useRef(value);91 92 const callbacks = useRef({ onChange, onComplete, onInvalidChange });93 useEffect(() => {94 callbacks.current = { onChange, onComplete, onInvalidChange };95 }, [onChange, onComplete, onInvalidChange]);96 97 const setValue = useCallback(98 (next: string, caret?: number) => {99 const cleaned = onlyDigits(next).slice(0, length);100 if (caret !== undefined) pendingCaret.current = Math.min(caret, cleaned.length);101 if (!isControlled) setUncontrolled(cleaned);102 callbacks.current.onChange?.(cleaned);103 if (cleaned.length === length && cleaned !== prevValue.current) {104 callbacks.current.onComplete?.(cleaned);105 }106 prevValue.current = cleaned;107 },108 [isControlled, length],109 );110 111 useEffect(() => {112 prevValue.current = value;113 }, [value]);114 115 /* ── Caret model ──────────────────────────────────────── */116 // A filled slot is selected as a single character, so typing overwrites it117 // in place. Past the last digit the caret is collapsed so typing appends.118 const placeCaret = useCallback(119 (index: number) => {120 const input = inputRef.current;121 if (!input) return;122 const len = input.value.length;123 const i = Math.max(0, Math.min(index, len, length - 1));124 if (i < len) input.setSelectionRange(i, i + 1);125 else input.setSelectionRange(len, len);126 setSelection([input.selectionStart ?? i, input.selectionEnd ?? i]);127 },128 [length],129 );130 131 useLayoutEffect(() => {132 if (pendingCaret.current === null) return;133 const caret = pendingCaret.current;134 pendingCaret.current = null;135 if (document.activeElement === inputRef.current) placeCaret(caret);136 }, [value, placeCaret]);137 138 const syncSelection = () => {139 const input = inputRef.current;140 if (!input) return;141 setSelection([input.selectionStart ?? 0, input.selectionEnd ?? 0]);142 };143 144 const activeIndex = !focused145 ? -1146 : Math.min(selection[0], length - 1);147 148 /* ── Invalid: shake, scramble, clear ─────────────────── */149 useEffect(() => {150 if (!invalid) return;151 // Hand the pulse straight back so the parent can fire it again.152 callbacks.current.onInvalidChange?.(false);153 setError(true);154 155 if (reduce) {156 if (clearOnInvalid) setValue("", 0);157 return;158 }159 160 const full = value.length === length;161 const scrambler = full162 ? window.setInterval(() => {163 setScramble(164 Array.from({ length }, () => String(Math.floor(Math.random() * 10))).join(""),165 );166 }, 45)167 : 0;168 169 const shake = animateTrack(170 trackRef.current,171 { x: [0, -9, 8, -6, 4, -2, 0] },172 { duration: 0.42, ease: "easeOut" },173 );174 shake.then(() => {175 window.clearInterval(scrambler);176 setScramble(null);177 if (clearOnInvalid) setValue("", 0);178 });179 180 return () => {181 window.clearInterval(scrambler);182 };183 // eslint-disable-next-line react-hooks/exhaustive-deps184 }, [invalid]);185 186 useEffect(() => {187 if (!autoFocus) return;188 const el = inputRef.current;189 if (!el) return;190 const rect = el.getBoundingClientRect();191 if (rect.top >= 0 && rect.bottom <= window.innerHeight) {192 el.focus({ preventScroll: true });193 }194 }, [autoFocus]);195 196 /* ── Input handlers ───────────────────────────────────── */197 198 const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {199 const input = e.currentTarget;200 const raw = input.value;201 const digits = onlyDigits(raw).slice(0, length);202 // Map the native caret through any characters we stripped.203 const caretRaw = input.selectionStart ?? raw.length;204 const caret = onlyDigits(raw.slice(0, caretRaw)).length;205 setError(false);206 setValue(digits, caret);207 };208 209 const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {210 const input = e.currentTarget;211 const start = input.selectionStart ?? 0;212 const end = input.selectionEnd ?? 0;213 214 if (e.key === "ArrowLeft" || e.key === "ArrowRight") {215 e.preventDefault();216 const step = e.key === "ArrowLeft" ? -1 : 1;217 placeCaret(Math.min(start, length - 1) + step);218 return;219 }220 if (e.key === "Home" || e.key === "End") {221 e.preventDefault();222 placeCaret(e.key === "Home" ? 0 : length);223 return;224 }225 if (e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey) {226 e.preventDefault();227 setError(false);228 if (end - start > 1) {229 setValue(value.slice(0, start) + value.slice(end), start);230 return;231 }232 if (start === end) {233 // Caret after the last digit: remove it.234 if (start === 0) return;235 setValue(value.slice(0, start - 1) + value.slice(start), start - 1);236 return;237 }238 // A slot is selected: clear it and step back.239 setValue(value.slice(0, start) + value.slice(start + 1), Math.max(0, start - 1));240 }241 };242 243 const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {244 e.preventDefault();245 const pasted = onlyDigits(e.clipboardData.getData("text"));246 if (!pasted) return;247 setError(false);248 if (pasted.length >= length) {249 setValue(pasted.slice(0, length), length);250 return;251 }252 const input = e.currentTarget;253 const start = input.selectionStart ?? value.length;254 const end = input.selectionEnd ?? start;255 const next = (value.slice(0, start) + pasted + value.slice(end)).slice(0, length);256 setValue(next, start + pasted.length);257 };258 259 // Clicks land on the transparent input; route them to the slot under the pointer.260 const onPointerUp = (e: React.PointerEvent<HTMLInputElement>) => {261 const x = e.clientX;262 let index = length;263 for (let i = 0; i < length; i++) {264 const rect = slotRefs.current[i]?.getBoundingClientRect();265 if (rect && x <= rect.right + 4) {266 index = i;267 break;268 }269 }270 // Pointer-up runs after the browser has placed its own caret, so this wins.271 placeCaret(index);272 };273 274 const shown = scramble ?? value;275 const locked = disabled || success;276 277 return (278 <div className={cn("inline-flex flex-col items-center gap-2", className)}>279 <motion.div ref={trackRef} className="relative">280 <div className="flex items-center" aria-hidden>281 {Array.from({ length }, (_, index) => {282 const digit = shown[index] ?? "";283 const filled = digit !== "";284 const active = index === activeIndex && !locked;285 const showCaret = active && !filled && selection[0] === selection[1];286 const gapAfter = group > 0 && (index + 1) % group === 0 && index < length - 1;287 288 return (289 <React.Fragment key={index}>290 <motion.div291 ref={(el) => {292 slotRefs.current[index] = el;293 }}294 animate={295 success && !reduce296 ? { y: [0, -4, 0], transition: { delay: index * 0.04, duration: 0.36, ease: EASE_OUT } }297 : undefined298 }299 className={cn(300 "relative grid h-13 w-11 place-items-center rounded-[12px] sm:w-12",301 "border bg-(--color-paper) text-[22px] font-medium tabular-nums",302 "shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(0,0,0,0.04)]",303 "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",304 "transition-[border-color,background-color,color] duration-(--dur-micro) ease-out",305 index > 0 && "ml-2",306 filled ? "border-(--color-ink)/20 text-(--color-ink)" : "border-(--color-rule)",307 error && "border-red-500/60 bg-red-500/[0.04] text-red-600 dark:text-red-400",308 success &&309 "border-emerald-500/60 bg-emerald-500/[0.06] text-emerald-700 dark:text-emerald-400",310 disabled && "opacity-50",311 )}312 >313 {active && (314 <motion.span315 layoutId={`${baseId}-focus`}316 className={cn(317 "pointer-events-none absolute -inset-px rounded-[12px]",318 "ring-2 ring-(--color-ink)/80",319 error && "ring-red-500/80",320 )}321 transition={reduce ? { duration: 0 } : SPRING}322 />323 )}324 325 <AnimatePresence initial={false} mode="popLayout">326 {filled ? (327 <motion.span328 key={scramble ? `s-${index}` : `${index}-${digit}`}329 initial={reduce || scramble ? false : { opacity: 0, y: 6, scale: 0.8, filter: "blur(2px)" }}330 animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}331 exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.8, transition: { duration: 0.1 } }}332 transition={{ duration: 0.2, ease: EASE_OUT }}333 >334 {digit}335 </motion.span>336 ) : null}337 </AnimatePresence>338 339 {showCaret && (340 <motion.span341 animate={reduce ? undefined : { opacity: [1, 1, 0, 0] }}342 transition={{ duration: 1.06, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}343 className="pointer-events-none absolute h-6 w-[1.5px] rounded-full bg-(--color-ink)"344 />345 )}346 </motion.div>347 348 {gapAfter && (349 <span className="ml-2 h-[2px] w-2.5 rounded-full bg-(--color-rule)" />350 )}351 </React.Fragment>352 );353 })}354 </div>355 356 {/* The real field: one input over the slots, so autofill, paste,357 screen readers and mobile keyboards all see a normal text field. */}358 <input359 ref={inputRef}360 type="text"361 inputMode="numeric"362 autoComplete="one-time-code"363 pattern="[0-9]*"364 maxLength={length}365 spellCheck={false}366 data-1p-ignore367 data-lpignore="true"368 aria-label={ariaLabel}369 aria-invalid={error || undefined}370 aria-describedby={error ? errorId : undefined}371 disabled={disabled}372 readOnly={success}373 value={value}374 onChange={onInputChange}375 onKeyDown={onKeyDown}376 onPaste={onPaste}377 onSelect={syncSelection}378 onPointerUp={onPointerUp}379 onFocus={(e) => {380 setFocused(true);381 // Tab focus selects everything; park on the next empty slot instead.382 // Mouse focus is refined again by onPointerUp.383 const input = e.currentTarget;384 placeCaret(input.value.length);385 }}386 onBlur={() => setFocused(false)}387 className={cn(388 // Wider than the track and clipped, so password-manager badges stay hidden.389 "absolute inset-y-0 left-0 w-[calc(100%+40px)] [clip-path:inset(0_40px_0_0)]",390 "bg-transparent text-[16px] text-transparent caret-transparent outline-none",391 // The slots draw the digits. Keep the real text invisible in every392 // state: selection (global ::selection sets a text colour), the393 // WebKit fill colour, and the autofill paint.394 "[-webkit-text-fill-color:transparent] selection:bg-transparent selection:text-transparent",395 "autofill:[-webkit-text-fill-color:transparent] autofill:transition-[background-color] autofill:duration-[99999s]",396 locked ? "cursor-default" : "cursor-text",397 )}398 />399 </motion.div>400 401 <div className="min-h-4 text-center text-[12px]">402 <AnimatePresence initial={false}>403 {error ? (404 <motion.div405 key="error"406 id={errorId}407 role="alert"408 initial={reduce ? false : { opacity: 0, y: -4 }}409 animate={{ opacity: 1, y: 0 }}410 exit={{ opacity: 0, transition: { duration: 0.12 } }}411 transition={{ duration: 0.2, ease: EASE_OUT }}412 className="text-red-600 dark:text-red-400"413 >414 {errorMessage}415 </motion.div>416 ) : null}417 </AnimatePresence>418 </div>419 </div>420 );421}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { OtpField } from "@/components/ui/otp-field";2 3<OtpField4 length={6}5 onComplete={(code) => verify(code)}6 invalid={isWrong}7 onInvalidChange={setIsWrong}8/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| length | number | No | 6 | Number of digit slots. |
| value | string | No | — | Controlled digit string. |
| onChange | (value: string) => void | No | — | Fires on every digit change. |
| onComplete | (value: string) => void | No | — | Fires when all slots are filled. |
| defaultValue | string | No | — | Uncontrolled initial digits. |
| invalid | boolean | No | — | Pulse true to trigger the error: shake, scramble and message. |
| onInvalidChange | (invalid: boolean) => void | No | — | Hands the invalid pulse back (false) so it can fire again. |
| clearOnInvalid | boolean | No | true | Clear the code after an invalid attempt and return to the first slot. |
| errorMessage | string | No | "Invalid code. Try again." | Message announced and shown under the slots on error. |
| success | boolean | No | — | Locks the field in a verified (read-only) state. |
| groupSize | number | No | 3 for 6 digits | Visual divider after every n slots. 0 to disable. |
| disabled | boolean | No | — | Disables input. |
| autoFocus | boolean | No | — | Focus the first slot on mount. |
Best Practices
- Use
tabular-numsso digits don't shift slot width. - Use one real input with
autoComplete="one-time-code"— separate per-digit inputs break SMS autofill on several browsers. - Invalid scramble is delight-tier — skip it under reduced motion, but still show and announce the message.
- Clear
invalidviaonInvalidChangeso it can re-fire. - Clear a wrong code for the user; nobody wants to backspace six digits.