Passcode
A segmented one-time-code input: a row of single-character cells backed by real inputs. Typing seats each digit with a soft pop and advances focus, Backspace retreats, arrow keys move, and paste or SMS autofill distributes across cells. The focused empty cell shows an animated caret in an accent ring; a complete code flashes success and fires onComplete. Fully keyboard and screen-reader accessible with a mobile numeric keypad.
motionfree
"use client";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface PasscodeProps {
/** Controlled value — a string of up to `length` characters. Omit for uncontrolled use. */
value?: string;
/** Initial value when uncontrolled. @default "" */
defaultValue?: string;
/** Number of single-character cells. @default 6 */
length?: number;
/**
* Accepted characters. `"numeric"` allows 0–9 (mobile number pad, one-time-code
* autofill); `"alphanumeric"` allows 0–9 and A–Z. @default "numeric"
*/
mode?: "numeric" | "alphanumeric";
/** Hide entered characters behind dots (like a PIN). @default false */
masked?: boolean;
/** Fires on every change with the concatenated value. */
onChange?: (value: string) => void;
/** Fires once every cell is filled, with the complete code. */
onComplete?: (value: string) => void;
/**
* `[accent, success]`. Accent lights the focus ring + caret; success flashes
* the row when the code completes. @default indigo + emerald
*/
colors?: [string, string] | string[];
/** Animation-speed multiplier for the pop, caret blink and success flash. @default 1 */
speed?: number;
/** Freeze the caret blink and skip the success flash (still seats instantly). @default false */
paused?: boolean;
/** Disable all cells. @default false */
disabled?: boolean;
/** Focus the first cell on mount. @default false */
autoFocus?: boolean;
/** Submit name — emits a hidden input carrying the concatenated value. */
name?: string;
/** Accessible name for the group of cells. @default "Verification code" */
"aria-label"?: string;
className?: string;
}
/** Indigo accent, emerald success — neutral-elegant, cool. */
const DEFAULT_COLORS: [string, string] = ["#818cf8", "#34d399"];
function sanitize(input: string, mode: "numeric" | "alphanumeric"): string {
return mode === "numeric"
? input.replace(/[^0-9]/g, "")
: input.replace(/[^0-9a-zA-Z]/g, "");
}
/**
* Passcode — a row of `length` segmented single-character cells backed by real
* inputs. Typing advances focus and seats each digit with a soft pop; Backspace
* retreats, arrow/Home/End keys move, and a paste (or SMS one-time-code autofill)
* distributes across the cells. The focused empty cell shows an animated caret
* inside an accent focus ring; the moment every cell is filled the row flashes
* a success hue and `onComplete` fires. Fully keyboard operable, every cell
* carries its own aria-label, and mobile gets a numeric keypad. Reduced motion
* drops the pop, caret blink and flash — cells seat instantly and success shows
* as a static colour change.
*/
export function Passcode({
value,
defaultValue = "",
length = 6,
mode = "numeric",
masked = false,
onChange,
onComplete,
colors = DEFAULT_COLORS,
speed = 1,
paused = false,
disabled = false,
autoFocus = false,
name,
"aria-label": ariaLabel = "Verification code",
className,
}: PasscodeProps) {
const reducedMotion = useReducedMotion();
const animate = !reducedMotion && !paused;
const count = Math.max(1, Math.floor(length));
const spd = Math.max(0.1, speed);
const [accent, success] = React.useMemo<[string, string]>(() => {
const c = colors.length ? colors : DEFAULT_COLORS;
return [c[0] ?? DEFAULT_COLORS[0], c[1] ?? DEFAULT_COLORS[1]];
}, [colors]);
const inputsRef = React.useRef<(HTMLInputElement | null)[]>([]);
const groupRef = React.useRef<HTMLDivElement>(null);
const completedRef = React.useRef(false);
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
const [pulseKey, setPulseKey] = React.useState(0);
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState<string[]>(() =>
Array.from({ length: count }, (_, i) => defaultValue[i] ?? "")
);
// Derive the fixed-length cell array from whichever source of truth is active.
const chars = React.useMemo<string[]>(() => {
const source = isControlled ? (value ?? "") : internal.join("");
return Array.from({ length: count }, (_, i) => source[i] ?? "");
}, [isControlled, value, internal, count]);
const complete = chars.every((c) => c !== "");
const commit = React.useCallback(
(next: string[]) => {
if (!isControlled) setInternal(next);
onChange?.(next.join(""));
},
[isControlled, onChange]
);
// Fire onComplete once per completion; re-arm when the code is edited back down.
React.useEffect(() => {
if (complete && !completedRef.current) {
completedRef.current = true;
if (animate) setPulseKey((k) => k + 1);
onComplete?.(chars.join(""));
} else if (!complete && completedRef.current) {
completedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [complete]);
const focusCell = React.useCallback(
(index: number) => {
const clamped = Math.max(0, Math.min(count - 1, index));
const el = inputsRef.current[clamped];
if (el) {
el.focus();
el.select();
}
},
[count]
);
const setCharAt = React.useCallback(
(index: number, char: string) => {
const next = [...chars];
next[index] = char;
commit(next);
},
[chars, commit]
);
const distribute = React.useCallback(
(start: number, incoming: string) => {
if (!incoming) return;
const next = [...chars];
let idx = start;
for (const ch of incoming) {
if (idx >= count) break;
next[idx] = ch;
idx += 1;
}
commit(next);
focusCell(Math.min(idx, count - 1));
},
[chars, commit, count, focusCell]
);
const handleChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const incoming = sanitize(event.target.value, mode);
if (!incoming) {
setCharAt(index, "");
return;
}
distribute(index, incoming);
};
const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case "Backspace":
event.preventDefault();
if (chars[index]) {
setCharAt(index, "");
} else if (index > 0) {
setCharAt(index - 1, "");
focusCell(index - 1);
}
break;
case "Delete":
event.preventDefault();
setCharAt(index, "");
break;
case "ArrowLeft":
event.preventDefault();
focusCell(index - 1);
break;
case "ArrowRight":
event.preventDefault();
focusCell(index + 1);
break;
case "Home":
event.preventDefault();
focusCell(0);
break;
case "End":
event.preventDefault();
focusCell(count - 1);
break;
default:
break;
}
};
const handlePaste = (index: number, event: React.ClipboardEvent<HTMLInputElement>) => {
event.preventDefault();
const text = sanitize(event.clipboardData.getData("text"), mode);
distribute(index, text);
};
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (!groupRef.current?.contains(event.relatedTarget as Node | null)) {
setFocusedIndex(null);
}
};
const joined = chars.join("");
const popTransition = { type: "spring" as const, stiffness: 560, damping: 22, mass: 0.6 };
return (
<div
ref={groupRef}
role="group"
aria-label={ariaLabel}
data-crucible="passcode"
className={cn(
"relative inline-flex items-center gap-2 sm:gap-3",
disabled && "opacity-50",
className
)}
>
{chars.map((char, i) => {
const isFocused = focusedIndex === i;
const showCaret = isFocused && !char && !disabled;
const cellColor = complete ? success : isFocused ? accent : undefined;
return (
<div
key={i}
className="relative flex h-14 w-11 items-center justify-center sm:w-12"
>
{/* Cell frame — neutral at rest, accent on focus, success on complete. */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-xl border bg-neutral-900/40 transition-colors",
char ? "border-neutral-600" : "border-neutral-800",
complete && "border-transparent"
)}
style={{
borderColor: cellColor,
boxShadow: isFocused && !complete ? `0 0 0 3px ${accent}33` : undefined,
transitionDuration: reducedMotion ? "0ms" : "180ms",
}}
/>
{/* Success flash — a one-shot glow when the last cell fills. */}
{animate && complete && (
<motion.div
key={pulseKey}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-xl"
initial={{ opacity: 0.55, boxShadow: `0 0 0 4px ${success}66` }}
animate={{ opacity: 0, boxShadow: `0 0 0 10px ${success}00` }}
transition={{ duration: 0.5 / spd, ease: "easeOut" }}
/>
)}
{/* Visible glyph / caret. The real input above is transparent, so this
layer owns every pixel the user sees. Decorative → aria-hidden. */}
<div
aria-hidden
className="pointer-events-none relative z-10 flex h-full w-full items-center justify-center"
>
<AnimatePresence mode="popLayout" initial={false}>
{char ? (
<motion.span
key="char"
className="text-2xl font-medium tabular-nums"
style={{ color: complete ? success : "#f5f5f5" }}
initial={animate ? { scale: 0.55, opacity: 0, y: 3 } : false}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={animate ? { scale: 0.55, opacity: 0 } : { opacity: 0 }}
transition={animate ? popTransition : { duration: 0 }}
>
{masked ? (
<span className="inline-block h-2.5 w-2.5 rounded-full bg-current align-middle" />
) : (
char
)}
</motion.span>
) : showCaret ? (
<motion.span
key="caret"
className="block h-6 w-[2px] rounded-full"
style={{ backgroundColor: accent }}
initial={{ opacity: 1 }}
animate={animate ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}
transition={
animate
? { duration: 1.06 / spd, repeat: Infinity, ease: "linear" }
: { duration: 0 }
}
/>
) : null}
</AnimatePresence>
</div>
{/* The real, accessible input — transparent text + caret so the layer
above renders instead. Holds exactly one character of state. */}
<input
ref={(el) => {
inputsRef.current[i] = el;
}}
type="text"
inputMode={mode === "numeric" ? "numeric" : "text"}
pattern={mode === "numeric" ? "[0-9]*" : undefined}
autoComplete={i === 0 ? "one-time-code" : "off"}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus && i === 0}
disabled={disabled}
value={char}
aria-label={`${mode === "numeric" ? "Digit" : "Character"} ${i + 1} of ${count}`}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={(e) => handlePaste(i, e)}
onFocus={(e) => {
setFocusedIndex(i);
e.target.select();
}}
onBlur={handleBlur}
className={cn(
"absolute inset-0 z-20 h-full w-full rounded-xl bg-transparent text-center",
"text-transparent caret-transparent outline-none",
"selection:bg-transparent selection:text-transparent",
"disabled:cursor-not-allowed"
)}
/>
</div>
);
})}
{name ? <input type="hidden" name={name} value={joined} /> : null}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/passcodeManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | Controlled value — a string of up to length characters. Omit for uncontrolled use. | |
| defaultValue | string | "" | Initial value when uncontrolled. |
| length | number | 6 | Number of single-character cells. |
| mode | "numeric" | "alphanumeric" | "numeric" | Accepted characters. "numeric" allows 0–9 (mobile number pad, one-time-code autofill); "alphanumeric" allows 0–9 and A–Z. |
| masked | boolean | false | Hide entered characters behind dots (like a PIN). |
| onChange | (value: string) => void | Fires on every change with the concatenated value. | |
| onComplete | (value: string) => void | Fires once every cell is filled, with the complete code. | |
| colors | [string, string] | string[] | indigo + emerald | [accent, success]. Accent lights the focus ring + caret; success flashes the row when the code completes. |
| speed | number | 1 | Animation-speed multiplier for the pop, caret blink and success flash. |
| paused | boolean | false | Freeze the caret blink and skip the success flash (still seats instantly). |
| disabled | boolean | false | Disable all cells. |
| autoFocus | boolean | false | Focus the first cell on mount. |
| name | string | Submit name — emits a hidden input carrying the concatenated value. | |
| aria-label | string | "Verification code" | Accessible name for the group of cells. |
| className | string |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.