Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, CircleCheck, CircleSlash, Fingerprint, KeyRound, LoaderCircle, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * checking    — asking the device whether it can hold a passkey at all.
 * prompting   — the resting state a visitor answers.
 * waiting     — the ceremony is in flight; the platform sheet owns the screen.
 * success     — a passkey exists for this account on this authenticator.
 * declined    — the sheet closed without one. Not an error: it is retriable.
 * password    — the visitor picked the password route; the caller routes on.
 * unsupported — no passkey can be created here, whatever the visitor does.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/passkey-prompt.json

Prompt

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 an AbortSignal back, 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 in checking, 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 NotAllowedError was 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 AbortController and 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 statusstatus says where the machine is, reason says why, and the message is picked by reason first and status second. That is how one unsupported state carries three different repair paths, and how InvalidStateError becomes 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.

On This Page