Inputs

Pin Pad

A touch-first numeric keypad for PIN entry — big keys, masked dot indicator, optional shuffled layout, and pending/error states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Delete } from "lucide-react"
import { cn } from "@/lib/utils"

/** Hoisted through React 19's <style href precedence> — dedupes itself, no Tailwind config edit. */
const KEYFRAMES = `@keyframes zpp-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-6px)}40%{transform:translateX(5px)}60%{transform:translateX(-4px)}80%{transform:translateX(2px)}}`

const MIN_LENGTH = 4
const MAX_LENGTH = 12
/** Slots 0-8 are the 3x3 block, slot 9 sits in the middle of the bottom row. */
const NATURAL_ORDER = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pin-pad.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PinPad" component (lucide-react for the
delete glyph, no other libraries).

Contract
- Export a forwardRef div (role="group", aria-label from `label`, default
  "PIN entry") extending HTMLAttributes minus onChange/defaultValue.
- Props: length (default 4, clamped to 4-12), value?/defaultValue (controlled or
  uncontrolled string), onChange?(value), onComplete?(value), mask (default true),
  shuffle (default false), shuffleSeed (default 0), disabled, pending, error,
  errorMessage (default "Incorrect PIN. Try again."), label.
- The value is a digits-only string: anything a parent hands in is stripped of
  non-digits and clipped to `length`, so the component can never display or emit
  something that is not a PIN.
- length is a render loop bound, so clamp it: non-finite -> 4, floor, then 4..12.

Behavior
- Layout is a 3x4 grid: digits in ten slots (slots 0-8 are the 3x3 block, slot 9
  sits in the middle of the bottom row) plus a Clear key (bottom left) and a
  Delete key (bottom right). Every key is a real <button type="button"> with an
  aria-label ("Digit 7", "Delete", "Clear"), min 44px hit area (h-14, min-w-11).
