Mobile

Passcode Lock

An app lock screen with keypad passcode entry, biometric fallback, escalating attempt backoff and a terminal locked-out state.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Delete, Fingerprint, LockKeyhole, ScanFace, ShieldAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The wrong-passcode shake ships with the component: React 19 hoists
 * `<style href>` into the head and de-dupes by href, so several locks on one page
 * still share a single copy of the keyframes.
 *
 * It animates `translate`, not `transform` — the filled dots scale at the same
 * moment, and the two properties compose instead of overwriting each other.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/passcode-lock.json

Prompt

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 maxAttempts the 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.

On This Page