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, animate } 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 34export function OtpField({35 length = 6,36 value: valueProp,37 defaultValue = "",38 onChange,39 onComplete,40 invalid = false,41 onInvalidChange,42 disabled,43 autoFocus,44 className,45 "aria-label": ariaLabel = "One-time passcode",46}: OtpFieldProps) {47 const reduce = useReducedMotion();48 const baseId = useId();49 const isControlled = valueProp !== undefined;50 const [uncontrolled, setUncontrolled] = useState(51 onlyDigits(defaultValue).slice(0, length),52 );53 const value = (isControlled ? valueProp : uncontrolled).slice(0, length);54 const inputRefs = useRef<(HTMLInputElement | null)[]>([]);55 const [shake, setShake] = useState(0);56 const [focusIndex, setFocusIndex] = useState(-1);57 const [displayOverride, setDisplayOverride] = useState<string | null>(null);58 59 const shown = displayOverride ?? value;60 const digits = Array.from({ length }, (_, i) => shown[i] ?? "");61 62 const setValue = useCallback(63 (next: string) => {64 const cleaned = onlyDigits(next).slice(0, length);65 if (!isControlled) setUncontrolled(cleaned);66 onChange?.(cleaned);67 if (cleaned.length === length) onComplete?.(cleaned);68 },69 [isControlled, length, onChange, onComplete],70 );71 72 useEffect(() => {73 if (!invalid) return;74 if (reduce) {75 onInvalidChange?.(false);76 return;77 }78 setShake((n) => n + 1);79 const el = document.getElementById(`${baseId}-track`);80 if (el) {81 animate(82 el,83 { x: [0, -8, 8, -6, 6, -3, 0] },84 { duration: 0.28, ease: [0.16, 1, 0.3, 1] },85 );86 }87 if (value.length === length) {88 const scramble = Array.from({ length }, () =>89 String(Math.floor(Math.random() * 10)),90 ).join("");91 setDisplayOverride(scramble);92 const t = window.setTimeout(() => {93 setDisplayOverride(null);94 onInvalidChange?.(false);95 }, 280);96 return () => window.clearTimeout(t);97 }98 onInvalidChange?.(false);99 }, [invalid, reduce, baseId, value, length, onInvalidChange]);100 101 useEffect(() => {102 if (!autoFocus) return;103 // Don't steal scroll on mount — only focus if already in view104 const el = inputRefs.current[0];105 if (!el) return;106 const rect = el.getBoundingClientRect();107 const inView =108 rect.top >= 0 &&109 rect.bottom <= (window.innerHeight || document.documentElement.clientHeight);110 if (inView) {111 el.focus({ preventScroll: true });112 setFocusIndex(0);113 }114 }, [autoFocus]);115 116 const focusAt = (index: number) => {117 const clamped = Math.min(Math.max(index, 0), length - 1);118 setFocusIndex(clamped);119 inputRefs.current[clamped]?.focus();120 inputRefs.current[clamped]?.select();121 };122 123 const writeAt = (index: number, char: string) => {124 const chars = Array.from({ length }, (_, i) => value[i] ?? "");125 chars[index] = char.slice(0, 1);126 const cleaned = onlyDigits(chars.join(""));127 setValue(cleaned);128 if (char && index < length - 1) focusAt(index + 1);129 };130 131 const onPaste = (e: React.ClipboardEvent) => {132 e.preventDefault();133 const pasted = onlyDigits(e.clipboardData.getData("text")).slice(0, length);134 if (!pasted) return;135 setValue(pasted);136 focusAt(Math.min(pasted.length, length - 1));137 };138 139 return (140 <div141 id={`${baseId}-track`}142 role="group"143 aria-label={ariaLabel}144 className={cn("inline-flex gap-2", className)}145 data-shake={shake}146 >147 {digits.map((digit, index) => {148 const filled = digit !== "";149 const focused = focusIndex === index;150 return (151 <div key={index} className="relative">152 {focused && !disabled && (153 <motion.span154 layoutId={`${baseId}-focus`}155 className={cn(156 "pointer-events-none absolute inset-0 rounded-md",157 "ring-2 ring-(--color-focus) ring-offset-2 ring-offset-(--color-paper)",158 )}159 transition={reduce ? { duration: 0.12 } : SPRING}160 />161 )}162 <input163 ref={(el) => {164 inputRefs.current[index] = el;165 }}166 id={`${baseId}-${index}`}167 type="text"168 inputMode="numeric"169 autoComplete={index === 0 ? "one-time-code" : "off"}170 pattern="[0-9]*"171 maxLength={1}172 disabled={disabled}173 value={digit}174 aria-label={`Digit ${index + 1} of ${length}`}175 onFocus={() => setFocusIndex(index)}176 onBlur={(e) => {177 // Clear ring when focus leaves the whole OTP group178 const next = e.relatedTarget as Node | null;179 if (!e.currentTarget.parentElement?.parentElement?.contains(next)) {180 setFocusIndex(-1);181 }182 }}183 onPaste={onPaste}184 onKeyDown={(e) => {185 if (e.key === "Backspace") {186 e.preventDefault();187 if (digits[index]) {188 writeAt(index, "");189 } else if (index > 0) {190 writeAt(index - 1, "");191 focusAt(index - 1);192 }193 return;194 }195 if (e.key === "ArrowLeft") {196 e.preventDefault();197 focusAt(index - 1);198 }199 if (e.key === "ArrowRight") {200 e.preventDefault();201 focusAt(index + 1);202 }203 }}204 onChange={(e) => {205 const raw = onlyDigits(e.target.value);206 if (!raw) {207 writeAt(index, "");208 return;209 }210 if (raw.length > 1) {211 // Mobile autofill may dump full code into one field212 setValue(raw.slice(0, length));213 focusAt(Math.min(raw.length, length - 1));214 return;215 }216 writeAt(index, raw);217 }}218 className={cn(219 "relative z-1 size-11 text-center text-lg font-medium tabular-nums",220 "rounded-md border border-(--color-rule)",221 "bg-(--color-paper) text-(--color-ink)",222 "outline-none",223 "transition-[border-color,background-color,transform] duration-(--dur-micro) ease-out",224 "focus:border-(--color-ink-2)",225 filled && "bg-(--color-paper-2)",226 invalid && "border-red-600/50",227 "disabled:opacity-50",228 "active:scale-[0.96]",229 )}230 />231 </div>232 );233 })}234 </div>235 );236}
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.