- There is no <input> anywhere: no type="password", no hidden field. The PIN
  lives in React state (or the parent's state) and is never written into an
  attribute. Masked slots render a dot, not the character.
- Keyboard parity: keydown is handled on the root, so events bubbling from any
  focused key drive the same state machine — 0-9 append, Backspace deletes the
  last digit, Escape clears. Modified keystrokes (meta/ctrl/alt) are ignored so
  browser shortcuts survive. Escape is only preventDefault + stopPropagation'ed
  when there is something to clear, so an empty pad inside a dialog still lets
  Escape close the dialog.
- onComplete fires on the transition into a full PIN (tracked with a ref holding
  the last announced value), not on every later press; dropping below full
  re-arms it. Presses past `length` are ignored. Only presses commit, so a parent
  that hands in an already-full value does not trigger a verification round.
- pending marks the keys aria-disabled (not disabled) so a focused key keeps
  focus while the parent verifies; the indicator pulses and the status region
  says "Checking PIN".
- error (a rejected PIN) shakes the indicator once, clears the displayed entry
  and shows errorMessage under it. The clear is tracked as render-phase
  adjust-state (a `cleared` flag next to the previous `error` value), never in an
  effect: an effect would need setState, and synthesising an onChange("") would
  fire a callback the user never triggered. `cleared` is initialised from `error`
  so a pad that mounts already rejected is cleared too — otherwise its entry
  stays full behind the destructive styling and every keypress is swallowed by
  the "entry is complete" guard. The parent's value converges on the next press,
  which commits a fresh digit. Slots keep showing fill while error is up, so a
  parent that leaves error=true does not make retyped digits invisible.
- After a rejection, focus returns to the first key — but only when focus is
  already inside the pad, so a background pad never steals focus from the page.
- shuffle scrambles the ten digits with a seeded Fisher-Yates (mulberry32 keyed
  on shuffleSeed). Math.random() during render would disagree between server and
  client and re-roll on every re-render, moving the keys under the user's finger
  mid-entry; a seed makes the layout a pure function you re-roll on purpose
  (bump shuffleSeed between attempts).
- disabled puts the native disabled attribute on every key, which takes the whole
  pad out of the tab order.

Rendering & styling
- Semantic tokens only. Keys: border + bg-secondary + text-secondary-foreground,
  hover:bg-primary/10, active:bg-primary/20 (secondary/accent/muted share one
  value in the default light palette, so the hover tint has to be primary-based
  to be visible in both themes). Focus ring: focus-visible:ring-2 ring-ring.
  Indicator: filled dot border-primary bg-primary, empty dot
  border-muted-foreground, destructive variants while error. Message line
  text-destructive.
- The shake is a hoisted @keyframes in a React 19 <style href precedence="medium">
  tag, applied as [animation:...] with motion-reduce:[animation:none]: under
  reduced motion the entry is still cleared and the message still appears, only
  the movement is dropped. The pulse is animate-pulse motion-reduce:animate-none.
- The indicator row wraps (flex-wrap + min-h) and tightens its gap past 8 slots:
  12 dots at gap-3 are wider than the pad and would push a horizontal scrollbar
  into the surrounding card.
- Accessibility: the indicator is aria-hidden and a single sr-only role="status"
  announces progress as "N of M digits entered" — never the digits themselves,
  which would read a PIN out loud. While pending it announces "Checking PIN",
  and it announces errorMessage while the entry is empty after a rejection.
  aria-busy on the root while pending. Merge the consumer className via cn().

Security boundary
- Masked or not, treat the value as a secret: keep it in memory, submit it as
  soon as onComplete fires and clear it (an empty string) right after, do not log
  it, do not put it in a form field, a URL or component state that gets persisted
  or serialised into HTML. mask={false} renders the digits as text in the DOM —
  only use it for non-secret codes (kiosk order numbers, table numbers).

Customization levers
- Length: length covers 4-digit bank PINs and 6-digit passcodes; the clamp
  (4..12) is the only thing to touch for longer door codes.
- Key size/density: h-14 + gap-2 on the grid + max-w-64 on the root are the three
  knobs; scale them together (h-12/max-w-56 compact, h-16/max-w-72 for kiosks).
  Keep the hit area at 44px or more.
- Indicator: swap the dots for bars or boxes by changing the one span per slot;
  mask={false} switches to underlined digits.
- Bottom row: replace Clear with a "Cancel" that calls your own handler, or with
  a biometric key (Face ID / fingerprint) — it is one button among twelve.
- Verification: call your mutation from onComplete, drive `pending` from its
  in-flight state and `error` from its result, and bump shuffleSeed on each
  failure if you shuffle.
- Copy: label and errorMessage are the two consumer-facing strings; the key
  aria-labels and the status sentence are inline and easy to translate.

Concepts

  • Keypad entry, not text entry — the value is built by discrete key presses instead of a text field, which is why there is no input, no caret and no paste path; the physical keyboard is wired to the same three actions (digit, Backspace, Escape) so the widget stays reachable without a touchscreen.
  • Completion edge triggers verificationonComplete fires on the transition into a full PIN, so the parent's verify call runs once; everything after that (pending, reject, unlock) is the parent's state flowing back in as props.
  • Reject = clear + shake + say it — a rejection is three signals at once, because the shake alone disappears under prefers-reduced-motion; the cleared entry and the message carry the meaning, the motion is decoration.
  • Deterministic shuffle — an anti-shoulder-surfing layout still has to be a pure function of a seed: Math.random() at render time would differ between server and client and re-roll on every re-render, moving keys under the user's finger. Re-rolling is an explicit act (bump shuffleSeed).
  • Count, never content — the live region announces "3 of 4 digits entered"; reading the digits would defeat the mask for anyone within earshot, and the visual indicator is aria-hidden for the same reason.
  • Secret in memory only — masked slots render dots, so the PIN is never written into the DOM as text or into an input's value; submit it on completion and clear it immediately.

On This Page