Passcode Lock
An app lock screen with keypad passcode entry, biometric fallback, escalating attempt backoff and a terminal locked-out state.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/passcode-lock.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PasscodeLock" component (lucide-react for the
lock, check, shield, delete and biometric glyphs; no other libraries).
It is a mobile component on purpose, not a narrow password field. There is no text
input anywhere in it: a software keyboard on a lock screen covers the very screen it
is unlocking and routes the passcode through autofill, dictation and password
managers. The keypad IS the keyboard, the keys sit in the thumb arc against the
bottom edge, and the face / fingerprint key is a peer of the digits because
biometrics are a phone affordance.
Contract
- Export a forwardRef div extending HTMLAttributes minus children / defaultValue /
onChange / onSubmit / title — the last two are DOM event names and the custom
props of the same name would not otherwise be assignable. The root carries
role="dialog" (or role="group" when modal is false), aria-labelledby pointing at
the headline, tabIndex={-1}, data-phase and data-variant.
- variant: "cover" | "sheet" | "biometric", default "cover". cover fills its
container; sheet is a panel that sits on the bottom edge of the screen it guards;
biometric opens on a scan target and falls back to the same keypad. All three run
one state machine.
- length (default 6, clamped 4..12) is both the render-loop bound for the dots and
the completion trigger, so clamp it rather than trusting it.
- value / defaultValue / onChange: controlled and uncontrolled both supported. The
code is a digits-only string; anything a parent passes is stripped of non-digits
and clipped to length, so the component can never show or emit a non-passcode.
- onSubmit(code) => boolean | Promise<boolean> is the only verifier. It belongs to
the consumer, so the real passcode stays on the server or in the keychain and
never ships in the bundle. true unlocks, false burns an attempt, a rejection is
"could not check".
- onBiometric?() => boolean | Promise<boolean>. Omit it and the biometric key is not
rendered at all. biometric: "face" | "fingerprint" picks glyph and wording.
- maxAttempts (default 6, clamped 1..99) and backoff (default [0, 0, 30, 60, 300],
seconds after failure 1, 2, 3 …, last entry repeats). Sanitise the table: a NaN or
negative entry would end the cooldown instantly.
- defaultAttempts / defaultCooldown restore a backoff that outlived a reload — read
once at mount, never as a control channel. Without them a reload walks straight
out of the punishment.
- Callbacks: onUnlock, onAttempt(failures, cooldownSeconds), onLockedOut, onRecover
(the only way out of the terminal state; omit it and that state is a dead end on
purpose), onCancel (renders a Cancel affordance and is what Escape calls).
- title, hint and a labels object covering every string (attemptsLeft "{n} tries
left.", cooldown "Try again in {t}.", digitsEntered "{n} of {t} digits entered",
wrong, lastAttempt, lockedOut, verifyError, biometricFailed, biometricPrompt,
checking, scanning, unlocked, usePasscode, recover, cancel, delete, retryReady),
so a locale never needs a fork.
- modal (default true) presents it as a real modal: focus in on mount, Tab
contained, focus back to the opener on unmount.
Behavior
- Phases: entry -> checking -> unlocked | wrong | cooldown | locked, plus scanning
for the biometric path. Mirror the phase onto data-phase.
- Completion is edge-triggered inside the key handler, not in an effect: the press
that fills the last dot calls onSubmit with the finished code. A busy ref is read
AND written synchronously in that handler, so a double tap can never submit twice.
- Which rejections cost an attempt is the heart of the component: a wrong code does,
a biometric refusal does NOT (the platform keeps its own biometric budget), and a
rejected onSubmit promise does NOT (a dead network must not lock a user out of
their own device — say "couldn't check that passcode" instead).
- After a wrong code: shake the dot row, clear the code, increment the failure
count, report it through onAttempt, then branch. Backoff 0 -> a wrong state held
~900ms that any keypress cancels early. Backoff > 0 -> cooldown. Failures at
maxAttempts -> locked, terminal, keypad replaced by the recovery panel.
- The cooldown counts down against a deadline stamped when it started, never by
decrementing a counter, and it re-reads that deadline every 250ms — a phone that
slept through the wait comes back honest instead of owing the user 40 seconds.
Never read the clock during render.
- During cooldown, checking, scanning, unlocked and locked, every key reports
aria-disabled and the handlers guard themselves. Never use the native disabled
attribute: the browser blurs a node the instant it becomes disabled, which would
drop focus to <body> from under the user's finger.
- Biometric: refusal reveals the keypad, keeps the failure count untouched, and
because the scan target unmounts in that moment, focus is handed to a deliberate
successor (the panel, or the recovery button) — and only when focus was already
inside the lock, so it can never be stolen from the rest of the page.
- Keyboard map, an equal path for every touch target: digits 0-9 append, Backspace
deletes the last digit, Shift+Backspace clears, Escape calls onCancel (or clears
when there is none), Enter submits a full code when the panel itself holds focus
(ignore it on a key, where it is that button's own activation), Tab reaches all
twelve keys and is contained while modal.
- Screen readers: the dots are aria-hidden decoration, so one polite role="status"
region carries everything — the digit count on each press, then checking, the
verdict, the cooldown and the lockout. The countdown line is role="timer" so the
seconds do not become a per-second announcement storm.
- Cleanup: the hold timer, the shake timer and the countdown interval are all
cleared on unmount and whenever the phase they belong to changes; async
verification checks a mounted ref before touching state.
- Positioning stays with the consumer: render it fixed inset-0 z-50 for a real lock,
or inside a drawer for step-up auth. The component only fills its container.
Rendering & styling
- Semantic tokens only: bg-background / bg-card / bg-muted / text-foreground /
text-muted-foreground / border / ring. The two things that matter most INVERT
rather than take a colour — the filled dots and the biometric scan target use
bg-foreground text-background. Colour is spent only on genuine semantics:
text-destructive for a wrong code and for the locked-out state.
- Radius ladder: rounded-2xl for the panel, keys and primary actions, rounded-full
for the dots and the scan target.
- Every key is 56px tall inside a 3-column grid capped at 17rem, so the whole pad
fits a 390px screen with room to spare; touch-action: manipulation kills the
double-tap zoom delay, and the root is select-none so a fast retry never paints a
selection.
- Safe area: pad with max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>,
0px)), <floor>) on all four edges of the cover and on the bottom + sides of the
sheet. Reading the custom property first is what lets a device-frame preview
simulate a notch on hardware that reports 0.
- Motion is decoration only: the shake and the key press scale are wrapped in
motion-reduce:animate-none / motion-reduce:transition-none. With animation off the
rejection still reads — destructive dots, destructive status line, live region.
Ship the shake keyframes in a <style href> so React 19 hoists and de-dupes them.
- Status line has a reserved min height so the layout never jumps between hint,
verdict and countdown.
Customization levers
- Presentation: variant picks cover / sheet / biometric. Drop the head glyph, or add
a clock or an emergency-call row above the keypad, without touching the machine.
- Policy: length, maxAttempts and the backoff table are the whole punishment curve —
[0, 0, 30, 60, 300] is iOS-ish, [0, 5, 15] is friendlier, [0] never cools down.
Persist onAttempt into storage and feed it back through defaultAttempts /
defaultCooldown for a backoff that survives a reload.
- Affordances: onBiometric, onCancel and onRecover each add or remove exactly one
control, so a kiosk lock with no way out is the same component with no props.
- Copy: every string is in labels, including the "{n}" / "{t}" templates.
- Density: key height, grid gap and dot size are the three numbers that scale it for
a tablet or a small phone; the head block and the keypad are separate flex
children, so moving the pad higher or lower is a padding change.
- Colour: the inverted surfaces are bg-foreground/text-background by design. If a
brand demands a tinted lock, swap those two for bg-primary/text-primary-foreground
and leave destructive alone.Concepts
- Keypad instead of keyboard — the component owns its digits so no software keyboard opens over the screen being unlocked, and the code never passes through an input where autofill, dictation or a password manager could reach it.
- Attempt budget vs sensor budget — only a wrong code increments the counter. A biometric refusal and a verification that never completed both leave the budget alone; the first has its own platform limit, the second is the network failing, not the user.
- Deadline countdown, not a decrementing counter — the cooldown stores the moment it ends and re-derives the remaining seconds every tick, so backgrounding the tab (or the phone sleeping) neither pauses nor cheats the wait.
- Terminal state with exactly one exit — past
maxAttemptsthe keypad is gone, not merely disabled; the only remaining control is the recovery action, and omitting it makes the refusal absolute by design. - Thumb-arc anchoring — the head block floats in the free space while the keys stay pinned to the bottom edge inside
env(safe-area-inset-bottom), which is what keeps the last row reachable one-handed and clear of the home indicator. - Focus hand-off on unmount — the two transitions that remove a focused control (scan target becoming a keypad, keypad becoming the refusal panel) move focus to a deliberate successor, and only when focus was already inside the lock.
Map Bottom Sheet
A map with a draggable sheet over it: detents, velocity settling, and a map that lifts its focal point by half of whatever the sheet covers.
Wallet Card Stack
A phone-screen wallet: cards overlap so only a tappable strip of each shows, tapping one lifts it to the top safe-area edge and tucks the rest under the bottom, and a downward drag — or Escape — puts it back.