OTP Segmented Input
Six boxes that are secretly one real input - the hard version nobody demos. The input is stretched invisibly over the row (transparent text and caret, never hidden, so autocomplete="one-time-code" SMS autofill and paste just work), and the cells are painted from its value. The active cell is derived from the native selection, never stored beside it: ←/→ move the real caret and the highlight follows, Backspace walks backwards for free, and select-all paints all six cells because a selection range maps to a cell range. Paste 246 810 or 246-810 - one normalize pass strips the junk. Six digits verify on their own: the right code cascades green left to right, a wrong one shakes once, then the digits drop out one by one before the caret comes back. The blueprint toggle tints the hidden input's glyphs red - they are letter-spaced to sit exactly under the cells.
State: idle · 0/6 · attempts 0
Install
$ npx moumenlab add otp-segmented-inputFor AI
Usage
"use client";
import OtpInput from "./otp-segmented-input";
// Wire your real check through `verify` — sync or async. Return true and the
// cells cascade green; false and the row shakes, drops the digits, and hands
// the caret back:
//
// <OtpInput
// verify={async (code) => {
// const res = await fetch("/api/verify-otp", { method: "POST", body: code });
// return res.ok;
// }}
// />
export default function OtpInputExample() {
return (
<div className="flex flex-col items-center gap-8">
{/* Default: six digits, verified against your adapter (or the `code` prop). */}
<OtpInput verify={(code) => code === "246810"} />
{/* Masked — paints • instead of the digit, the value stays untouched. */}
<OtpInput mask />
{/* Split 3-3, the way SMS codes are read aloud. */}
<OtpInput group />
{/* Any length — the group gap lands at the halfway point. */}
<OtpInput length={4} code="2468" group />
</div>
);
}
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { MotionConfig, motion } from "motion/react";
// OTP segmented input — N boxes, secretly ONE real input.
//
// The version everyone demos is six <input>s wired together with JS focus
// hops. It looks right and behaves wrong: SMS autofill can't fill it (iOS
// offers the code to ONE field), paste needs bespoke splitting, screen
// readers announce six unlabeled boxes, and half the keyboard is re-invented.
//
// This is the hard version: one real <input> stretched invisibly over the
// whole row (color and caret transparent — NOT display:none, it must stay
// focusable and autofillable), with the cells painted underneath from its
// value. Everything hard becomes free:
//
// · SMS autofill just works — autocomplete="one-time-code" on a real,
// visible-to-the-browser input.
// · Paste just works — "246 810" lands in the input, one normalize pass
// strips the junk, the cells repaint.
// · Backspace walks backwards and ←/→ move the caret because they are the
// NATIVE caret — the active cell is derived from selectionStart, never
// stored beside it. Select-all paints all cells selected, because a
// selection range maps to a cell range.
// · The input's own glyphs are letter-spaced to sit under the cells, so the
// blueprint toggle can simply tint them red and you SEE the real input
// lying over the fake one.
//
// Verification is yours: pass `verify` (sync or async — hit your API) and a
// full code drives the little state machine: right → the cells cascade green
// left to right; wrong → the row shakes, the digits drop out one by one, then
// the field clears and hands the caret back. Without `verify` it compares
// against the `code` prop, so the component demos out of the box.
//
// Animation via motion/react; honours prefers-reduced-motion. Requires the
// lab-theme tokens. Fully Tailwind, no CSS files.
const EASE = [0.22, 1, 0.36, 1] as const;
const SHAKE_S = 0.38; // wrong code: the row shake
const DROP_S = 0.24; // each digit's fall-out
const STAGGER_S = 0.045; // per-digit clear offset
const FILL_S = 0.055; // per-cell success cascade offset
const OTP_VARS = {
"--otp-cell-w": "2.75rem",
"--otp-cell-h": "3.25rem",
"--otp-gap": "0.5rem",
} as CSSProperties;
export interface OtpInputState {
length: number;
caret: { start: number; end: number };
state: "idle" | "success" | "error";
attempts: number;
focused: boolean;
}
export default function OtpInput({
length = 6,
code = "246810",
verify,
mask = false, // paint • instead of the digit
group = false, // split in half, like SMS codes read aloud
prefill = null, // {key, code} — simulate an autofill (keyed so re-picking re-applies)
inspect = false,
onStateChange,
}: {
length?: number;
/** Demo fallback: the code `verify` defaults to comparing against. */
code?: string;
/** Your check — sync or async (hit your API); return whether the code is right. */
verify?: (value: string) => boolean | Promise<boolean>;
mask?: boolean;
group?: boolean;
prefill?: { key: number; code: string } | null;
inspect?: boolean;
onStateChange?: (state: OtpInputState) => void;
}) {
const [value, setValue] = useState("");
const [sel, setSel] = useState({ start: 0, end: 0 });
const [focused, setFocused] = useState(false);
const [state, setState] = useState<"idle" | "success" | "error">("idle");
const [attempts, setAttempts] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const errorTimerRef = useRef<number>(0);
const chars = value.split("");
const collapsed = sel.start === sel.end;
const caretCell = Math.min(sel.start, length - 1);
const groupAt = Math.ceil(length / 2);
function syncSel() {
const el = inputRef.current;
if (!el) return;
setSel({ start: el.selectionStart ?? 0, end: el.selectionEnd ?? 0 });
}
// The active cell is DERIVED from the native selection — arrows, backspace,
// select-all all just move the real caret and the paint follows.
useEffect(() => {
const onSelectionChange = () => {
if (document.activeElement === inputRef.current) syncSel();
};
document.addEventListener("selectionchange", onSelectionChange);
return () => document.removeEventListener("selectionchange", onSelectionChange);
}, []);
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
if (state !== "idle") return;
// One normalize pass covers typing, paste and autofill: "246 810",
// "246-810" and "246810" all become the same digits.
const next = event.target.value.replace(/\D/g, "").slice(0, length);
setValue(next);
requestAnimationFrame(syncSel);
}
// Native click mapping is the one thing that's wrong for OTP (you can't
// edit the middle of a code) — snap pointer focus to the end instead.
function handleMouseDown(event: React.MouseEvent) {
event.preventDefault();
const el = inputRef.current;
el?.focus({ preventScroll: true });
el?.setSelectionRange(value.length, value.length);
syncSel();
}
// A full code in → verify. A beat of delay so the last digit is seen landing
// before the row answers; the check itself may be async (your API).
useEffect(() => {
if (state !== "idle" || value.length !== length) return undefined;
let cancelled = false;
const timer = window.setTimeout(async () => {
let ok: boolean;
try {
ok = await Promise.resolve(verify ? verify(value) : value === code);
} catch {
ok = false;
}
if (cancelled) return;
setAttempts((n) => n + 1);
if (ok) {
setState("success");
} else {
setState("error");
// Shake, then the digits drop out one by one, then the field clears
// and the caret comes back for another try.
errorTimerRef.current = window.setTimeout(() => {
setValue("");
setState("idle");
const el = inputRef.current;
if (el && document.activeElement === el) {
el.setSelectionRange(0, 0);
syncSel();
} else {
setSel({ start: 0, end: 0 });
}
}, SHAKE_S * 1000 + length * STAGGER_S * 1000 + 260);
}
}, 320);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [value, state, code, verify, length]);
// Simulated autofill (e.g. demo presets) — through the same normalize +
// verify path a real autofill would take.
useEffect(() => {
if (!prefill) return;
clearTimeout(errorTimerRef.current);
setState("idle");
setValue(String(prefill.code).replace(/\D/g, "").slice(0, length));
setSel({ start: length, end: length });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefill?.key]);
function reset() {
clearTimeout(errorTimerRef.current);
setValue("");
setState("idle");
setAttempts(0);
const el = inputRef.current;
el?.focus({ preventScroll: true });
el?.setSelectionRange(0, 0);
syncSel();
}
useEffect(() => () => clearTimeout(errorTimerRef.current), []);
useEffect(() => {
onStateChange?.({
length: value.length,
caret: { start: sel.start, end: sel.end },
state,
attempts,
focused,
});
}, [value.length, sel, state, attempts, focused, onStateChange]);
const cells = useMemo(
() =>
Array.from({ length }, (_, index) => {
const char = chars[index];
return {
index,
char,
active: focused && state === "idle" && collapsed && caretCell === index,
selected: focused && !collapsed && index >= sel.start && index < sel.end,
};
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[chars.join(""), length, focused, state, collapsed, caretCell, sel.start, sel.end],
);
return (
<MotionConfig reducedMotion="user">
<div className="relative flex flex-col items-center gap-2" style={OTP_VARS} data-state={state}>
{/* Wrong code: the row shakes once, as one object. */}
<motion.div
className="relative flex gap-[var(--otp-gap)]"
animate={state === "error" ? { x: [0, -6, 5, -4, 3, -1, 0] } : { x: 0 }}
transition={{ duration: SHAKE_S, ease: EASE }}
>
{cells.map((cell) => (
<div
key={cell.index}
className={[
"flex items-center justify-center w-[var(--otp-cell-w)] h-[var(--otp-cell-h)] rounded-[0.625rem] text-xl font-medium tabular-nums",
"transition-[box-shadow,background-color,color] duration-150",
group && cell.index === groupAt ? "ml-3" : "",
// the success cascade retimes the tint with a per-cell delay
state === "success"
? "shadow-[var(--shadow-border),0_0_0_1.5px_#16a34a] bg-[#f0fdf4] text-[#15803d]"
: cell.active
? "bg-white text-[#111] shadow-[var(--shadow-border),0_0_0_2px_#111]"
: state === "error"
? "bg-white text-[#dc2626] shadow-border"
: "bg-white text-[#111] shadow-border",
inspect ? "outline outline-[1.5px] outline-dashed outline-[#3b82f6] -outline-offset-2" : "",
].join(" ")}
style={state === "success" ? { transitionDelay: `${cell.index * FILL_S * 1000}ms` } : undefined}
aria-hidden="true"
>
{cell.char && (
// A selection RANGE maps to a cell range — one input. The
// highlight hugs the digit like native text selection paints
// the glyph's line box, instead of tinting the whole slot.
// Padding is offset by negative margins so toggling it never
// shifts the centered glyph.
<motion.span
className={`inline-block rounded-[0.3125rem] px-1 py-0.5 -mx-1 -my-0.5 transition-colors duration-150 ${
cell.selected ? "bg-gray-200" : "bg-transparent"
}`}
initial={false}
animate={
state === "success"
? { scale: [1, 1.15, 1], y: 0, opacity: 1, filter: "blur(0px)" }
: state === "error"
? { y: "0.5rem", opacity: 0, filter: "blur(2px)" }
: { scale: 1, y: 0, opacity: 1, filter: "blur(0px)" }
}
transition={
state === "success"
? { duration: 0.3, ease: EASE, delay: cell.index * FILL_S }
: state === "error"
? { duration: DROP_S, ease: "easeOut", delay: SHAKE_S + cell.index * STAGGER_S }
: { duration: 0 }
}
>
{mask ? "•" : cell.char}
</motion.span>
)}
{cell.active && !cell.char && (
// The fake caret: a hard blink (steps, not a fade).
<motion.span
className="w-[1.5px] h-[1.375rem] rounded-[1px] bg-[#111]"
animate={{ opacity: [1, 1, 0, 0] }}
transition={{ duration: 1.1, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: "linear" }}
/>
)}
</div>
))}
{/* THE component: one real input over the whole row. Transparent, not
hidden — the browser must see it to autofill and focus it. No
maxLength: it would truncate a formatted paste ("246 810" is 7
chars) BEFORE the normalize pass — the slice enforces length. */}
<input
ref={inputRef}
className={[
"absolute inset-0 w-full h-full border-0 outline-none bg-transparent font-mono text-xl cursor-text",
"[letter-spacing:calc(var(--otp-cell-w)+var(--otp-gap)-1ch)] pl-[calc(var(--otp-cell-w)/2-0.5ch)]",
// A host page's own ::selection styling repaints selected glyphs
// with a visible foreground — select-all would reveal masked
// digits (this site does exactly that: selection:text-white on
// the page wrapper, which Tailwind cascades to descendants at
// EQUAL specificity, so source order decides). `!` makes the
// component win deterministically in any host page; the
// text-fill-color below is the second lock — ::selection cannot
// override it, so the glyphs stay invisible mid-selection.
"selection:bg-transparent! selection:text-transparent! [caret-color:transparent]",
inspect
? "text-[rgba(220,38,38,0.55)] [-webkit-text-fill-color:rgba(220,38,38,0.55)] outline outline-[1.5px] outline-dashed outline-[#ef4444] outline-offset-4" // the secret, revealed
: "text-transparent [-webkit-text-fill-color:transparent]",
].join(" ")}
type="text"
value={value}
inputMode="numeric"
autoComplete="one-time-code"
aria-label={`${length}-digit verification code`}
spellCheck={false}
autoCorrect="off"
readOnly={state !== "idle"}
onChange={handleChange}
onMouseDown={handleMouseDown}
onKeyUp={syncSel}
onFocus={() => {
setFocused(true);
const el = inputRef.current;
el?.setSelectionRange(value.length, value.length);
syncSel();
}}
onBlur={() => setFocused(false)}
/>
</motion.div>
{/* Fixed-height under-row so Verified / Wrong code never shift the layout. */}
<div className="flex items-center gap-2.5 min-h-7">
<span
className={`text-[0.8125rem] transition-colors duration-150 ${
state === "success" ? "text-[#16a34a] font-medium" : state === "error" ? "text-[#dc2626] font-medium" : "text-gray-400"
}`}
>
{state === "success" ? "Verified" : state === "error" ? "Wrong code" : mask ? "Digits are masked" : " "}
</span>
{state === "success" && (
<motion.button
type="button"
className="h-7 px-2.5 rounded-lg bg-[#f4f4f5] text-[#111] text-xs font-medium cursor-pointer transition-[background-color,scale] duration-150 hover:bg-[#e4e4e7] active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#111]"
onClick={reset}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, ease: [0.2, 0, 0, 1] }}
>
Try again
</motion.button>
)}
</div>
<span className="sr-only" aria-live="polite">
{state === "success" ? "Code verified." : state === "error" ? "Wrong code, the field will clear. Try again." : ""}
</span>
{/* Blueprint annotations — red is the real input revealed (its glyphs
are letter-spaced to sit under the cells), blue the derived paint. */}
{inspect && (
<>
<span className="absolute bottom-[calc(100%+0.75rem)] left-1/2 -translate-x-1/2 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#fecaca] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#dc2626] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums">
one real input · color: transparent · autocomplete: one-time-code
</span>
<span className="absolute top-[calc(100%+0.4rem)] left-1/2 -translate-x-1/2 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#bfdbfe] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#2563eb] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums">
selection {sel.start}..{sel.end} → {collapsed ? `cell ${caretCell}` : `cells ${sel.start}-${Math.max(sel.start, sel.end - 1)}`}
</span>
</>
)}
</div>
</MotionConfig>
);
}