Pin Pad
A touch-first numeric keypad for PIN entry — big keys, masked dot indicator, optional shuffled layout, and pending/error states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pin-pad.jsonPrompt
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 verification —
onCompletefires 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 (bumpshuffleSeed). - 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-hiddenfor 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.
Query Builder
A recursive AND/OR condition tree — the operator menu follows the field's type, any condition can be turned into a nested group up to a depth cap, and a read-only SQL-ish expression shows exactly what the tree means.
Percent Allocator
Split a total across rows with largest-remainder rebalancing, per-row locks and min/max limits — the shares always add up to the target exactly.