Overview
A production-ready one-time passcode field: per-digit slots, shared-layout focus ring, full-code paste / autofill, arrow + backspace navigation, and an invalid state that shakes then briefly scrambles digits.
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 useRef,8 useState,9} from "react";10import { motion, useReducedMotion } from "motion/react";11import { cn } from "@/lib/utils";12 13export type OtpFieldProps = {14 length?: number;15 value?: string;16 defaultValue?: string;17 onChange?: (value: string) => void;18 onComplete?: (value: string) => void;19 /** Triggers error shake + optional scramble */20 invalid?: boolean;21 onInvalidChange?: (invalid: boolean) => void;22 disabled?: boolean;23 autoFocus?: boolean;24 className?: string;25 "aria-label"?: string;26};27 28const SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };29 30function onlyDigits(s: string) {31 return s.replace(/\D/g, "");32}33 34function readMs(name: string, fallback: number) {35 const v = parseFloat(36 getComputedStyle(document.documentElement).getPropertyValue(name),37 );38 return Number.isFinite(v) ? v : fallback;39}40 41export function OtpField({42 length = 6,43 value: valueProp,44 defaultValue = "",45 onChange,46 onComplete,47 invalid = false,48 onInvalidChange,49 disabled,50 autoFocus,51 className,52 "aria-label": ariaLabel = "One-time passcode",53}: OtpFieldProps) {54 const reduce = useReducedMotion();55 const baseId = useId();56 const isControlled = valueProp !== undefined;57 const [uncontrolled, setUncontrolled] = useState(58 onlyDigits(defaultValue).slice(0, length),59 );60 const value = (isControlled ? valueProp : uncontrolled).slice(0, length);61 const inputRefs = useRef<(HTMLInputElement | null)[]>([]);62 const wrapRef = useRef<HTMLDivElement>(null);63 const trackRef = useRef<HTMLDivElement>(null);64 const revertTimer = useRef<number | null>(null);65 const [focusIndex, setFocusIndex] = useState(-1);66 const [displayOverride, setDisplayOverride] = useState<string | null>(null);67 const [error, setError] = useState(false);68 const [shaking, setShaking] = useState(false);69 70 const shown = displayOverride ?? value;71 const digits = Array.from({ length }, (_, i) => shown[i] ?? "");72 73 const setValue = useCallback(74 (next: string) => {75 const cleaned = onlyDigits(next).slice(0, length);76 if (!isControlled) setUncontrolled(cleaned);77 onChange?.(cleaned);78 if (cleaned.length === length) onComplete?.(cleaned);79 },80 [isControlled, length, onChange, onComplete],81 );82 83 const clearError = useCallback(() => {84 if (revertTimer.current) {85 window.clearTimeout(revertTimer.current);86 revertTimer.current = null;87 }88 setError(false);89 setShaking(false);90 wrapRef.current?.classList.remove("is-error");91 trackRef.current?.classList.remove("is-error", "is-shaking");92 }, []);93 94 useEffect(() => {95 if (!invalid) return;96 if (reduce) {97 onInvalidChange?.(false);98 return;99 }100 101 const wrap = wrapRef.current;102 const track = trackRef.current;103 if (!wrap || !track) {104 onInvalidChange?.(false);105 return;106 }107 108 wrap.classList.add("is-error");109 track.classList.add("is-error");110 setError(true);111 112 track.classList.remove("is-shaking");113 void track.offsetWidth;114 track.classList.add("is-shaking");115 setShaking(true);116 117 const shakeMs =118 readMs("--shake-dur-a", 80) * 2 + readMs("--shake-dur-b", 60) * 2;119 const shakeClear = window.setTimeout(() => {120 track.classList.remove("is-shaking");121 setShaking(false);122 }, shakeMs + 20);123 124 if (value.length === length) {125 const scramble = Array.from({ length }, () =>126 String(Math.floor(Math.random() * 10)),127 ).join("");128 setDisplayOverride(scramble);129 window.setTimeout(() => setDisplayOverride(null), shakeMs);130 }131 132 if (revertTimer.current) window.clearTimeout(revertTimer.current);133 const hold = readMs("--revert-hold", 3000);134 revertTimer.current = window.setTimeout(() => {135 revertTimer.current = null;136 wrap.classList.remove("is-error");137 track.classList.remove("is-error");138 setError(false);139 onInvalidChange?.(false);140 }, shakeMs + hold);141 142 // Parent often toggles `invalid` as a pulse — clear the prop flag so it can retrigger143 onInvalidChange?.(false);144 145 return () => {146 window.clearTimeout(shakeClear);147 };148 }, [invalid, reduce, value, length, onInvalidChange]);149 150 useEffect(() => {151 if (!autoFocus) return;152 const el = inputRefs.current[0];153 if (!el) return;154 const rect = el.getBoundingClientRect();155 const inView =156 rect.top >= 0 &&157 rect.bottom <=158 (window.innerHeight || document.documentElement.clientHeight);159 if (inView) {160 el.focus({ preventScroll: true });161 setFocusIndex(0);162 }163 }, [autoFocus]);164 165 const focusAt = (index: number) => {166 const clamped = Math.min(Math.max(index, 0), length - 1);167 setFocusIndex(clamped);168 inputRefs.current[clamped]?.focus();169 inputRefs.current[clamped]?.select();170 };171 172 const writeAt = (index: number, char: string) => {173 const chars = Array.from({ length }, (_, i) => value[i] ?? "");174 chars[index] = char.slice(0, 1);175 const cleaned = onlyDigits(chars.join(""));176 setValue(cleaned);177 if (char && index < length - 1) focusAt(index + 1);178 };179 180 const onPaste = (e: React.ClipboardEvent) => {181 e.preventDefault();182 const pasted = onlyDigits(e.clipboardData.getData("text")).slice(0, length);183 if (!pasted) return;184 setValue(pasted);185 focusAt(Math.min(pasted.length, length - 1));186 };187 188 return (189 <div190 ref={wrapRef}191 className={cn("t-input-wrap inline-flex flex-col gap-1", className)}192 >193 <div194 ref={trackRef}195 id={`${baseId}-track`}196 role="group"197 aria-label={ariaLabel}198 className={cn(199 "t-input inline-flex gap-2 rounded-md",200 error && "is-error",201 shaking && "is-shaking",202 )}203 >204 {digits.map((digit, index) => {205 const filled = digit !== "";206 const focused = focusIndex === index;207 return (208 <div key={index} className="relative">209 {focused && !disabled && (210 <motion.span211 layoutId={`${baseId}-focus`}212 className={cn(213 "pointer-events-none absolute inset-0 rounded-md",214 "ring-2 ring-(--color-focus) ring-offset-2 ring-offset-(--color-paper)",215 )}216 transition={reduce ? { duration: 0.12 } : SPRING}217 />218 )}219 <input220 ref={(el) => {221 inputRefs.current[index] = el;222 }}223 id={`${baseId}-${index}`}224 type="text"225 inputMode="numeric"226 autoComplete={index === 0 ? "one-time-code" : "off"}227 pattern="[0-9]*"228 maxLength={1}229 disabled={disabled}230 value={digit}231 aria-label={`Digit ${index + 1} of ${length}`}232 aria-invalid={error || undefined}233 onFocus={() => setFocusIndex(index)}234 onBlur={(e) => {235 const next = e.relatedTarget as Node | null;236 if (237 !e.currentTarget.parentElement?.parentElement?.contains(238 next,239 )240 ) {241 setFocusIndex(-1);242 }243 }}244 onPaste={onPaste}245 onKeyDown={(e) => {246 if (e.key === "Backspace") {247 e.preventDefault();248 if (digits[index]) {249 writeAt(index, "");250 } else if (index > 0) {251 writeAt(index - 1, "");252 focusAt(index - 1);253 }254 return;255 }256 if (e.key === "ArrowLeft") {257 e.preventDefault();258 focusAt(index - 1);259 }260 if (e.key === "ArrowRight") {261 e.preventDefault();262 focusAt(index + 1);263 }264 }}265 onChange={(e) => {266 clearError();267 const raw = onlyDigits(e.target.value);268 if (!raw) {269 writeAt(index, "");270 return;271 }272 if (raw.length > 1) {273 setValue(raw.slice(0, length));274 focusAt(Math.min(raw.length, length - 1));275 return;276 }277 writeAt(index, raw);278 }}279 className={cn(280 "relative z-1 size-11 text-center text-lg font-medium tabular-nums",281 "rounded-md border border-(--color-rule)",282 "bg-(--color-paper) text-(--color-ink)",283 "outline-none",284 "transition-[border-color,background-color,transform] duration-(--dur-micro) ease-out",285 "focus:border-(--color-ink-2)",286 filled && "bg-(--color-paper-2)",287 error && "border-red-600/50",288 "disabled:opacity-50",289 "active:scale-[0.96]",290 )}291 />292 </div>293 );294 })}295 </div>296 <p297 className={cn(298 "t-error-msg m-0 text-xs text-red-600 dark:text-red-400",299 )}300 role="alert"301 >302 Invalid code. Try again.303 </p>304 </div>305 );306}
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. |
| invalid | boolean | No | — | Triggers shake + scramble feedback. |
| autoFocus | boolean | No | — | Focus the first slot on mount. |
Best Practices
- Use
tabular-numsso digits don't shift slot width. - Wire
autoComplete="one-time-code"on the first slot (already set). - Invalid scramble is delight-tier — skip it under reduced motion.
- Clear
invalidviaonInvalidChangeafter the animation so it can re-fire.