A button that asks for browser notification permission at a moment you choose, and refuses to ask when asking cannot work. Reach for it wherever a notification is the point rather than the decoration: a chat, support inbox or team channel that needs to reach somebody who has tabbed away, an alert or on-call dashboard, a long export, build, render or import that finishes minutes later, a CI run or deploy a developer is waiting on, a calendar or meeting reminder, a reminder, habit or medication app, a delivery or order-status page, a live auction or bid about to close, the other player's turn, a price or stock watch, and the settings screen where somebody goes looking for the switch afterwards. Common asks it answers: "notification permission react", "Notification.requestPermission react", "ask for notification permission button", "browser notification permission component", "request notification permission shadcn", "useNotificationPermission hook", "web notifications react hook", "enable notifications button react", "notification permission denied forever", "can't ask for notification permission again", "Notification.requestPermission does nothing", "requestPermission resolves denied immediately", "how to reset notification permission", "notification permission three states", "default granted denied notification", "detect when user allows notifications", "listen for notification permission change", "navigator.permissions.query notifications", "new Notification does not show anything", "Notification constructor silently fails", "notification not showing but no error", "requestPermission callback vs promise", "notification permission in iframe", "notification permission ios safari", "notifications only work in installed pwa", "showNotification service worker react", "two enable notification buttons out of sync", "should i ask for notification permission on page load". shadcn/ui ships nothing that touches notifications: measured against the live catalogue on 2026-10-28 (65 items indexed, the 62 fetchable ones concatenated to 223,287 bytes of source), Notification, requestPermission, navigator.permissions, PermissionStatus, permission, serviceWorker, showNotification and isSecureContext are all zero hits. pulld has nothing either — the single occurrence of the word anywhere in the registry is toast's aria-label="Notifications", which is a string and not an API. It is the asking half of the family that pulld install-prompt and geolocation-button belong to, and the loud counterpart to pulld tab-title-badge, which tells a reader something is waiting without asking anyone for permission at all; on iOS, where notifications are reachable only from an installed app, install-prompt is the route to get there first. The reason to install this rather than write the four lines is that the four lines spend a thing you only get once, and the platform gives almost no sign of it. Measured in Chrome 152 rather than remembered. One: on an origin that has already been blocked, Notification.requestPermission() resolved "denied" in 0.3 milliseconds, with no prompt and without needing a user gesture to do it, then did the same three more times in under a tenth of a millisecond each. There is no retry and no second chance — the call is a lookup wearing the costume of a request — so a "Try again" button is decoration, and the only honest interface for that state says the browser will not ask again and points at site settings. This component refuses the press itself rather than handing it to an API that will answer instantly and identically forever. Two: new Notification("hello") on a blocked origin does not throw. It constructs, returns an object, displays nothing, and fires an error event instead — measured: onerror fired within 600ms while onshow and onclose did not. So the natural `try { new Notification(t) } catch {}` reports success to a caller whose user is seeing nothing, permanently. notify() here checks the permission before constructing anything and reports from the event, never from the absence of an exception; when neither event arrives inside a second it answers "unknown" rather than claiming success it did not observe. Three: a sandboxed or cross-origin embed reads exactly like a refusal. Measured in an iframe with sandbox="allow-scripts": location.origin is the string "null", Notification is still a function, permission reads "denied", and requestPermission() resolves "denied" with no prompt — identical to somebody who pressed Block, and the opposite advice. Telling that user to fix their site settings sends them hunting for a switch that does not exist, so denied carries a reason, "blocked" or "embedded", and the copy follows it. Four: Notification.permission is an accessor on the constructor (measured: a getter, no value), so it is never stale and will never tell React it changed. Reading it during render is a hydration mismatch as well, so the first paint is deterministic and an effect corrects it — the button renders nothing until it knows, rather than flashing an offer at somebody who accepted months ago. The live value arrives through navigator.permissions.query({ name: "notifications" }), which hands back a real PermissionStatus with a change event, which is what turns "change it in your settings" into advice that visibly works instead of a button that stays dead until a reload. A descriptor a browser does not recognise rejects with a TypeError rather than answering, so not knowing is handled as an ordinary answer and every path works without it. Five: requestPermission has two signatures and must be called once. The promise form is what runs today (measured: arity 0, returns a real Promise), but the callback form is older and is the only one some browsers shipped, where the call returns undefined and `await undefined` coerces to "not granted" — throwing away an answer the person gave. The obvious repair, await the promise and call again with a callback if it was undefined, asks twice: two prompts for one press, the second spending the chance the first was still waiting on. The deprecated callback is an optional trailing argument of the promise form, so one call serves both. Concurrent presses collapse too — the permission is one fact about the origin, not a property of a button, so a request in flight is shared and two controls on one page settle together. The API is the hook and the button. useNotificationPermission() returns status ("default" | "granted" | "denied" | "unsupported"), canAsk, deniedReason, isRequesting, isResolved, request() and notify(); request() resolves "granted", "denied", "dismissed" — a prompt closed without an answer stores nothing and really can be asked again, which is the one denial worth offering a retry for — or "unavailable". notify(title, options) prefers an existing service-worker registration where there is one and registers none of its own, falling back to the constructor. NotificationPermissionButton takes label, requestingLabel, grantedLabel, deniedLabel, unsupportedLabel, hideWhenGranted, iconOnly, hideMessage, per-situation messages, containerClassName and onOutcome. isNotificationSupported and isOpaqueOrigin are exported for the parts worth overriding. Accessible by construction: the message region is mounted empty from the first render rather than inserted when something happens, because a live region that arrives already holding its text is not reliably announced; the refusal is aria-disabled rather than disabled, so the control keeps its place in the tab order and the one explanation of why notifications are off reaches the people who most need it; the button carries aria-busy while the prompt is open, icons are aria-hidden, icon-only keeps the label as the accessible name, and a caller's own aria-describedby is merged rather than overwritten. Styled with shadcn tokens (input, accent, ring, muted-foreground) so it follows light and dark, className merges rather than fights, server-rendering safe, one file, no notification library.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/notification-permission.json"