Passkey Prompt
A passkey enrolment card that asks the device what it can do before offering anything, runs the WebAuthn ceremony you inject through an AbortSignal, and treats a closed sheet as a retriable answer instead of an error.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/passkey-prompt.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PasskeyPrompt" card (lucide-react for
icons and the shadcn cn() helper — clsx + tailwind-merge — for class merging; no
other runtime dependencies, and no WebAuthn call of its own).
Contract
- Export a forwardRef<HTMLDivElement, PasskeyPromptProps> card, the unions
PasskeyPromptStatus = "checking" | "prompting" | "waiting" | "success" |
"declined" | "password" | "unsupported" and PasskeyPromptReason = "created" |
"existing" | "cancelled" | "no-api" | "no-authenticator" | "refused", plus a
PASSKEY_COPY record holding every user-facing string.
- Props: account (required string, shown in every state); authenticator?
(default "this device" — the human name of the place the passkey lands, e.g.
"iCloud Keychain", "Windows Hello", "1Password"); onEnroll:
(signal: AbortSignal) => Promise<void> | void (required); probeAvailability?:
() => Promise<boolean> | boolean; status? plus reason? (the controlled pair);
onStatusChange?: (status, reason) => void (fires on every transition in both
modes); onUsePassword? and onDismiss? (each renders its button; the card never
hides itself); title?; timeoutMs? (default 60000, zero or less disables);
defaultFactsOpen?. className merged with cn(), remaining native div props
spread on the root, and the consumer's onKeyDown chained rather than replaced.
- onEnroll is the whole point of the contract: the component never calls
navigator.credentials.create() itself. Resolving means a passkey now exists;
rejecting reports the outcome and is triaged by DOMException name. Forward the
given signal into the WebAuthn call — Cancel, the timeout and unmount all
abort it, and a ceremony that ignores the signal keeps running behind a card
that has already moved on.
- probeAvailability answers "can a passkey be stored here at all". The default
is PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().
Resolve false = the device has no user-verifying authenticator; reject with
NotSupportedError / TypeError = this browser has no WebAuthn; reject with
SecurityError = the origin is not allowed to enrol. Three different sentences
because they have three different fixes.
- Every user-facing string lives in PASSKEY_COPY, with {account} and
{authenticator} placeholders filled at render time, so translating or
re-wording the card is one object instead of a hunt through JSX.
Behavior
- Machine: checking -> prompting | unsupported; prompting -> waiting; waiting ->
success | declined; declined -> waiting (Try again); prompting / declined /
unsupported -> password; password -> waiting (Try a passkey after all, offered
only when the probe has not already ruled this device out).
- "checking" is the initial status on purpose. The availability question is
asynchronous, so it cannot go through useSyncExternalStore (that needs a
synchronous snapshot), and a server has no idea what the visitor's device can
do. Run it in an effect, client-side only, and let SSR render "checking" —
the only honest answer, never a guessed "supported".
- Triage by DOMException name: InvalidStateError -> success / "existing" (the
authenticator already holds a credential for this account, so the visitor is
already done and it would be a lie to call that a failure);
NotSupportedError / TypeError -> unsupported / "no-api"; SecurityError ->
unsupported / "refused"; everything else, NotAllowedError included ->
declined / "cancelled".
- Declined is a first-class answer, not an error. Browsers deliberately merge
"the visitor closed the sheet" and "the ceremony ran out of time" into one
NotAllowedError, because telling them apart would leak whether a human was
standing there; the copy says exactly that instead of guessing, the strip
stays neutral (never destructive — nothing broke), and the primary action
becomes Try again. Only the "refused" reason, a site misconfiguration, gets
the destructive strip.
- Cancellation is real. Each ceremony gets its own AbortController; Cancel, the
timeoutMs timer and unmount all abort the signal, clear the timer and bump a
generation counter, so a promise that settles late can never revive a settled
card. The timeout takes the same exit as Cancel — one path out, one message,
which is also the only honest one.
- One-shot: the press handler reads and writes a pendingRef in the same
synchronous turn, so a double click or a repeat-fired Enter opens exactly one
ceremony. Cancel works even when no promise is in flight, so a card parked in
"waiting" by a parent still has a live way out.
- Swapping `account` restarts the machine at checking — derived from a key
stored on the state record rather than reset inside an effect, so no frame
ever shows the previous account's answer — and the same effect abandons the
ceremony that was running for the old one.
- Focus never falls to <body>. Every transition records which of the card's own
controls held focus while it is still mounted; an effect afterwards checks
whether that node is still inside the card and, if the transition unmounted it
(Cancel disappearing, the actions row emptying on success), moves focus to a
deliberate successor: the primary action if one is rendered, otherwise the
outcome strip, which carries tabIndex -1 for exactly this purpose.
- Keyboard: Tab / Shift+Tab reach the disclosure toggle and every action in DOM
order, which is also the visual order (the primary button comes first);
Enter / Space activate them natively; Escape while waiting cancels the
ceremony and stops propagating, so a surrounding dialog does not close on the
same press. During a real ceremony the platform sheet is modal and owns the
keyboard, so that Escape matters for injected or simulated flows and for the
moment right after the sheet closes.
- Controlled mode: status and reason are one pair. The card still reports every
transition through onStatusChange, and a parent that ignores them pins the
card — including against the probe, which only transitions while the live
status is still "checking" and therefore never drags a card out of a state it
did not put it in.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border / shadow-sm for
the card, bg-primary/10 + text-primary for the fingerprint chip, border-primary/30
+ bg-primary/10 with a text-primary icon for the success strip, border-border +
bg-muted + text-muted-foreground for checking, waiting, declined, password and
unsupported, and border-destructive/30 + bg-destructive/10 with a text-destructive
icon for the "refused" reason alone. No hex, no rgb(), no palette class names.
- One live region (role="status" aria-live="polite") stays mounted in every
state and is empty while prompting: a status element that appears together
with its text is announced unreliably, an already-present one is not. The root
is role="group" with aria-labelledby / aria-describedby wired to useId ids,
and aria-busy while checking or waiting.
- Never the native disabled attribute on the primary button. It goes inert
underneath the visitor who just pressed it, and browsers blur a node the
moment it is disabled, which would drop a keyboard user on the page body
mid-ceremony. Use aria-disabled plus a guard in the handler, and style it with
the aria-disabled: variant.
- The "What is a passkey?" disclosure is a real button with aria-expanded and
aria-controls; its list stays mounted behind the hidden attribute so
aria-controls always resolves to something.
- The only motion is the strip / button spinner and the chevron rotation, both
carrying motion-reduce:animate-none and motion-reduce:transition-none. With
animation off the copy plus aria-busy still carry the pending state, and every
path through the card is buttons and keys — there is no gesture to miss.
- Nothing in the card is derived from a clock: the one time value is a duration
handed to setTimeout. If you add a "saved on" line, take the instant as a prop
and format it with a fixed locale and time zone, or server and client will
disagree.
Customization levers
- Copy and locale: PASSKEY_COPY is the single edit point — translate it, shorten
the four plain-words facts, or swap in your help-centre wording. Keep the
{account} / {authenticator} placeholders and the fill() helper keeps working.
- Authenticator naming: pass authenticator from whatever you already know about
the visitor ("iCloud Keychain", "Windows Hello", "your phone"); it appears in
the intro, the facts, the waiting label and the success line at once.
- Density: drop the shadow and the fingerprint chip for an inline settings-row
look, widen max-w-md to a full panel for an onboarding step, or start with
defaultFactsOpen for audiences that have never seen a passkey.
- Which exits exist: omit onUsePassword for a passkey-only flow, omit onDismiss
for a card that must be answered, and remember the dismissal in the parent so
the offer can come back later rather than never.
- Timing: timeoutMs shortens or removes the budget — pair a short one with a
ceremony that really honours the signal, or set 0 and let the browser's own
timeout produce the NotAllowedError.
- Reason set: adding a case (for example an enterprise policy block) means one
entry in PasskeyPromptReason, one line in PASSKEY_COPY, and one line in the
triage table; the machine itself does not change.
- Beyond enrolment: the same shell drives a sign-in ceremony by pointing
onEnroll at navigator.credentials.get() and re-wording PASSKEY_COPY —
the states, the abort handling and the fallback all stay as they are.Concepts
- Consumer-owned ceremony — the card never touches
navigator.credentials.create(). It renders a state machine over a promise you hand it and passes anAbortSignalback, so the same card drives a real ceremony, a wrapper with retries, and a demo that never touches a security key. - Checking as the honest server answer — availability is an asynchronous question, so it cannot ride
useSyncExternalStore, and no server can answer it. The card opens inchecking, runs the probe in an effect, and only then claims support — instead of rendering an offer that a device without a screen lock can never accept. - Declined is not an error — a closed sheet means the visitor said no, so it gets the same quiet strip as everything else, plus a retry. Browsers refuse to say whether a
NotAllowedErrorwas a dismissal or a timeout, so the copy says that too rather than inventing a cause. - Abortable ceremony with a generation counter — every attempt owns an
AbortControllerand a run id. Cancel, the timeout and unmount abort the signal and bump the id, so a late resolution (or a consumer who ignored the signal) arrives too late to touch a card that already settled. - Reason refines status —
statussays where the machine is,reasonsays why, and the message is picked by reason first and status second. That is how oneunsupportedstate carries three different repair paths, and howInvalidStateErrorbecomes a success rather than a failure. - Focus handoff on unmount — controls disappear as the machine moves (Cancel when the ceremony ends, the whole row on success). The card remembers which of its own controls held focus, notices when that node has gone, and hands focus to the primary action or to the outcome strip — never to the page body.
Exit Intent
A leave-detector and its surface — a pointer crossing the top edge or a fast scroll-up opens a dismissable payload once per session, and never over a field somebody is typing into.
Announcer
A screen-reader announcement centre — two always-mounted live regions, a queue that spaces messages out, an invisible marker that forces repeats to be read, and a timed clear.