Input Field
A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Six-digit code entry with auto-advance, paste and SMS autofill, error shake and a success cascade.
Browser support · All evergreen browsers. SMS autofill via autocomplete=one-time-code on iOS Safari and Android Chrome.
npx shadcn@latest add https://freedesignmd.com/r/otp-input.json1/**2 * OtpInput — one-time-code entry (default 6 digits) with auto-advance,3 * paste and SMS autofill support, a shake on error and a success cascade.4 *5 * Props: length (default 6), value / defaultValue / onChange(code),6 * onComplete(code), status ("idle" | "verifying" | "error" | "success"),7 * groupSize (visual gap after N digits, default 3), disabled, autoFocus,8 * aria-label, theme, charset ("numeric" | "alphanumeric").9 *10 * Browser support: all evergreen browsers. autocomplete="one-time-code" gives11 * iOS / Android SMS suggestions on the first cell.12 * Accessibility: each cell is a labelled <input> ("Digit 3 of 6"), the group13 * has role="group", errors are announced via a live region. Keys: digits type14 * and advance, Backspace clears then steps back, Arrow/Home/End move, Ctrl/⌘+V15 * pastes the whole code. Reduced motion: no shake or pop, colour change only.16 */17import { useCallback, useEffect, useId, useRef, useState } from "react";18 19export type OtpStatus = "idle" | "verifying" | "error" | "success";20 21export interface OtpInputProps {22 length?: number;23 value?: string;24 defaultValue?: string;25 onChange?: (code: string) => void;26 onComplete?: (code: string) => void;27 status?: OtpStatus;28 groupSize?: number;29 disabled?: boolean;30 autoFocus?: boolean;31 charset?: "numeric" | "alphanumeric";32 theme?: "light" | "dark";33 className?: string;34 "aria-label"?: string;35}36 37export function OtpInput({38 length = 6,39 value,40 defaultValue = "",41 onChange,42 onComplete,43 status = "idle",44 groupSize = 3,45 disabled,46 autoFocus,47 charset = "numeric",48 theme,49 className,50 "aria-label": ariaLabel = "Verification code",51}: OtpInputProps) {52 const uid = useId();53 const [inner, setInner] = useState(defaultValue.slice(0, length));54 const code = (value ?? inner).slice(0, length);55 const cells = useRef<(HTMLInputElement | null)[]>([]);56 const [active, setActive] = useState<number | null>(null);57 const [shakeKey, setShakeKey] = useState(0);58 const locked = disabled || status === "verifying" || status === "success";59 const clean = useCallback((s: string) => (charset === "numeric" ? s.replace(/\D/g, "") : s.replace(/[^a-z0-9]/gi, "").toUpperCase()), [charset]);60 61 const commit = useCallback(62 (next: string) => {63 if (value === undefined) setInner(next);64 onChange?.(next);65 if (next.length === length) onComplete?.(next);66 },67 [length, onChange, onComplete, value],68 );69 70 const focusCell = (i: number) => {71 const c = cells.current[Math.max(0, Math.min(length - 1, i))];72 c?.focus();73 c?.select();74 };75 76 useEffect(() => {77 if (autoFocus) focusCell(0);78 // eslint-disable-next-line react-hooks/exhaustive-deps79 }, []);80 81 useEffect(() => {82 if (status === "error") {83 setShakeKey((k) => k + 1);84 const t = window.setTimeout(() => focusCell(0), 420);85 return () => window.clearTimeout(t);86 }87 // eslint-disable-next-line react-hooks/exhaustive-deps88 }, [status]);89 90 const put = (start: number, raw: string) => {91 const s = clean(raw);92 if (!s) return;93 const arr = code.padEnd(length, " ").split("");94 let i = start;95 for (const ch of s) {96 if (i >= length) break;97 arr[i++] = ch;98 }99 commit(arr.join("").replace(/ +$/, "").replace(/ /g, ""));100 // keep holes impossible: only contiguous codes are stored101 window.requestAnimationFrame(() => focusCell(Math.min(i, length - 1)));102 };103 104 const chars = code.split("");105 106 return (107 <div className={`fd-otp${className ? " " + className : ""}`} data-status={status} data-fd-theme={theme}>108 <style>{css}</style>109 <div key={shakeKey} className="fd-otp-row" role="group" aria-label={ariaLabel} data-shake={status === "error" ? "" : undefined}>110 {Array.from({ length }).map((_, i) => (111 <span key={i} className="fd-otp-slot" data-gap={groupSize && i > 0 && i % groupSize === 0 ? "" : undefined}>112 <input113 ref={(n) => {114 cells.current[i] = n;115 }}116 id={`${uid}-${i}`}117 className="fd-otp-cell"118 type="text"119 inputMode={charset === "numeric" ? "numeric" : "text"}120 pattern={charset === "numeric" ? "[0-9]*" : undefined}121 autoComplete={i === 0 ? "one-time-code" : "off"}122 aria-label={`Digit ${i + 1} of ${length}`}123 aria-invalid={status === "error" || undefined}124 maxLength={length}125 disabled={locked}126 value={chars[i] ?? ""}127 data-filled={chars[i] ? "" : undefined}128 data-active={active === i ? "" : undefined}129 style={{ animationDelay: status === "success" ? `${i * 55}ms` : undefined }}130 onFocus={(e) => {131 setActive(i);132 // never leave a gap: jump to the first empty cell133 const first = Math.min(code.length, length - 1);134 if (i > first) focusCell(first);135 else e.currentTarget.select();136 }}137 onBlur={() => setActive((a) => (a === i ? null : a))}138 onChange={(e) => {139 const v = clean(e.target.value);140 if (!v) {141 commit(code.slice(0, i) + code.slice(i + 1));142 return;143 }144 put(i, v.length > 1 ? v : v.slice(-1));145 }}146 onPaste={(e) => {147 e.preventDefault();148 put(0, e.clipboardData.getData("text"));149 }}150 onKeyDown={(e) => {151 if (e.key === "Backspace") {152 e.preventDefault();153 if (chars[i]) commit(code.slice(0, i) + code.slice(i + 1));154 else if (i > 0) {155 commit(code.slice(0, i - 1) + code.slice(i));156 focusCell(i - 1);157 }158 } else if (e.key === "ArrowLeft") (e.preventDefault(), focusCell(i - 1));159 else if (e.key === "ArrowRight") (e.preventDefault(), focusCell(Math.min(i + 1, code.length)));160 else if (e.key === "Home") (e.preventDefault(), focusCell(0));161 else if (e.key === "End") (e.preventDefault(), focusCell(code.length));162 }}163 />164 {status === "verifying" && <i className="fd-otp-pulse" style={{ animationDelay: `${i * 80}ms` }} aria-hidden="true" />}165 </span>166 ))}167 </div>168 <span className="fd-otp-sr" role="status" aria-live="polite">169 {status === "error" ? "Incorrect code" : status === "success" ? "Code verified" : status === "verifying" ? "Verifying" : ""}170 </span>171 </div>172 );173}174 175const css = `176:where(.fd-otp,.fd-otp *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}177:where(.fd-otp,.fd-otp *)::before,:where(.fd-otp,.fd-otp *)::after{box-sizing:border-box}178:where(.fd-otp input){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0;outline:0}179.fd-otp{180 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;181 --o-bg:#fff;--o-fg:#0B0B0F;--o-line:rgba(11,11,15,.16);--o-line-h:rgba(11,11,15,.3);--o-accent:#007AFF;--o-err:#D70015;--o-ok:#12823F;--o-ok-bg:rgba(31,164,91,.08);--o-err-bg:rgba(215,0,21,.05);182 display:inline-block;max-width:100%;183 font:600 28px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Display","Inter","Segoe UI",Roboto,sans-serif;color:var(--o-fg);184}185[data-fd-theme="dark"] .fd-otp,.fd-otp[data-fd-theme="dark"]{--o-bg:#16161B;--o-fg:#F5F5F7;--o-line:rgba(255,255,255,.16);--o-line-h:rgba(255,255,255,.3);--o-accent:#0A84FF;--o-err:#FF6A60;--o-ok:#5BE382;--o-ok-bg:rgba(48,209,88,.1);--o-err-bg:rgba(255,69,58,.08)}186.fd-otp-row{display:flex;align-items:center;gap:8px}187.fd-otp-slot{position:relative;display:block;flex:1 1 0;min-width:0;width:52px;max-width:56px}188.fd-otp-slot[data-gap]{margin-left:12px}189.fd-otp-cell{display:block;width:100%;height:64px;padding:0;border-radius:14px;text-align:center;background:var(--o-bg);color:var(--o-fg);caret-color:var(--o-accent);190 font-family:inherit;font-size:28px;font-weight:600;line-height:1;letter-spacing:0;font-variant-numeric:tabular-nums;191 box-shadow:0 0 0 1px var(--o-line),0 1px 2px rgba(11,11,15,.04);192 transition:box-shadow var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring),color var(--fd-dur-2) var(--fd-ease-out)}193@media (hover:hover){.fd-otp-cell:hover:not(:disabled):not(:focus){box-shadow:0 0 0 1px var(--o-line-h),0 1px 2px rgba(11,11,15,.04)}}194.fd-otp-cell[data-filled]{box-shadow:0 0 0 1px var(--o-line-h),0 1px 2px rgba(11,11,15,.04)}195.fd-otp-cell:focus{box-shadow:0 0 0 1.5px var(--o-accent),0 0 0 5px color-mix(in srgb,var(--o-accent) 18%,transparent);transform:translateY(-1px)}196.fd-otp-cell[data-filled]:not(:focus){animation:fd-otp-pop var(--fd-dur-3) var(--fd-spring)}197.fd-otp-cell:disabled{cursor:default;opacity:1;-webkit-text-fill-color:currentColor}198.fd-otp[data-status="verifying"] .fd-otp-cell{opacity:.7}199.fd-otp[data-status="error"] .fd-otp-cell{color:var(--o-err);background:var(--o-err-bg);box-shadow:0 0 0 1.5px var(--o-err)}200.fd-otp[data-status="success"] .fd-otp-cell{color:var(--o-ok);background:var(--o-ok-bg);box-shadow:0 0 0 1.5px var(--o-ok);animation:fd-otp-ok 520ms var(--fd-spring) both}201.fd-otp-row[data-shake]{animation:fd-otp-shake 460ms cubic-bezier(.36,.07,.19,.97) both}202.fd-otp-pulse{position:absolute;left:50%;bottom:10px;width:6px;height:6px;margin-left:-3px;border-radius:50%;background:var(--o-accent);opacity:0;animation:fd-otp-dot 900ms ease-in-out infinite}203.fd-otp-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}204@keyframes fd-otp-pop{0%{transform:scale(.92)}100%{transform:scale(1)}}205@keyframes fd-otp-ok{0%{transform:scale(1)}40%{transform:scale(1.08) translateY(-3px)}100%{transform:scale(1)}}206@keyframes fd-otp-shake{10%,90%{transform:translateX(-2px)}20%,80%{transform:translateX(4px)}30%,50%,70%{transform:translateX(-7px)}40%,60%{transform:translateX(7px)}}207@keyframes fd-otp-dot{0%,100%{opacity:0;transform:translateY(2px)}50%{opacity:.9;transform:translateY(-2px)}}208@media (max-width:420px){.fd-otp-row{gap:6px}.fd-otp-slot{width:auto}.fd-otp-cell{height:56px;font-size:24px;border-radius:12px}.fd-otp-slot[data-gap]{margin-left:8px}}209@media (prefers-reduced-motion:reduce){.fd-otp-row[data-shake]{animation:none}.fd-otp-cell,.fd-otp-cell[data-filled]:not(:focus),.fd-otp[data-status="success"] .fd-otp-cell{animation:none;transition:box-shadow 1ms,color 1ms;transform:none}.fd-otp-pulse{animation:none;opacity:.8}}210@media (prefers-contrast:more){.fd-otp-cell{box-shadow:0 0 0 1px currentColor}}211`;212 213/* ───────────────────────────── demo ───────────────────────────── */214 215const DEMO_CODE = "482913";216 217export function Demo() {218 const [theme, setTheme] = useState<"light" | "dark">("light");219 const [code, setCode] = useState("");220 const [status, setStatus] = useState<OtpStatus>("idle");221 const [tries, setTries] = useState(3);222 const [secs, setSecs] = useState(28);223 const timer = useRef(0);224 225 useEffect(() => () => window.clearTimeout(timer.current), []);226 useEffect(() => {227 if (secs <= 0) return;228 const t = window.setTimeout(() => setSecs((s) => s - 1), 1000);229 return () => window.clearTimeout(t);230 }, [secs]);231 232 const verify = (c: string) => {233 setStatus("verifying");234 timer.current = window.setTimeout(() => {235 if (c === DEMO_CODE) setStatus("success");236 else {237 setStatus("error");238 setTries((t) => Math.max(0, t - 1));239 timer.current = window.setTimeout(() => {240 setCode("");241 setStatus("idle");242 }, 900);243 }244 }, 800);245 };246 const reset = () => {247 setCode("");248 setStatus("idle");249 setTries(3);250 setSecs(28);251 };252 253 return (254 <div className="fd-otp-demo" data-fd-theme={theme}>255 <style>{demoCss}</style>256 <header className="fd-otp-demo-bar">257 <span className="fd-otp-demo-brand"><i aria-hidden="true" />Fjord Bank</span>258 <span className="fd-otp-demo-sec"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg>Secure session</span>259 <button type="button" className="fd-otp-demo-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>260 </header>261 262 <main className="fd-otp-demo-main">263 <section className="fd-otp-demo-card" aria-labelledby="fd-otp-h">264 <span className="fd-otp-demo-icon" data-ok={status === "success" || undefined} aria-hidden="true">265 {status === "success" ? (266 <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>267 ) : (268 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2.5" width="12" height="19" rx="3" /><path d="M11 18h2" /></svg>269 )}270 </span>271 <h1 id="fd-otp-h">{status === "success" ? "Transfer approved" : "Confirm your transfer"}</h1>272 <p className="fd-otp-demo-sub">273 {status === "success" ? (274 <>$4,200.00 is on its way to <b>Ines Moreau</b>. Arrives tomorrow.</>275 ) : (276 <>Enter the 6-digit code we texted to <b>•••• ••42</b> to send <b>$4,200.00</b> to Ines Moreau.</>277 )}278 </p>279 280 {status !== "success" ? (281 <>282 <div className="fd-otp-demo-field">283 <OtpInput autoFocus value={code} onChange={setCode} onComplete={verify} status={status} aria-label="6-digit verification code" />284 </div>285 <p className="fd-otp-demo-msg" data-bad={status === "error" || undefined} role="status">286 {status === "error" ? `That code didn’t match. ${tries} attempt${tries === 1 ? "" : "s"} left.` : status === "verifying" ? "Verifying…" : "Codes expire after 10 minutes."}287 </p>288 <div className="fd-otp-demo-resend">289 {secs > 0 ? (290 <span>Resend code in <b>0:{String(secs).padStart(2, "0")}</b></span>291 ) : (292 <button type="button" onClick={() => setSecs(28)}>Resend code</button>293 )}294 <span aria-hidden="true">·</span>295 <button type="button">Call me instead</button>296 </div>297 <p className="fd-otp-demo-hint">Demo code: <code>{DEMO_CODE}</code> — or paste any 6 digits.</p>298 </>299 ) : (300 <button type="button" className="fd-otp-demo-done" onClick={reset}>Make another transfer</button>301 )}302 </section>303 </main>304 </div>305 );306}307 308const demoCss = `309:where(.fd-otp-demo,.fd-otp-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}310:where(.fd-otp-demo,.fd-otp-demo *)::before,:where(.fd-otp-demo,.fd-otp-demo *)::after{box-sizing:border-box}311:where(.fd-otp-demo svg){display:block;flex-shrink:0}312:where(.fd-otp-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}313.fd-otp-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;--d-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;314 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;315 --d-bg:#F2F4F7;--d-card:#fff;--d-ink:#0B0B0F;--d-mut:#6B6B76;--d-line:rgba(11,11,15,.08);--d-accent:#007AFF;--d-navy:#0B2545;316 min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}317.fd-otp-demo[data-fd-theme="dark"]{--d-bg:#07080B;--d-card:#101116;--d-ink:#F5F5F7;--d-mut:#9A9AA6;--d-line:rgba(255,255,255,.09);--d-accent:#0A84FF;--d-navy:#7FB2FF}318.fd-otp-demo-bar{display:flex;align-items:center;gap:16px;height:60px;padding:0 clamp(16px,4vw,40px)}319.fd-otp-demo-brand{display:flex;align-items:center;gap:10px;margin-right:auto;font:700 17px/1 var(--d-disp);letter-spacing:-.03em;color:var(--d-navy)}320.fd-otp-demo-brand i{display:block;width:20px;height:20px;border-radius:50% 50% 50% 4px;background:var(--d-navy)}321.fd-otp-demo-sec{display:flex;align-items:center;gap:6px;font:500 13px/1 var(--d-font);color:var(--d-mut)}322.fd-otp-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}323.fd-otp-demo-theme:hover{color:var(--d-ink)}.fd-otp-demo-theme:focus-visible,.fd-otp-demo-resend button:focus-visible,.fd-otp-demo-done:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px;border-radius:6px}324.fd-otp-demo-main{display:grid;place-items:start center;padding:clamp(24px,8vh,80px) 16px 64px}325.fd-otp-demo-card{display:flex;flex-direction:column;align-items:center;text-align:center;width:100%;max-width:480px;padding:clamp(24px,5vw,44px);border-radius:28px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(0,0,0,.04),0 32px 64px -32px rgba(0,0,0,.25)}326.fd-otp-demo-icon{display:grid;place-items:center;width:56px;height:56px;border-radius:18px;background:color-mix(in srgb,var(--d-accent) 12%,transparent);color:var(--d-accent);transition:background-color var(--fd-dur-2) var(--fd-ease-out),color var(--fd-dur-2) var(--fd-ease-out)}327.fd-otp-demo-icon[data-ok]{background:rgba(48,209,88,.16);color:#1C9A44}328.fd-otp-demo-card h1{margin-top:20px;font:600 24px/1.2 var(--d-disp);letter-spacing:-.02em}329.fd-otp-demo-sub{margin-top:10px;max-width:34ch;font:400 15px/1.5 var(--d-font);color:var(--d-mut)}.fd-otp-demo-sub b{color:var(--d-ink);font-weight:600}330.fd-otp-demo-field{margin-top:28px;max-width:100%}331.fd-otp-demo-msg{min-height:20px;margin-top:16px;font:400 14px/20px var(--d-font);color:var(--d-mut)}332.fd-otp-demo-msg[data-bad]{color:#D70015}.fd-otp-demo[data-fd-theme="dark"] .fd-otp-demo-msg[data-bad]{color:#FF6A60}333.fd-otp-demo-resend{display:flex;align-items:center;gap:10px;margin-top:20px;font:400 14px/1 var(--d-font);color:var(--d-mut)}334.fd-otp-demo-resend b{color:var(--d-ink);font-variant-numeric:tabular-nums}335.fd-otp-demo-resend button{cursor:pointer;font:500 14px/1 var(--d-font);color:var(--d-accent)}336.fd-otp-demo-hint{margin-top:28px;padding-top:20px;border-top:1px solid var(--d-line);width:100%;font:400 13px/1.4 var(--d-font);color:var(--d-mut)}337.fd-otp-demo-hint code{font:500 12.5px/1 var(--d-mono);padding:3px 6px;border-radius:6px;background:var(--d-line);color:var(--d-ink)}338.fd-otp-demo-done{margin-top:28px;cursor:pointer;height:44px;padding:0 22px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 15px/1 var(--d-font)}339`;A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Card entry with brand detection, live formatting, Luhn validation and a preview that flips to the CVC.
A three-step onboarding flow with progress, validation, animated transitions and a review step.