usePermission
Subscribes to navigator.permissions for one name or a whole panel of them — query only, never request — folding Firefox's rejections, Safari's gaps, unknown names and insecure origins into one honest unsupported state with a reason.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-permission.jsonPrompt
Build a React + TypeScript "usePermission" hook (React only — no npm
dependencies; browser Permissions API).
Contract
- Two overloads over one implementation:
`usePermission(name)` → one result object;
`usePermission([name, ...])` → `Record<name, result>` with the argument's
literal names as keys (generic `<N extends PermissionQueryName>` so
`perms.camera` type-checks and `perms.typo` does not).
- A result is `{ state, isSupported, error, query }`.
- `state` is `"granted" | "denied" | "prompt" | "unsupported" | "unknown"`.
granted/denied/prompt come verbatim from `PermissionStatus.state`;
`unknown` means the query has not answered yet; `unsupported` means this
environment cannot answer for this name at all.
- `error` is `{ reason, name, message }`, non-null only while `state` is
`"unsupported"`. `reason` is `"no-api"` (navigator.permissions missing),
`"unknown-name"` (the engine rejected the name with a TypeError) or
`"query-rejected"` (the name was understood, the query still failed).
`name` is the rejection's own name ("TypeError", "NotSupportedError", …) and
`message` is the engine's raw text — every engine words it differently, so
UI copy must branch on `reason` and never on `message`.
- `isSupported` is "this environment can answer for this name": false once the
API is missing or the query rejected. While `state` is `"unknown"` it is
optimistic (it only means the API exists).
- `query()` re-reads that one permission on demand and is referentially stable
for as long as the name stays in the argument.
- The accepted-name type is deliberately open: a union of the known literals
plus `(string & {})`, because the DOM lib's own `PermissionName` is missing
entries (no `clipboard-read`) and every engine ships a different subset. An
unknown name is a runtime `TypeError`, not a compile error.
Behavior
- QUERY ONLY, NEVER REQUEST. `navigator.permissions.query()` raises no dialog,
which is exactly why a whole panel of names can be read on mount. Asking has
to be done by the capability's own API from a real user gesture
(`Notification.requestPermission()`, `getUserMedia()`,
`getCurrentPosition()`), and this hook must not touch any of them — a
permission dialog is a one-shot budget, and a hook that spends it on mount is
a trap.
- The browser differences ARE the feature. Firefox rejects `camera` /
`microphone` outright instead of answering "denied"; Safari answers for only a
short list of names; any name outside an engine's enum throws a `TypeError`;
a non-secure origin does not expose `navigator.permissions` at all (the API is
secure-context only). Every one of those must land on
`state: "unsupported"` with the matching `reason` — the consumer never writes
a try/catch and never sees an unhandled rejection.
- `unsupported` must stay a separate state from `denied`. They demand opposite
copy: a denied permission has a settings page that fixes it, an unanswerable
one does not, and a "go to site settings" walkthrough for something Firefox
will never report is pure user-hostility.
- Wrap the call as `new Promise(resolve => resolve(navigator.permissions.query(
{ name })))`. `Promise.resolve(fn())` cannot catch a synchronous throw — the
exception escapes before `Promise.resolve` ever sees a value — and engines
have historically thrown synchronously on a bad descriptor.
- Subscribe to each resolved `PermissionStatus`'s `change` event so flipping the
site setting updates the UI with no reload, and `removeEventListener` on
unmount **and** whenever the requested names change. Also expose `query()`:
several engines stay silent when a permission is *revoked*, so a manual
re-read is the only honest escape hatch.
- Normalise the argument to a joined string key during render and derive the
name list from that key inside a `useMemo`. Consumers write inline arrays
(`usePermission(["camera", "microphone"])`), whose identity changes every
render; feeding that straight into the effect's dependency array re-queries
and re-subscribes on every render — measured at 66 `query()` calls and 65
listener attach/detach cycles across 30 renders, versus 2 and 2 once
normalised. Deduplicate while normalising so a repeated name is queried once.
- Capability detection must NOT happen during render: reading `navigator`
while rendering breaks hydration. Use
`useSyncExternalStore(noopSubscribe, detect, () => false)`. The server (and
the hydrating first paint) therefore report `state: "unsupported"` with
`isSupported: false` — the honest answer where there is no navigator — and
React swaps in the truth immediately after hydration.
- The query is async, so late resolutions must not land: keep a `mountedRef`
set to `true` **inside** the mount effect body (only clearing it in cleanup
leaves it false for the live instance under StrictMode's mount → cleanup →
mount) plus a per-effect `cancelled` flag, and drop any resolution that
arrives after either flips.
- The state updater must be pure (StrictMode double-invokes it) and must bail
out when the entry is unchanged, so a `change` event re-reporting the same
state does not re-render every consumer.
Rendering & styling
- The hook renders nothing. Consumers branch on `state`: a neutral badge for
`prompt`, a positive one for `granted`, `text-destructive` for `denied` with
per-permission recovery steps, and a muted "your browser cannot report this"
block for `unsupported` that offers no settings walkthrough. Use semantic
tokens only (`bg-primary`, `text-muted-foreground`, `border`,
`bg-destructive/10`, `text-destructive`), give any spinner
`motion-reduce:animate-none`, and keep the raw `error.message` in a
`font-mono break-words` block — never `break-all`, which collapses a column's
min-content width to one character.
- Never render a request button that the hook itself powers: if the UI asks for
something, the click handler must call the capability's own API.
Customization levers
- One name vs a list — a single gate (`usePermission("camera")`) or a whole
settings panel (`usePermission(["geolocation", "notifications", "camera",
"microphone", "clipboard-read"])`) from the same hook.
- Which names to include — anything the engine accepts, plus forward-compatible
strings (`"window-management"`, `"local-fonts"`) that simply report
`unsupported` where unknown.
- Copy per `error.reason` — the whole point of the three reasons is that they
deserve three different sentences; translate them in one map.
- Add a `refetchOnFocus` option (re-`query()` on `visibilitychange`) if visitors
are expected to edit permissions in another tab and your target engines do not
emit `change` on revoke.
- Need the full descriptor (`push` with `userVisibleOnly`, `midi` with
`sysex`)? Widen the argument from a name to a `PermissionDescriptor`; the
rest of the state machine is unchanged, and those names currently land on
`reason: "query-rejected"`.
- Pair it with an explainer card (`feedback/permission-prompt`) for the asking
half: this hook decides whether the card is still worth showing.Concepts
- Query, never request — reading the permission store raises no dialog, so a whole panel of names can be resolved on mount; asking is a one-shot budget that only the capability's own API may spend, from a real user gesture. That split is why this hook is safe to mount on a settings page and why it never imports
NotificationorgetUserMedia. unsupported≠denied— three failures collapse into one state but keep separate reasons:no-api(secure-context-only API absent),unknown-name(the engine's enum has no such member — Firefox forcamera/microphone),query-rejected(understood but refused, e.g. Chromium'spushwithoutuserVisibleOnly). A denied permission has a settings page; an unanswerable one does not, and offering a walkthrough for it is worse than saying nothing.- Rejection-to-state, never to the caller — every rejection is caught and turned into a state, and the query is wrapped in a
new Promiseexecutor rather thanPromise.resolve(...)so even a synchronous throw becomes a rejection instead of escaping. A consumer never writes atry/catchand never sees an unhandled rejection in the console. - Argument normalised to a string key — inline array arguments change identity every render; deriving the name list from a joined, deduplicated key is what keeps the subscription effect from re-arming. Measured in Edge: 30 re-renders cost 2 queries and 2 listeners after normalisation, 66 queries and 65 attach/detach cycles before it.
- Live subscription plus a manual re-read — the
changeevent keeps the UI honest when the visitor edits site settings mid-session (measured: the state flips granted → prompt with no re-render triggered by the app), whilequery()covers the engines that stay silent on revoke. - Hydration-safe detection —
navigator.permissionsis probed throughuseSyncExternalStorewith afalseserver snapshot instead of during render, so SSR markup and the first client paint agree onunsupported/isSupported: false, and React fills in the real answer right after hydration.
useRovingTabindex
A roving-tabindex hook that collapses a whole control group into one Tab stop — arrow keys inside, with horizontal/vertical/grid lanes, disabled-item skipping, loop-or-stop, Home/End and typeahead.
useWebShare
Opens the native share sheet and degrades honestly — a cancelled sheet is reported as dismissed rather than an error, files are checked with canShare first, and the fallback's own success or failure is reported as such.