A secure numeric PIN pad whose dots fill per digit, shake on a wrong code, and cascade to success on the right one.
npx shadcn@latest add @paragon/pin-pad"use client";
import * as React from "react";
import { Check, Delete } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PinPadProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
/** Number of digits. */
length?: number;
/** Fires on every change. */
onValueChange?: (value: string) => void;
/** Fires once the last digit lands; return false to reject (shake + clear). */
onComplete?: (value: string) => boolean | void | Promise<boolean | void>;
/** External error trigger — shakes the dot row and clears. */
error?: boolean;
label?: string;
static?: boolean;
}
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "back"];
/**
* A secure numeric PIN pad. Dots fill as digits are entered; a wrong code
* shakes the row and clears; a correct code cascades the dots to a success
* color and shows a check. Never stores or echoes the digits as text — the
* value lives only in state and is reported via callbacks. Full keyboard
* support (number keys, Backspace, Enter). Reduced motion drops the shake and
* cascade, keeps color/state.
*/
export function PinPad({
length = 4,
onValueChange,
onComplete,
error = false,
label = "Enter PIN",
static: isStatic = false,
className,
...props
}: PinPadProps) {
const [code, setCode] = React.useState("");
const [shaking, setShaking] = React.useState(false);
const [succeeded, setSucceeded] = React.useState(false);
const busy = React.useRef(false);
const rootRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (error) {
setShaking(true);
setCode("");
}
}, [error]);
const clearShakeSoon = () => {
// Fallback in case animationend doesn't fire (reduced motion).
window.setTimeout(() => setShaking(false), 320);
};
const commit = React.useCallback(
async (next: string) => {
setCode(next);
onValueChange?.(next);
if (next.length === length) {
busy.current = true;
const ok = onComplete ? await onComplete(next) : undefined;
busy.current = false;
if (ok === false) {
setShaking(true);
setCode("");
onValueChange?.("");
clearShakeSoon();
} else if (ok === true) {
setSucceeded(true);
}
}
},
[length, onComplete, onValueChange],
);
const press = (digit: string) => {
if (busy.current || succeeded) return;
if (code.length >= length) return;
commit(code + digit);
};
const backspace = () => {
if (busy.current || succeeded || code.length === 0) return;
const next = code.slice(0, -1);
setCode(next);
onValueChange?.(next);
};
return (
<>
<style href="paragon-pin-pad" precedence="paragon">{`
@keyframes pg-pin-shake {
10%, 90% { translate: -1px 0; }
30%, 70% { translate: 4px 0; }
50% { translate: -6px 0; }
}
@keyframes pg-pin-fill {
from { transform: scale(0.4); opacity: 0.4; }
to { transform: scale(1); opacity: 1; }
}
@keyframes pg-pin-cascade {
0% { transform: translateY(0); }
40% { transform: translateY(-4px); }
100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pg-pin-row, .pg-pin-dot, .pg-pin-dot-filled { animation: none !important; }
}
`}</style>
<div
ref={rootRef}
role="group"
aria-label={label}
tabIndex={0}
onKeyDown={(e) => {
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
press(e.key);
} else if (e.key === "Backspace") {
e.preventDefault();
backspace();
}
}}
className={cn(
"flex w-fit flex-col items-center gap-6 outline-none",
className,
)}
{...props}
>
<div className="flex flex-col items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{label}
</span>
<div
className={cn(
"pg-pin-row flex items-center gap-3",
shaking && "[animation:pg-pin-shake_300ms_var(--ease-out)]",
)}
onAnimationEnd={(e) => {
if (e.animationName.includes("pg-pin-shake")) setShaking(false);
}}
aria-hidden
>
{Array.from({ length }, (_, i) => {
const filled = i < code.length;
return (
<span
key={i}
className={cn(
"flex size-3.5 items-center justify-center rounded-full transition-colors duration-150",
succeeded
? "bg-success"
: filled
? "bg-foreground"
: "bg-transparent shadow-border",
)}
style={
succeeded && !isStatic
? {
animation: `pg-pin-cascade 400ms var(--ease-out) ${i * 60}ms both`,
}
: undefined
}
>
{filled && !succeeded && (
<span
className="pg-pin-dot-filled size-full rounded-full bg-current"
style={
isStatic
? undefined
: {
animation:
"pg-pin-fill 150ms var(--ease-out) both",
}
}
/>
)}
{succeeded && i === length - 1 && (
<Check
className="size-2.5 text-success-foreground"
strokeWidth={3.5}
/>
)}
</span>
);
})}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{KEYS.map((key, i) => {
if (key === "") return <span key={i} aria-hidden />;
if (key === "back") {
return (
<button
key={i}
type="button"
aria-label="Delete"
onClick={backspace}
disabled={code.length === 0 || succeeded}
className={cn(
"flex size-14 items-center justify-center rounded-full text-muted-foreground outline-none transition-[background-color,color,scale] duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30 disabled:hover:bg-transparent",
!isStatic && "active:not-disabled:scale-[0.94]",
)}
>
<Delete className="size-5" aria-hidden />
</button>
);
}
return (
<button
key={i}
type="button"
onClick={() => press(key)}
disabled={succeeded}
className={cn(
"flex size-14 items-center justify-center rounded-full text-xl font-medium tabular-nums outline-none transition-[background-color,scale] duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
!isStatic && "active:not-disabled:scale-[0.94]",
)}
>
{key}
</button>
);
})}
</div>
</div>
</>
);
}