Mobile & Consumer

PIN Pad

A secure numeric PIN pad whose dots fill per digit, shake on a wrong code, and cascade to success on the right one.

Install

npx shadcn@latest add @paragon/pin-pad

pin-pad.tsx

"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>
    </>
  );
}