OTP Input

Six-digit code entry with auto-advance, paste and SMS autofill, error shake and a success cascade.

  • Forms
  • Banking
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. SMS autofill via autocomplete=one-time-code on iOS Safari and Android Chrome.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/otp-input.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/forms/otp-input.tsx
1/**
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" gives
11 * iOS / Android SMS suggestions on the first cell.
12 * Accessibility: each cell is a labelled <input> ("Digit 3 of 6"), the group
13 * has role="group", errors are announced via a live region. Keys: digits type
14 * and advance, Backspace clears then steps back, Arrow/Home/End move, Ctrl/⌘+V
15 * 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-deps
79 }, []);
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-deps
88 }, [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 stored
101 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 <input
113 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 cell
133 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`;

More Forms. Same file contract.

Input Field

A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.

Payment Form

Card entry with brand detection, live formatting, Luhn validation and a preview that flips to the CVC.

Multi-step Signup

A three-step onboarding flow with progress, validation, animated transitions and a review step.