Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Bell, Camera, Check, Clipboard, Loader2, MapPin, Mic, ShieldAlert, ShieldOff } from "lucide-react"
import { cn } from "@/lib/utils"

/** The browser capabilities this card knows how to explain. */
export type PermissionKind = "camera" | "microphone" | "geolocation" | "notifications" | "clipboard-read"

/**
 * idle        — nothing asked yet; the card is the *pre*-permission explainer.
 * requesting  — the consumer's onRequest is in flight (the native prompt is up).
 * granted     — the capability is usable.
 * denied      — the visitor (or a prior visit) blocked it; browsers won't re-ask.

Installation

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

Prompt

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 onDismiss deliberately does not hide the card by itself: the parent decides when to try again.
  • Consumer-owned request — the component never touches getUserMedia, getCurrentPosition, Notification.requestPermission or clipboard.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/SecurityError vs NotAllowedError) split the two, and only the first one hides the settings instructions.
  • Two-tier browser probenavigator is unreadable during render, so the synchronous "does the API exist" check goes through useSyncExternalStore with a true server snapshot (SSR ships the explainer, only genuinely unsupported browsers downgrade after hydration), while the asynchronous navigator.permissions.query() lives in an effect and subscribes to PermissionStatus's change event 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.

On This Page