"use client";
import * as React from "react";
import { motion, useAnimate } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
type PinPadSize = "sm" | "md" | "lg";
type PinPadShape = "round" | "square";
export interface PinPadProps
extends Omit<
React.ComponentPropsWithoutRef<"div">,
"onChange" | "children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Number of digits in the PIN (clamped to 3–10). */
length?: number;
/** Called with the full PIN as soon as the last digit is entered (and again on Enter while full). */
onComplete?: (pin: string) => void;
/** Called with the current entry after every change. Reset `error` here. */
onChange?: (pin: string) => void;
/**
* Controlled error state. When it turns true the dots shake, then the entry clears.
* Set it back to false on the next `onChange` so the following wrong attempt can shake again.
*/
error?: boolean;
/** Shown and announced while `error` is true. */
errorMessage?: string;
/** Visible heading; also names the keypad group. */
label?: string;
/** Randomise the digit keys on mount and after every error. */
shuffle?: boolean;
/** Phone-style letters under the digits. */
letters?: boolean;
shape?: PinPadShape;
size?: PinPadSize;
/** Accent for the active dot, key flash and focus ring. */
accent?: string;
/** Spring used when a dot fills or empties. */
spring?: Spring;
disabled?: boolean;
}
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
const LETTERS: Record<string, string> = { "2": "ABC", "3": "DEF", "4": "GHI", "5": "JKL", "6": "MNO", "7": "PQRS", "8": "TUV", "9": "WXYZ", "0": "+" };
const SIZES: Record<PinPadSize, { key: number; gap: number; digit: string; dot: number }> = {
sm: { key: 56, gap: 10, digit: "text-[22px]", dot: 11 },
md: { key: 68, gap: 14, digit: "text-[26px]", dot: 13 },
lg: { key: 80, gap: 16, digit: "text-[30px]", dot: 15 },
};
const ERROR = "#E5484D";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** Fisher–Yates on a copy. Only ever called on the client (effects and handlers). */
function shuffled(list: string[]) {
const out = [...list];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
type Announcement = { kind: "count" | "cleared" | "error"; n: number } | null;
/**
* A numeric keypad for PIN and passcode entry. The entry lives only in component state — it is never
* stored, logged or rendered as text — and screen readers hear progress ("3 of 6 digits entered"),
* never the digits. Keys are real buttons; while focus is inside the pad the physical keyboard works
* too (digits, Backspace, Enter). A controlled `error` shakes the dots and clears the entry.
*/
export function PinPad({
length: lengthProp = 4,
onComplete,
onChange,
error = false,
errorMessage = "Incorrect PIN. Try again.",
label = "Enter PIN",
shuffle = false,
letters = true,
shape = "round",
size = "md",
accent = "#FF7448",
spring = { stiffness: 520, damping: 22, mass: 0.6 },
disabled = false,
className = "",
style,
onKeyDown,
...props
}: PinPadProps) {
const length = Math.max(3, Math.min(10, Math.round(lengthProp)));
const uid = React.useId();
const labelId = `${uid}-label`;
const reduceMotion = usePrefersReducedMotion();
const [dotsScope, animate] = useAnimate<HTMLDivElement>();
const [value, setValue] = React.useState("");
const [order, setOrder] = React.useState(DIGITS);
const [flash, setFlash] = React.useState<string | null>(null);
const [announcement, setAnnouncement] = React.useState<Announcement>(null);
// True between an error arriving and the entry being cleared: input is ignored meanwhile.
const locked = React.useRef(false);
const flashTimer = React.useRef(0);
const s = SIZES[size] ?? SIZES.md;
const keys = shuffle ? order : DIGITS;
const full = value.length >= length;
// Shorter `length` while digits are entered: keep the entry consistent.
const entry = value.slice(0, length);
// Shuffle on the client only, so server and first client render agree.
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Math.random must not run during render (hydration)
if (shuffle) setOrder(shuffled(DIGITS));
}, [shuffle]);
// Error turned on: shake (unless reduced motion), then clear the entry and announce the message.
React.useEffect(() => {
if (!error) return;
locked.current = true;
const el = dotsScope.current;
if (!reduceMotion && el) {
animate(el, { x: [0, -11, 10, -8, 7, -4, 3, 0] }, { duration: 0.46, ease: "easeOut" });
}
const timer = window.setTimeout(
() => {
locked.current = false;
setValue("");
setAnnouncement({ kind: "error", n: 0 });
if (shuffle) setOrder(shuffled(DIGITS));
},
reduceMotion ? 0 : 520,
);
return () => {
window.clearTimeout(timer);
locked.current = false;
};
// Only the rising edge of `error` matters here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
React.useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const commit = (next: string) => {
setValue(next);
setAnnouncement(next.length ? { kind: "count", n: next.length } : { kind: "cleared", n: 0 });
onChange?.(next);
if (next.length === length) onComplete?.(next);
};
const press = (digit: string) => {
if (disabled || locked.current || entry.length >= length) return;
commit(entry + digit);
};
const backspace = () => {
if (disabled || locked.current || !entry.length) return;
commit(entry.slice(0, -1));
};
const clear = () => {
if (disabled || locked.current || !entry.length) return;
commit("");
};
const flashKey = (id: string) => {
window.clearTimeout(flashTimer.current);
setFlash(id);
flashTimer.current = window.setTimeout(() => setFlash(null), 140);
};
// Physical keyboard while focus is anywhere inside the pad.
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
if (/^[0-9]$/.test(event.key)) {
event.preventDefault();
if (!event.repeat) flashKey(event.key);
press(event.key);
} else if (event.key === "Backspace") {
event.preventDefault();
flashKey("back");
backspace();
} else if (event.key === "Enter") {
// Enter submits instead of activating the focused key (which would type its digit).
event.preventDefault();
if (!disabled && !locked.current && full) onComplete?.(entry);
}
};
const liveText =
announcement === null
? ""
: announcement.kind === "error"
? errorMessage
: announcement.kind === "cleared"
? "PIN cleared"
: `${Math.min(announcement.n, length)} of ${length} digits entered`;
const radius = shape === "round" ? "rounded-full" : "rounded-[18px]";
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const keyBase = `group/key relative flex select-none items-center justify-center ${radius} touch-manipulation outline-hidden [-webkit-tap-highlight-color:transparent] transition-[background-color,border-color,color] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pp-accent)] disabled:cursor-not-allowed disabled:opacity-40 aria-disabled:cursor-default aria-disabled:opacity-40`;
const digitKey = `${keyBase} border border-zinc-900/[0.08] bg-white text-zinc-900 shadow-[0_1px_0_rgb(0_0_0/0.04),0_6px_14px_-10px_rgb(0_0_0/0.35)] hover:border-zinc-900/15 active:bg-zinc-100 data-[flash=true]:bg-zinc-100 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-zinc-50 dark:shadow-none dark:hover:border-white/15 dark:active:bg-white/[0.11] dark:data-[flash=true]:bg-white/[0.11]`;
const utilityKey = `${keyBase} text-zinc-500 hover:text-zinc-900 active:bg-zinc-900/[0.06] data-[flash=true]:bg-zinc-900/[0.06] dark:text-zinc-400 dark:hover:text-zinc-50 dark:active:bg-white/[0.08] dark:data-[flash=true]:bg-white/[0.08]`;
const pressMotion = reduceMotion ? {} : { whileTap: { scale: 0.9 } };
const flashScale = (id: string) => (flash === id && !reduceMotion ? 0.9 : 1);
const renderDigit = (digit: string) => (
<motion.button
key={digit}
type="button"
layout={shuffle && !reduceMotion ? "position" : false}
className={digitKey}
style={{ width: s.key, height: s.key }}
data-flash={flash === digit}
disabled={disabled}
aria-label={digit}
onClick={() => press(digit)}
animate={{ scale: flashScale(digit) }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 30 }}
{...pressMotion}
>
{/* Accent bloom on press. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 ${radius} bg-[var(--pp-accent)] opacity-0 transition-opacity duration-300 group-active/key:opacity-[0.14] group-active/key:duration-0 group-data-[flash=true]/key:opacity-[0.14] group-data-[flash=true]/key:duration-0`}
/>
<span aria-hidden="true" className="relative flex flex-col items-center leading-none">
<span className={`${s.digit} font-light tabular-nums tracking-tight`}>{digit}</span>
{letters ? (
<span className="mt-1 h-[9px] font-mono text-[8.5px] uppercase tracking-[0.2em] text-zinc-400 dark:text-zinc-500">{LETTERS[digit] ?? ""}</span>
) : null}
</span>
</motion.button>
);
return (
<div
{...props}
role="group"
aria-labelledby={labelId}
onKeyDown={handleKeyDown}
className={`inline-flex flex-col items-center ${className}`}
style={{ ["--pp-accent" as string]: accent, ...style }}
>
<p id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</p>
{/* Masked dots: decorative, the live region below carries the meaning. */}
<div ref={dotsScope} aria-hidden="true" className="mt-4 flex items-center" style={{ gap: Math.round(s.dot * 0.95) }}>
{Array.from({ length }, (_, i) => {
const filled = i < entry.length;
const next = i === entry.length && !error && !disabled;
return (
<span
key={i}
className={`relative grid place-items-center rounded-full border transition-colors duration-200 ${
error
? "border-[var(--pp-error)]"
: filled
? "border-zinc-900 dark:border-zinc-50"
: next
? "border-[var(--pp-accent)]"
: "border-zinc-900/25 dark:border-white/25"
}`}
style={{ width: s.dot, height: s.dot, ["--pp-error" as string]: ERROR }}
>
<motion.span
className={`absolute inset-[-1px] rounded-full ${error ? "bg-[var(--pp-error)]" : "bg-zinc-900 dark:bg-zinc-50"}`}
initial={false}
animate={{ scale: filled ? 1 : 0, opacity: filled ? 1 : 0 }}
transition={transition}
/>
</span>
);
})}
</div>
<p
className={`mt-3 h-4 text-center text-[12px] leading-4 tracking-tight transition-opacity duration-200 ${error ? "opacity-100" : "opacity-0"}`}
style={{ color: ERROR }}
aria-hidden="true"
>
{errorMessage}
</p>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{liveText}
</span>
<div className="mt-4 grid grid-cols-3" style={{ gap: s.gap }}>
{keys.slice(0, 9).map(renderDigit)}
<button
type="button"
className={`${utilityKey} font-mono text-[10px] uppercase tracking-[0.14em]`}
style={{ width: s.key, height: s.key }}
data-flash={flash === "clear"}
disabled={disabled}
aria-disabled={!entry.length || undefined}
aria-label="Clear all digits"
onClick={clear}
>
Clear
</button>
{renderDigit(keys[9])}
<motion.button
type="button"
className={utilityKey}
style={{ width: s.key, height: s.key }}
data-flash={flash === "back"}
disabled={disabled}
aria-disabled={!entry.length || undefined}
aria-label="Delete last digit"
onClick={backspace}
animate={{ scale: flashScale("back") }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 30 }}
{...pressMotion}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-[26px]" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.2 5.5h9.3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9.2a2 2 0 0 1-1.5-.68L3.6 13.2a1.8 1.8 0 0 1 0-2.4l4.1-4.62a2 2 0 0 1 1.5-.68Z" stroke="currentColor" />
<path d="m11.5 9.5 5 5m0-5-5 5" stroke="currentColor" />
</svg>
</motion.button>
</div>
</div>
);
}