Permission Prompt
A pre-permission explainer card that says why a capability is needed before the browser's one-shot dialog is spent, with per-permission recovery steps once it's blocked.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/permission-prompt.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PermissionPrompt" component
(lucide-react for icons; no other runtime dependencies).
Contract
- Export a forwardRef<HTMLDivElement, PermissionPromptProps> card plus the
types PermissionKind = "camera" | "microphone" | "geolocation" |
"notifications" | "clipboard-read" and PermissionPromptState = "idle" |
"requesting" | "granted" | "denied" | "unavailable".
- Props: permission (required, PermissionKind); reason (required ReactNode —
the sentence explaining why the app needs it, shown in every state);
onRequest: () => Promise<void> | void (required); state? (controlled);
onStateChange?: (state) => void (fires on every transition in both modes);
onDismiss? (renders a secondary "Not now" button; the card does NOT hide
itself — the caller owns that decision); title? (heading override);
autoProbe? (default true). className merged with cn(), remaining native div
props spread on the root.
- onRequest is the whole point of the contract: this component never calls
getUserMedia / getCurrentPosition / Notification.requestPermission /
clipboard.readText itself. The consumer performs the real request and
reports the outcome by resolving or rejecting. APIs that resolve with a
status instead of throwing (Notification.requestPermission resolves with
"denied") must be normalised by the consumer into a throw.
- All user-facing strings live in one exported PERMISSION_COPY record keyed by
PermissionKind: label, title, action, retry, one message per non-idle state,
and a recovery: string[] of settings steps. Translating or re-wording the
component means editing that one object, not hunting through JSX.
Behavior
- State machine: idle -> requesting -> granted | denied | unavailable, with
denied -> requesting again via a Retry button. "unavailable" means the API
itself is missing (no device, insecure page, old browser); "denied" means a
human said no and there is a settings page that can undo it. Keeping them
apart is what makes the recovery copy honest.
- Rejection triage: NotFoundError / NotSupportedError / OverconstrainedError /
SecurityError / TypeError => unavailable; anything else (NotAllowedError and
friends) => denied.
- Capability probe never runs during render — the server has no navigator and
reading it while rendering would desync hydration. Run it through
useSyncExternalStore with a noop subscribe and a getServerSnapshot that
returns true, so SSR ships the useful explainer and only browsers that
genuinely lack the API downgrade to "unavailable" one tick after hydration.
A missing API outranks the state machine: it renders "unavailable" even in
controlled mode, because there is no prompt to show.
- Live permission state (autoProbe) runs in an effect, not in
useSyncExternalStore: that hook needs a synchronous snapshot and
navigator.permissions.query() is a promise. Await it, ignore rejections
(Firefox throws for camera/microphone, Safari for most names — an
unanswerable probe is not an error, the card just stays idle), map
granted/denied/prompt onto the state machine, then subscribe to the
PermissionStatus "change" event so a mid-session revoke is reflected. Detach
that listener in the effect cleanup, and skip the whole mapping while the
state is "requesting" — the intermediate "prompt" the browser reports while
its own dialog is open must not reset an in-flight request.
- Two derivations keep the machine honest instead of an effect: (a) the
uncontrolled state is stored as { key: permission, state } and read back only
when the key still matches, so swapping the permission prop restarts at idle
without a frame of the previous capability's answer; (b) a latest-ref that
mirrors the *rendered* state (not the raw prop) is what a controlled card
whose parent ignores onStateChange needs, otherwise the optimistic
"requesting" write would silently block every later request.
- Async discipline: keep a mountedRef and set it to true in the effect *body*
(not only cleared in cleanup — StrictMode's mount/cleanup/mount would leave
it false forever), then re-check it after every await before touching state,
because the consumer's promise routinely outlives the card. Keep onRequest
and onStateChange in latest-refs so inline arrow props never re-run effects.
- Denied is the state that earns the component: show the per-permission
recovery steps as an ordered list plus a Retry button. The steps differ on
purpose — "open Site settings" is useless advice for a clipboard read, and
notifications are the only permission browsers refuse to re-prompt for at
all, so that copy has to say so.
Rendering & styling
- Semantic tokens only: bg-card/text-card-foreground/border/shadow-sm for the
card, bg-primary/10 + text-primary for the capability icon chip and the
granted strip, bg-destructive/10 + border-destructive/30 + text-destructive
for the denied strip, bg-muted + text-muted-foreground for requesting and
unavailable. No hex, no rgb(), no palette class names.
- One live region that stays mounted in every state (role="status"
aria-live="polite", empty while idle) — a status element that appears
together with its text is announced unreliably, an already-present one is
not. The card itself is role="group" with aria-labelledby/aria-describedby
wired to useId-generated ids, and aria-busy while requesting.
- The only motion is the spinner; it carries motion-reduce:animate-none, and
the "Waiting for your browser" copy plus aria-busy keep the pending state
legible without it.
- Buttons: primary = bg-primary, secondary = border-input outline, both with
focus-visible:ring-2 ring-ring and disabled:opacity-50. Both are disabled
while requesting, since the native dialog is modal anyway.
Customization levers
- Copy and locale: PERMISSION_COPY is the single edit point — translate it,
shorten the recovery steps, or replace the browser-specific wording with
screenshots of your own help centre.
- Permission set: PermissionKind + PERMISSION_COPY + the icon map are three
parallel records; adding "midi", "screen-wake-lock" or a product-level
pseudo-permission means adding one entry to each and extending probeSupport.
- Density: drop the shadow and the icon chip for an inline settings-row look,
or stretch max-w-sm to a full-width panel for an onboarding step.
- Probe strategy: autoProbe={false} turns the card into a pure presentational
state machine (useful in demos, storybooks, and when a parent already owns
the permission state); pair it with the controlled `state` prop to drive
transitions entirely from outside.
- Dismissal: omit onDismiss for a card the user must answer, or keep it and
have the parent remember the dismissal so the explainer can reappear later —
that deferral is the entire value of asking before the browser does.
- Beyond the five states: a "partially granted" case (camera allowed but no
microphone) is best modelled as two cards, not a sixth state, so each one
keeps its own recovery steps.Concepts
- Pre-permission priming — the card is shown instead of the browser dialog, not next to it. The native prompt only fires from
onRequest, once the visitor has read why, which is the whole point: a dismissed explainer costs nothing, a dismissed browser prompt costs the capability. - One-shot prompt budget — most browsers ask at most once per site and then remember a "no" forever. Treating that single ask as a budget is why "Not now" is a first-class action and why
onDismissdeliberately does not hide the card by itself: the parent decides when to try again. - Consumer-owned request — the component never touches
getUserMedia,getCurrentPosition,Notification.requestPermissionorclipboard.readText. It renders a state machine over a promise you supply, so the same card works for a real API, a wrapper with retries, or a mocked one in tests. - Denied vs unavailable — a human blocking the camera and a laptop having no camera look identical in a naive implementation and need completely different copy. Rejection names (
NotFoundError/SecurityErrorvsNotAllowedError) split the two, and only the first one hides the settings instructions. - Two-tier browser probe —
navigatoris unreadable during render, so the synchronous "does the API exist" check goes throughuseSyncExternalStorewith atrueserver snapshot (SSR ships the explainer, only genuinely unsupported browsers downgrade after hydration), while the asynchronousnavigator.permissions.query()lives in an effect and subscribes toPermissionStatus'schangeevent so a mid-session revoke is reflected — with the listener detached on unmount. - Per-permission recovery copy — the settings path back differs per capability (address-bar toggle, OS privacy pane, "notifications never re-prompt", "Firefox and Safari never grant clipboard reads"), so the recovery steps are part of the permission's copy record instead of one generic sentence.
Save Indicator
The autosave line from a document editor's toolbar — Saving… holds a minimum beat, Saved fades back to "Last saved 3 minutes ago", and an error stays put with a Retry.
Connection Status
An online / offline / reconnecting bar with an exponential-backoff retry countdown, a manual retry button and SSR-safe browser subscription.