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 nothing has failed yet).
- `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,
and prefix every name with the separator before joining so `[]` and `[""]`
produce different keys — an empty-string name (a `usePermission(selected)`
whose selection still defaults to `""`) gets a result like any other unknown
name instead of `undefined`.
- Capability detection must NOT happen during render: reading `navigator`
while rendering breaks hydration. Use
`useSyncExternalStore(noopSubscribe, detect, () => null)` and map `null` to
`state: "unknown"`. The server (and the hydrating first paint) have not
been able to ask yet, which is exactly what `unknown` means — reporting
`unsupported` there would flash "your browser cannot report this" on every
SSR load in browsers that do have the API. Only a client snapshot of
`false` becomes `unsupported` with `reason: "no-api"`; 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 anullserver snapshot instead of during render, so SSR markup and the first client paint agree onunknown("not answered yet"), and React fills in the real answer right after hydration.unsupported/no-apionly ever comes from the client's own probe.
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.