Teseria
All elements

One-time code

One real input, six drawn boxes. Each digit drops into its box.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

OneTimeCode.tsx203 lines
"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.css272 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { OneTimeCode } from "./OneTimeCode";
import "./OneTimeCode.css";

export default function Demo() {
  return <OneTimeCode />;
}