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