One-time code
One real input, six drawn boxes. Each digit drops into its box.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// OneTimeCode: one real input, six drawn boxes. Each digit drops into its box. A wrong code flashes red, shakes, and the digits
// fall out. A right one sends a light across the row, then the row folds into a Verified pill.
import { useEffect, useId, useRef, useState, type CSSProperties, type RefObject } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check } from "lucide-react";
export type OneTimeCodeCue = "done" | "tactile-reject" | "tactile-key" | "tactile-push";
export type OneTimeCodeProps = {
/** Number of digits. Even lengths of 6 or more split into two groups. */
length?: number;
/** Return true for a good code. Async is fine; the row waits. Leave out to compare against `expected`. */
verify?: (code: string) => boolean | Promise<boolean>;
/** The code the demo accepts when no `verify` is given (checked after a short fake delay); its digits show faintly in the empty boxes while typing. */
expected?: string;
/** Called with the code once it has been accepted. */
onComplete?: (code: string) => void;
/** Focus the input on mount. */
autoFocus?: boolean;
/** Text of the success pill. */
verifiedLabel?: string;
/** Clicking the Verified pill starts over (handy for demos). */
resettable?: boolean;
/** Accessible name of the input. */
label?: string;
/** Sound hook: "tactile-key" per digit (with `at`) and on delete, "done" on success, "tactile-reject" on a wrong code, "tactile-push" on reset. */
onSound?: (cue: OneTimeCodeCue, opts?: { at?: number }) => void;
className?: string;
style?: CSSProperties;
};
const BOX = 40,
H = 48,
GAP = 6,
SPLIT = 10;
const MORPH = { type: "spring", stiffness: 340, damping: 30, mass: 0.8 } as const;
const cn = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" ");
/** True while the element is near the viewport and the tab is in front. */
function useActive(el: RefObject<Element | null>) {
const [inView, setInView] = useState(false);
const [front, setFront] = useState(true);
useEffect(() => {
const node = el.current;
if (!node || typeof IntersectionObserver === "undefined") return setInView(true);
const obs = new IntersectionObserver((list) => setInView(list[list.length - 1].isIntersecting), { rootMargin: "160px" });
obs.observe(node);
return () => obs.disconnect();
}, [el]);
useEffect(() => {
const sync = () => setFront(!document.hidden);
sync();
document.addEventListener("visibilitychange", sync);
return () => document.removeEventListener("visibilitychange", sync);
}, []);
return inView && front;
}
type Phase = "idle" | "checking" | "wrong" | "right" | "done";
/** A timer scheduler whose pending timeouts are cleared on unmount. */
function useSchedule() {
const pending = useRef(new Set<number>());
useEffect(() => {
const set = pending.current;
return () => set.forEach((id) => clearTimeout(id));
}, []);
return (fn: () => void, ms: number) => {
const id = window.setTimeout(() => {
pending.current.delete(id);
fn();
}, ms);
pending.current.add(id);
};
}
export function OneTimeCode({ length = 6, verify, expected = "424242", onComplete, autoFocus, verifiedLabel = "Verified", resettable = true, label = "One-time code", onSound, className, style }: OneTimeCodeProps) {
const root = useRef<HTMLDivElement>(null);
const active = useActive(root);
const field = useRef<HTMLInputElement>(null);
const fieldId = useId();
const schedule = useSchedule();
const [digits, setDigits] = useState("");
const [phase, setPhase] = useState<Phase>("idle");
const [focused, setFocused] = useState(false);
const lastDelete = useRef(0);
const pill = useRef<HTMLButtonElement>(null);
const gapAt = length % 2 === 0 && length >= 6 ? length / 2 : -1; // where the two groups part
const rowWidth = length * BOX + (length - 1) * GAP + (gapAt > 0 ? SPLIT - GAP : 0);
const folded = phase === "done";
useEffect(() => {
const at = document.activeElement;
if (folded && (!at || at === document.body || root.current?.contains(at))) pill.current?.focus(); // the field unmounts when it folds
}, [folded]);
const typing = focused && phase === "idle";
const judge = async (code: string) => {
setPhase("checking");
const good = verify ? await verify(code) : await new Promise<boolean>((res) => schedule(() => res(code === expected), 500));
if (good) {
onSound?.("done");
setPhase("right");
onComplete?.(code);
schedule(() => setPhase("done"), 760);
return;
}
onSound?.("tactile-reject");
setPhase("wrong");
schedule(() => {
setDigits("");
setPhase("idle");
field.current?.focus();
}, 560);
};
const handleInput = (raw: string) => {
if (phase !== "idle") return;
const next = raw.replace(/\D/g, "").slice(0, length);
if (next.length > digits.length) onSound?.("tactile-key", { at: next.length / length });
if (next.length < digits.length && performance.now() - lastDelete.current > 40) {
lastDelete.current = performance.now();
onSound?.("tactile-key");
}
setDigits(next);
if (next.length === length) void judge(next);
};
const startOver = () => {
if (!resettable) return;
onSound?.("tactile-push");
setDigits("");
setPhase("idle");
schedule(() => field.current?.focus(), 80);
};
const cell = (n: number) => (
<span
key={n}
className="totp-box"
data-live={typing && n === Math.min(digits.length, length - 1) ? "" : undefined}
data-filled={n < digits.length || undefined}
style={{ width: BOX, height: H, marginLeft: n === 0 ? 0 : n === gapAt ? SPLIT : GAP, "--totp-i": n } as CSSProperties}
>
{digits[n] !== undefined ? (
<span className="totp-digit" key={`${n}-${digits[n]}`}>
{digits[n]}
</span>
) : typing && n === digits.length ? (
<span className="totp-caret" aria-hidden />
) : (
typing && !verify && <span className="totp-ph" aria-hidden>{expected[n]}</span>
)}
</span>
);
const fade = { initial: { opacity: 0, scale: 0.94, filter: "blur(5px)" }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }, exit: { opacity: 0, scale: 0.94, filter: "blur(5px)" } };
return (
<div ref={root} className={cn("totp-root", className)} data-state={phase} data-done={folded || undefined} data-off={active ? undefined : ""} style={style}>
<div className="totp-stage">
<motion.div className="totp-frame" initial={false} animate={{ width: folded ? 132 : rowWidth, height: folded ? 40 : H, borderRadius: folded ? 999 : 14 }} transition={MORPH}>
<AnimatePresence mode="popLayout" initial={false}>
{folded ? (
<motion.button ref={pill} key="pill" type="button" className="totp-pill" onClick={startOver} {...fade} exit={{ ...fade.exit, scale: 0.9 }} initial={{ ...fade.initial, scale: 0.9 }} transition={{ duration: 0.22, delay: 0.08 }}>
<span className="totp-check">
<Check size={14} strokeWidth={2.6} />
</span>
{verifiedLabel}
</motion.button>
) : (
<motion.div key="row" className="totp-row" style={{ width: rowWidth, height: H }} {...fade} transition={{ duration: 0.18 }} onClick={() => field.current?.focus()}>
{Array.from({ length }, (_, n) => cell(n))}
<input
ref={field}
id={fieldId}
className="totp-in"
value={digits}
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={length}
aria-label={label}
aria-invalid={phase === "wrong" || undefined}
autoFocus={autoFocus}
disabled={phase === "checking" || phase === "right"}
onChange={(e) => handleInput(e.currentTarget.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSelect={(e) => {
const el = e.currentTarget;
if (el.selectionStart !== el.value.length) el.setSelectionRange(el.value.length, el.value.length);
}}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
);
}
/* OneTimeCode. Prefix totp-. */
.totp-root, .totp-root *, .totp-root *::before, .totp-root *::after { box-sizing: border-box; }
.totp-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.totp-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; }
:where(.totp-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.totp-root {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--ts-fg);
}
.totp-stage {
display: grid;
place-items: center;
height: 56px; /* row + padding: the pill morph happens inside a fixed box */
}
.totp-frame {
position: relative;
display: grid;
place-items: center;
}
.totp-root[data-done] .totp-frame {
overflow: hidden;
}
.totp-row {
position: relative;
display: flex;
align-items: center;
cursor: text;
}
.totp-root[data-state="wrong"] .totp-row {
animation: totp-shake 0.42s cubic-bezier(.22, 1, .36, 1);
}
@keyframes totp-shake {
18% {
translate: -6px 0;
}
42% {
translate: 5px 0;
}
66% {
translate: -3px 0;
}
86% {
translate: 1px 0;
}
}
.totp-box {
--totp-i: 0;
position: relative;
display: grid;
place-items: center;
border-radius: 11px;
background: var(--ts-raised);
box-shadow: var(--ts-edge);
font-size: 20px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
transition:
box-shadow 0.16s cubic-bezier(.22, 1, .36, 1),
background 0.16s cubic-bezier(.22, 1, .36, 1),
scale 0.42s cubic-bezier(.24, 1.34, .38, 1),
translate 0.42s cubic-bezier(.24, 1.34, .38, 1);
}
.totp-box[data-live] {
box-shadow: 0 0 0 1.5px var(--ts-fg);
scale: 1.04;
}
/* the box catches a landing digit: dips, then settles */
.totp-box[data-filled] {
animation: totp-catch 0.42s cubic-bezier(.22, 1, .36, 1);
}
@keyframes totp-catch {
0% {
scale: 1;
}
30% {
scale: 0.94 1.03;
translate: 0 2px;
}
100% {
scale: 1;
translate: 0;
}
}
.totp-root[data-state="checking"] .totp-box {
animation: totp-wave 0.7s cubic-bezier(.22, 1, .36, 1) infinite;
animation-delay: calc(var(--totp-i) * 40ms);
}
@keyframes totp-wave {
0%,
100% {
translate: 0 0;
}
40% {
translate: 0 -4px;
}
}
.totp-root[data-state="wrong"] .totp-box {
box-shadow: 0 0 0 1.5px var(--ts-danger);
background: color-mix(in srgb, var(--ts-danger) 10%, var(--ts-raised));
animation: none;
}
/* right: the green travels box to box with the hop, so the light and the motion are one wave */
.totp-root[data-state="right"] .totp-box {
box-shadow: 0 0 0 1.5px var(--ts-success);
transition-delay: calc(var(--totp-i) * 40ms);
animation: totp-hop 0.52s cubic-bezier(0.3, 1.4, 0.4, 1);
animation-delay: calc(var(--totp-i) * 40ms);
}
@keyframes totp-hop {
0% {
translate: 0 0;
background: var(--ts-raised);
}
35% {
translate: 0 -6px;
background: color-mix(in srgb, var(--ts-success) 22%, var(--ts-raised));
}
100% {
translate: 0 0;
background: color-mix(in srgb, var(--ts-success) 8%, var(--ts-raised));
}
}
.totp-root[data-state="right"] .totp-box {
background: color-mix(in srgb, var(--ts-success) 8%, var(--ts-raised));
}
.totp-digit {
display: block;
line-height: 1;
animation: totp-drop 0.46s cubic-bezier(0.26, 1.42, 0.4, 1) both;
}
@keyframes totp-drop {
0% {
translate: 0 -80%;
opacity: 0;
filter: blur(5px);
scale: 0.9 1.15;
}
55% {
opacity: 1;
filter: none;
}
70% {
scale: 1.08 0.92;
}
100% {
translate: 0;
scale: 1;
}
}
.totp-root[data-state="wrong"] .totp-digit {
animation: totp-fall 0.3s cubic-bezier(.4, 0, 1, 1) both;
animation-delay: calc(0.16s + var(--totp-i) * 28ms);
}
@keyframes totp-fall {
to {
translate: 0 90%;
opacity: 0;
filter: blur(4px);
rotate: 8deg;
}
}
.totp-caret {
position: absolute;
width: 1.5px;
height: 20px;
border-radius: 1px;
background: var(--ts-fg);
animation:
totp-caret-in 0.2s cubic-bezier(.22, 1, .36, 1) both,
totp-blink 1.1s steps(2, start) 0.6s infinite;
}
@keyframes totp-caret-in {
from {
scale: 1 0;
}
}
@keyframes totp-blink {
to {
visibility: hidden;
}
}
.totp-in {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
color: transparent;
caret-color: transparent;
border: 0;
padding: 0;
font-size: 16px;
outline: 0;
cursor: text;
}
.totp-pill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 16px 0 12px;
border: 0;
border-radius: 999px;
background: var(--ts-fg);
color: var(--ts-raised);
font: inherit;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
cursor: pointer;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.totp-pill:active {
scale: 0.96;
}
.totp-pill:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.totp-check {
width: 20px;
height: 20px;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--ts-success);
color: var(--ts-fg-inverse);
animation: totp-pop 0.52s cubic-bezier(0.3, 1.5, 0.4, 1) both;
animation-delay: 0.1s;
}
@keyframes totp-pop {
0% {
scale: 0.4;
}
45% {
scale: 1.22;
}
to {
scale: 1;
}
}
.totp-ph {
line-height: 1;
color: rgba(var(--ts-fg-rgb), 0.11);
}
@media (prefers-reduced-motion: reduce) {
.totp-digit,
.totp-box,
.totp-root[data-state="wrong"] .totp-digit,
.totp-root[data-state="wrong"] .totp-row,
.totp-root[data-state="right"] .totp-box,
.totp-root[data-state="checking"] .totp-box,
.totp-check,
.totp-caret {
animation: none;
}
}
/* off screen: endless animations hold still (the block sets data-off) */
.totp-root[data-off],
.totp-root[data-off] *,
.totp-root[data-off] ::before,
.totp-root[data-off] ::after,
.totp-root[data-off]::before,
.totp-root[data-off]::after {
animation-play-state: paused !important;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { OneTimeCode } from "./OneTimeCode";
import "./OneTimeCode.css";
export default function Demo() {
return <OneTimeCode />;
}