OTP Input
Installation
npx shadcn@latest add https://uibrik.com/r/otp-input.jsonInstall the packages
npm install figma-squircle motionCopy the source into your project
components/uibrik/otp-input/otp-input.tsx"use client"; import React, { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { getSvgPath } from "figma-squircle"; const LENGTH = 6; // Box geometry in px — w-12 h-16 at the default 16px root. True squircles // (continuous-corner, iOS-style), via figma-squircle: no CSS Houdini paint // worklet, so none of @squircle/tailwindcss's beta cross-browser bugs. const BOX_W = 48; const BOX_H = 64; const CORNER_RADIUS = 18; const CORNER_SMOOTHING = 0.8; // Per-box clip-paths, not one shape for the whole row — a single "merged // pill" shape has to snap into place with no way to transition smoothly // (you can't interpolate between two unrelated SVG paths), which is what // killed the merging animation. Keeping each box in charge of its own // corners means the only thing that ever animates is each box's margin // collapsing to 0 — exactly the motion that reads as "merging" — and the // corners just swap (imperceptibly, while boxes still have a gap open) // the instant the merge starts. const clip = (opts: Parameters<typeof getSvgPath>[0]) => `path('${getSvgPath(opts)}')`; // All four corners squircled — every box while typing. const BOX_CLIP = clip({ width: BOX_W, height: BOX_H, cornerRadius: CORNER_RADIUS, cornerSmoothing: CORNER_SMOOTHING }); // Only the left corners squircled, right corners square — box 1 once merged. const BOX_CLIP_LEFT = clip({ width: BOX_W, height: BOX_H, cornerRadius: 0, cornerSmoothing: CORNER_SMOOTHING, topLeftCornerRadius: CORNER_RADIUS, bottomLeftCornerRadius: CORNER_RADIUS, }); // Only the right corners squircled, left corners square — the last box. const BOX_CLIP_RIGHT = clip({ width: BOX_W, height: BOX_H, cornerRadius: 0, cornerSmoothing: CORNER_SMOOTHING, topRightCornerRadius: CORNER_RADIUS, bottomRightCornerRadius: CORNER_RADIUS, }); // The row's own width shrinks as its gaps collapse into the merged pill. // Pin the heading + row group to the row's widest (unmerged) width so that // shrinking never re-centers the group and drags "Verification Code" along // with it — a layout fix only, nothing about the boxes themselves changes. const ROW_GAP = 6; // was ms-1.5 const MAX_ROW_WIDTH = BOX_W * LENGTH + (LENGTH - 1) * ROW_GAP; // Boxes sit touching (0 gap) in actual layout at all times now — the "gap" // while typing is purely a translateX offset, fanned out symmetrically from // the row's own center. Merging is just that offset animating back to 0, so // every box moves in toward the middle instead of only the right side // sliding into a fixed left-anchored box 1. const boxOffset = (i: number) => ROW_GAP * (i - (LENGTH - 1) / 2); // Both words are exactly LENGTH letters, one per box. const PASS_LETTERS = "PASSED".split(""); const FAIL_LETTERS = "FAILED".split(""); const SCRAMBLE_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*"; const randomChar = () => SCRAMBLE_POOL[Math.floor(Math.random() * SCRAMBLE_POOL.length)]; // A beat after the last digit lands, so it's still readable before the // boxes merge and start decoding. const LAST_DIGIT_DELAY_MS = 220; // Scramble tick rate, and how the per-box lock-in cascades left to right — // box i settles on its real letter at BASE_LOCK_MS + i * LOCK_STAGGER_MS, // like a classic decode reveal. const BASE_LOCK_MS = 380; // Pause once every letter has locked (the pill is already colored by then — // that happens as soon as the first letter locks) before the shake-on-fail // fires and the hold-then-reset countdown starts. const CONFIRM_DELAY_MS = 200; const ENTRANCE_DURATION = 0.22; type Phase = "input" | "scramble" | "result"; type Result = "pass" | "fail" | null; export type OTPInputProps = { /** Heading above the boxes. */ label?: string; /** The six-digit code that passes. */ correctCode?: string; /** Milliseconds between scramble frames. */ scrambleSpeed?: number; /** Milliseconds between each letter locking in. */ lockStagger?: number; /** Milliseconds the result holds before the field resets. */ holdDuration?: number; }; export function OTPInput({ label = "Verification Code", correctCode: CORRECT_CODE = "123456", scrambleSpeed: TICK_MS = 45, lockStagger: LOCK_STAGGER_MS = 90, holdDuration: HOLD_MS = 1300, }: OTPInputProps) { const [digits, setDigits] = useState<string[]>(Array(LENGTH).fill("")); const [phase, setPhase] = useState<Phase>("input"); const [result, setResult] = useState<Result>(null); const [shake, setShake] = useState(false); const [displayChars, setDisplayChars] = useState<string[]>( Array(LENGTH).fill(""), ); const inputs = useRef<(HTMLInputElement | null)[]>([]); const suppressSelectRef = useRef(false); const digitsRef = useRef(digits); const phaseRef = useRef(phase); const pendingRef = useRef<number | undefined>(undefined); const tickRef = useRef<number | undefined>(undefined); const resultTimerRef = useRef<number | undefined>(undefined); const lockedRef = useRef<boolean[]>(Array(LENGTH).fill(false)); const targetRef = useRef<string[]>(PASS_LETTERS); useEffect(() => { digitsRef.current = digits; phaseRef.current = phase; }); // Clears any pending timers if the page unmounts mid-flight. useEffect( () => () => { clearTimeout(pendingRef.current); clearInterval(tickRef.current); clearTimeout(resultTimerRef.current); }, [], ); // Merges the boxes, then decodes: every box scrambles until its turn // comes, then locks onto its letter of PASSED / FAILED and stays there. // The whole pill flashes its color the instant the first letter locks — // not just that one box — then holds a beat once the last one locks too. const startVerification = (code: string) => { targetRef.current = code === CORRECT_CODE ? PASS_LETTERS : FAIL_LETTERS; lockedRef.current = Array(LENGTH).fill(false); // The last box typed into is still focused (and its text selected) from // fillFrom's landing spot — clear that now, before it goes `inert`, so // no stray caret or selection outlives the merge into the reset state. (document.activeElement as HTMLInputElement | null)?.blur(); setPhase("scramble"); setDisplayChars(Array.from({ length: LENGTH }, randomChar)); // startVerification only ever runs from an event handler or a timeout // callback (see fillFrom below), never during render, so this timing // read is safe despite the lint rule's conservative render-purity check. // eslint-disable-next-line react-hooks/purity const start = performance.now(); tickRef.current = window.setInterval(() => { const elapsed = performance.now() - start; let allLocked = true; const next: string[] = []; for (let idx = 0; idx < LENGTH; idx++) { if (lockedRef.current[idx]) { next.push(targetRef.current[idx]); continue; } if (elapsed >= BASE_LOCK_MS + idx * LOCK_STAGGER_MS) { lockedRef.current[idx] = true; next.push(targetRef.current[idx]); // The very first letter locking in is the tell — color the whole // pill right then, not just its own box, and well before the rest // of the word has finished decoding. if (idx === 0) { setResult(targetRef.current === PASS_LETTERS ? "pass" : "fail"); } // "FAI" locked in is the tell for a failed code — shake right // then, not once the whole word has finished decoding. if (idx === 5 && targetRef.current === FAIL_LETTERS) { setShake(true); } } else { next.push(randomChar()); allLocked = false; } } setDisplayChars(next); if (allLocked) { clearInterval(tickRef.current); resultTimerRef.current = window.setTimeout(() => { setPhase("result"); }, CONFIRM_DELAY_MS); } }, TICK_MS); }; const setDigit = (i: number, value: string) => { clearTimeout(pendingRef.current); const next = [...digitsRef.current]; next[i] = value; digitsRef.current = next; setDigits(next); }; // Fills forward from index i, one char per box, and focuses/selects the // box after the last one written — same landing spot as typing by hand, // so the blinking caret always ends up somewhere visible. A short delay // after the code completes keeps the last digit readable before it's // swallowed into the merge. const fillFrom = (i: number, value: string) => { const chars = value.replace(/\D/g, "").split(""); if (!chars.length) return; clearTimeout(pendingRef.current); const next = [...digitsRef.current]; let idx = i; for (const ch of chars) { if (idx > LENGTH - 1) break; next[idx] = ch; idx++; } digitsRef.current = next; setDigits(next); const landed = Math.min(idx, LENGTH - 1); // Landing on a fresh empty box: selecting it is harmless (nothing to // highlight) and useful if it gets typed into next. But once idx runs // past the last box, "landed" is really the box we just filled — select // would just paint its brand-new digit blue, so skip it there. if (idx > LENGTH - 1) suppressSelectRef.current = true; inputs.current[landed]?.focus(); if (phaseRef.current === "input" && next.every(Boolean)) { pendingRef.current = window.setTimeout(() => { if (phaseRef.current === "input" && digitsRef.current.every(Boolean)) { startVerification(digitsRef.current.join("")); } }, LAST_DIGIT_DELAY_MS); } }; const handleChange = (i: number) => (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value.replace(/\D/g, ""); if (!value) { setDigit(i, ""); return; } fillFrom(i, value); }; const handleKeyDown = (i: number) => (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Backspace") { if (digits[i]) { setDigit(i, ""); } else if (i > 0) { setDigit(i - 1, ""); inputs.current[i - 1]?.focus(); } e.preventDefault(); } else if (e.key === "ArrowLeft" && i > 0) { inputs.current[i - 1]?.focus(); e.preventDefault(); } else if (e.key === "ArrowRight" && i < LENGTH - 1) { inputs.current[i + 1]?.focus(); e.preventDefault(); } }; const handlePaste = (i: number) => (e: React.ClipboardEvent<HTMLInputElement>) => { const value = e.clipboardData.getData("text").replace(/\D/g, ""); if (!value) return; e.preventDefault(); fillFrom(i, value); }; // Global capture: digits type into the code even when no box is focused — // e.g. the user starts typing right after the liquid menu closes. A box // is still focused first, so the caret keeps blinking somewhere. Only // live while a code is actually being entered. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (phaseRef.current !== "input") return; if (inputs.current.includes(document.activeElement as HTMLInputElement)) return; const target = e.target as HTMLElement; if (target.closest("input, textarea, [contenteditable]")) return; if (/^[0-9]$/.test(e.key)) { const firstEmpty = digitsRef.current.findIndex((d) => !d); fillFrom(firstEmpty === -1 ? LENGTH - 1 : firstEmpty, e.key); e.preventDefault(); } else if (e.key === "Backspace") { let lastFilled = -1; for (let j = LENGTH - 1; j >= 0; j--) { if (digitsRef.current[j]) { lastFilled = j; break; } } if (lastFilled !== -1) { setDigit(lastFilled, ""); inputs.current[lastFilled]?.focus(); } e.preventDefault(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Hold the result on screen, then clear the code and go back to input. useEffect(() => { if (phase !== "result") return; const reset = window.setTimeout(() => { const empty = Array(LENGTH).fill(""); digitsRef.current = empty; setDigits(empty); setResult(null); setShake(false); setPhase("input"); // The box is still `inert` in the DOM until this state commits, so // focusing has to wait a frame or the browser silently drops it. requestAnimationFrame(() => inputs.current[0]?.focus()); }, HOLD_MS); return () => clearTimeout(reset); }, [phase]); const merged = phase !== "input"; return ( <div className="flex flex-col items-start gap-2 relative" style={{ width: MAX_ROW_WIDTH }}> <h4 className="text-lg font-medium tracking-tight text-zinc-600 dark:text-zinc-300">{label}</h4> <motion.div className="flex items-center" animate={shake ? { x: [0, -8, 8, -6, 6, -3, 3, 0] } : { x: 0 }} transition={{ duration: 0.4, ease: "easeInOut" }} > {digits.map((digit, i) => { const isFirst = i === 0; const isLast = i === LENGTH - 1; const boxClip = !merged ? BOX_CLIP : isFirst ? BOX_CLIP_LEFT : isLast ? BOX_CLIP_RIGHT : undefined; // Color lives on every box (not one shape for the whole row), so // touching boxes with an identical fill read as one seamless // pill once their gaps close — no separate shape needed for that. const bg = result ? result === "pass" ? "bg-green-100 dark:bg-green-900/40" : "bg-red-100 dark:bg-red-900/40" : "bg-neutral-200/50 dark:bg-neutral-700/50"; return ( <div key={i} className={`relative w-12 h-16 transition-[transform,background-color] duration-300 ease-out ${bg}`} style={{ transform: `translateX(${merged ? 0 : boxOffset(i)}px)`, ...(boxClip ? { clipPath: boxClip } : undefined), }} > <input ref={(el) => { inputs.current[i] = el; }} type="text" inputMode="numeric" autoComplete="one-time-code" maxLength={1} value={digit} inert={merged} tabIndex={merged ? -1 : 0} onChange={handleChange(i)} onKeyDown={handleKeyDown(i)} onPaste={handlePaste(i)} onFocus={(e) => { if (suppressSelectRef.current) { suppressSelectRef.current = false; return; } e.target.select(); }} className={`w-full h-full text-center font-mono text-2xl bg-transparent outline-none text-transparent ${ // The last box keeps focus after being typed into (nowhere // left to advance to), so its caret would otherwise sit // there blinking next to the digit for the whole delay // before the merge. Caret stays while it's empty, though. isLast && digit ? "caret-transparent" : "caret-neutral-800 dark:caret-neutral-200" }`} /> {/* The real digit is drawn here instead, so it can slide in from below on entry and slide out above on delete; the input's own text stays invisible but keeps the native caret. */} <AnimatePresence initial={false}> {phase === "input" && digit && ( <motion.span key={digit} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }} className="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-2xl" > {digit} </motion.span> )} </AnimatePresence> {/* Scrambles, then locks onto its own letter of PASSED / FAILED — the scramble IS the reveal, nothing fades in after it. */} <AnimatePresence initial={false}> {merged && ( <motion.span key="merged-display" initial={{ opacity: 0, filter: "blur(6px)", scale: 0.85 }} animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} exit={{ opacity: 0, filter: "blur(6px)", scale: 0.85 }} transition={{ duration: ENTRANCE_DURATION, ease: [0.23, 1, 0.32, 1], }} className={`pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-2xl font-medium transition-colors duration-200 ${ result ? result === "pass" ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400" : "text-zinc-600 dark:text-zinc-300" }`} > {displayChars[i]} </motion.span> )} </AnimatePresence> </div> ); })} </motion.div> </div> ); }
Adds the files to
components/uibrik/otp-input and installs figma-squircle and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/otp-input.Props
OTPInput- label
- Type string
- "Verification Code"
- Heading above the boxes.
- correctCode
- Type string
- "123456"
- The six-digit code that passes.
- scrambleSpeed
- Type number
- 45
- Milliseconds between scramble frames.
- lockStagger
- Type number
- 90
- Milliseconds between letters locking in.
- holdDuration
- Type number
- 1300
- How long the result shows before resetting.