@wcstack/notification
v4.5.0
Published
Declarative desktop-notification component for Web Components. Framework-agnostic Notifications API wrapper via wc-bindable-protocol, with Service Worker support.
Downloads
4,812
Maintainers
Readme
@wcstack/notification
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
@wcstack/notification is a headless desktop-notification component for the wcstack ecosystem.
It is not a visual UI widget.
It is an async primitive node that turns the Notifications API into reactive state and a state-driven command — the same way @wcstack/geolocation turns the device's location into reactive state.
With @wcstack/state, <wcs-notify> can be bound directly through path contracts:
- command surface:
request,notify,close,closeAll - input surface:
notice(reactive show),mode,body,icon,badge,tag,lang,dir,require-interaction,silent,renotify,manual - output state surface:
permission,granted,denied,prompt,unsupported,error,errorInfo,clicked,closed,shown
@wcstack/notification follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
NotificationCore) handles permission, showing (constructor or Service Worker), and click relaying - Shell (
<wcs-notify>) connects that to DOM attributes, the reactivenoticeinput, and lifecycle - Binding Contract (
static wcBindable) declares observableproperties,inputs, andcommands
Why this exists — both directions in one tag
Most wcstack IO nodes lean one way: <wcs-permission> only watches (it has no commands, because the Permissions API has no request()); <wcs-speak>/<wcs-listen> split a duo across two tags. The Notifications API is different — it is genuinely bidirectional in a single API:
- show is a command (state → element):
notify(title, options). - click / close / show are events (element → state): the user interacting with the OS notification.
So <wcs-notify> is the first wcstack node where the command-token (show) and event-token (click) directions live together in one tag. And unlike <wcs-permission>, the Notifications API does have Notification.requestPermission(), so this node is self-contained: it both requests/monitors the permission and shows notifications.
Secure context required. The Notifications API only works in a secure context (HTTPS, or
localhost). Where it is absent,<wcs-notify>reportspermission = "unsupported"instead of throwing. Since 4.5 it does so on a page that is not a secure context, too, where Chromium keeps theNotificationconstructor and answers"denied", the same as a user's refusal;notify()says why inerror. Requesting permission and showing also typically require a user gesture — firingnotifyfrom a timer may show nothing.
Install
npm install @wcstack/notificationQuick Start
1. Ask, then show — from state
<script type="module" src="https://esm.run/@wcstack/notification/auto"></script>
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<wcs-state>
<script type="module">
export default {
$commandTokens: ["request", "notify"],
$eventTokens: ["opened"],
ask() { this.$command.request.emit(); },
send() { this.$command.notify.emit("New message", { body: "Tap to open", tag: "chat", data: { room: 7 } }); },
$on: {
opened: (state, event) => { console.log("clicked", event.detail); }, // { tag, data, action }
},
};
</script>
</wcs-state>
<wcs-notify data-wcs="
command.request: $command.request;
command.notify: $command.notify;
eventToken.clicked: opened
"></wcs-notify>
<button data-wcs="onclick: ask">Allow notifications</button>
<button data-wcs="onclick: send">New message</button>The positional args of notify.emit(title, options) pass straight through to notify(title, options) — the same argument-forwarding contract used by <wcs-speak>/<wcs-fetch>.
2. Reactive notice vs imperative notify
<!-- reactive: shows whenever the bound value *changes* (same-value guard). -->
<wcs-notify data-wcs="notice: statusMessage | debounce(1000)"></wcs-notify>notice is the declarative counterpart of notify: writing a changed value shows it; an identical write is suppressed. The imperative notify command fires every call (even the same text). Auto-firing a notification on every state change risks spam, so debounce the bound source and prefer a tag so the OS de-dups.
3. Permission as bindable state
<wcs-notify data-wcs="permission: notifyPerm; granted: canNotify"></wcs-notify>
<!-- one boolean, straight from the node -->
<div data-wcs="hidden: canNotify">Allow notifications to get alerts.</div>permission is "prompt" / "granted" / "denied" / "unsupported". The Notifications API's own "default" is normalized to "prompt", so this node shares the exact four-value surface of @wcstack/permission / @wcstack/geolocation.
4. Reading the click
<wcs-notify data-wcs="command.notify: $command.notify; eventToken.clicked: opened"></wcs-notify>clicked / closed / shown carry { tag, data, action }. tag identifies the notification (your options.tag, or a generated wcs-<n> when omitted); data is whatever you passed in options.data; action is the Service Worker action-button id (always "" for the constructor backend).
See examples/state-notification-chat for the full demo.
Service Worker / mobile
new Notification() works on desktop only. On Android Chrome it throws, and ServiceWorkerRegistration.showNotification() is required. <wcs-notify> picks the backend per mode:
| mode | Behavior |
| ------------- | --------------------------------------------------------------------------------- |
| auto (default) | Try the Notification constructor; on a TypeError (mobile), fall back to the SW. |
| constructor | Constructor only; a TypeError surfaces as an error (no fallback). |
| sw | Always ServiceWorkerRegistration.showNotification(). |
The SW's notificationclick fires inside your Service Worker, which this package cannot inject into. Import the one-line helper so clicks relay back to the page:
// your sw.js
import { wireNotificationClicks } from "@wcstack/notification/sw";
wireNotificationClicks();It relays each click over BroadcastChannel("wcs-notify") (primary) and clients.postMessage (fallback); NotificationCore on the page de-dups the two transports and emits wcs-notify:click. The notification's own data is { __wcsId, payload }, where payload is the data you passed to notify() — the click event gives you payload back, but a Service Worker that reads event.notification.data itself reads the wrapper.
Attributes / Inputs
| Attribute | Type | Default | Description |
| --------------------- | ------- | ------- | --------------------------------------------------------------------------- |
| mode | string | auto | Show backend: auto / sw / constructor. |
| body | string | "" | Notification body text. |
| icon | string | "" | Icon URL. |
| badge | string | "" | Badge URL (monochrome, mobile). |
| tag | string | "" | Notification tag (the OS replaces a notification with the same tag). |
| lang | string | "" | Language tag. |
| dir | string | "" | Text direction: auto / ltr / rtl. |
| require-interaction | boolean | false | Keep the notification visible until the user dismisses it. |
| silent | boolean | false | Suppress sound/vibration. |
| renotify | boolean | false | Re-alert when replacing a same-tag notification. |
| manual | boolean | false | Mute the reactive notice path (the notify command still works). |
notice is a reactive input (no attribute): writing a changed value shows a notification. Per-call notify(title, options) options win per-key over these attribute defaults.
null and undefined. The inputs backed by an attribute (mode, body, icon, badge, tag, lang, dir, requireInteraction, silent, renotify, manual) take null as "clear": the attribute is removed and the input falls back to its default. undefined means "no value supplied": the attribute goes back to what it was before the first write — the value written in the markup, or none. Neither is ever written as the string "null" / "undefined". (@wcstack/state never writes undefined; React 19 does when a prop that had a value is removed, and so can a direct assignment.) notice ignores both — no value is not a notification.
Observable Properties (outputs)
| Property | Event | Description |
| ------------- | ------------------------------ | ----------------------------------------------------------------- |
| permission | wcs-notify:permission-change | "prompt" / "granted" / "denied" / "unsupported", live. |
| granted / denied / prompt / unsupported | wcs-notify:permission-change | Convenience booleans derived from permission. |
| error | wcs-notify:error | { error, message } on a failure (never-throw), else null. |
| errorInfo | wcs-notify:error-info-changed | Serializable failure taxonomy (WcsIoErrorInfo: stable code / phase / recoverable) on a failure, else null. Additive — derived from error, whose shape is unchanged. |
| clicked | wcs-notify:click | { tag, data, action } of the last click (event-token source). |
| closed | wcs-notify:close | { tag, data, action } of the last close. |
| shown | wcs-notify:show | { tag, data, action } of the last shown notification. |
Commands
| Command | Description |
| ------------ | --------------------------------------------------------------------------------- |
| request() | Notification.requestPermission(); resolves to the normalized permission state. |
| notify(title, options?) | Show a notification; returns its identifying tag. |
| close(tag) | Dismiss the notification(s) with tag. |
| closeAll() | Dismiss every notification this element has shown. |
CSS styling with :state()
<wcs-notify> reflects five boolean output states onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| State | On when |
|-------|---------|
| granted | wcs-notify:permission-change fires with "granted" |
| denied | wcs-notify:permission-change fires with "denied" |
| prompt | wcs-notify:permission-change fires with "prompt" |
| unsupported | wcs-notify:permission-change fires with "unsupported" |
| error | wcs-notify:error fires with a non-null detail (cleared on null) |
granted / denied / prompt / unsupported are a mutually exclusive
group: each permission-change event sets exactly one of the four and
clears the other three in the same pass.
wcs-notify:state(denied) ~ .fallback { display: block; }
wcs-notify:state(unsupported) ~ .fallback { display: block; }
form:has(wcs-notify:state(error)) .banner { display: block; }Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-notify> itself keeps working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-notify:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["granted"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes ontodata-wcs-state-granted/data-wcs-state-denied/data-wcs-state-prompt/data-wcs-state-unsupported/data-wcs-state-errorattributes on the element, so the Elements panel highlights them as they toggle:<wcs-notify debug-states></wcs-notify>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attributes exist purely to make state changes visible while debugging with
DevTools open; they are not a supported styling hook.
Notes & limitations
- Notifications outlive the page. Disconnecting
<wcs-notify>(or callingdispose()on the Core) detaches its subscriptions but does not close open notifications — a notification is meant to persist past the page. Useclose/closeAllto dismiss. modeis fixed at connect time. The backend is chosen once, when the element connects (observe(mode)); there is noobservedAttributesentry formode. Changing themodeattribute on an already-connected element has no effect until you re-connect it (remove and re-insert).- The SW backend does not relay
close. The@wcstack/notification/swhelper wiresnotificationclickonly, notnotificationclose. So under theswbackendclosed/wcs-notify:closenever fires (there is no cross-worker relay for close).clickedstill works via the click relay;closedis a constructor-backend-only signal. - Push API is out of scope. This package wraps the Notifications API (local notifications). Server-initiated Push is a separate concern.
- Silent failure handling (zero-log). Consistent with wcstack's zero-dependency philosophy,
<wcs-notify>never logs or throws. A missing API →permission = "unsupported"; a not-granted permission or a show failure → theerrorproperty. Binderror/permissionto react. The one exception: since 4.5 anotify()refused because the permission is not granted also warns once per element ([wcs/command-not-ready]), since the notification is lost and only a page that bindserrorheard of it — ask withrequest()first. errorInfotaxonomy (additive). Alongsideerror,<wcs-notify>publishes a serializableerrorInfo(wcs-notify:error-info-changed) that classifies the same failure into a stableWcsIoErrorInfo(code/phase/recoverable), without changing theerrorshape. The Core's own stable error code maps straight through:unsupported→capability-missing(phaseprobe); a not-granted permission →not-allowedand a non-string title →invalid-argument(both phasestart); a show failure →show-failedand a missing Service Worker backend →no-service-worker(both phaseexecute); any unexpected code folds defensively tonotify-error(phaseexecute). All six arerecoverable: false.errorInfotransitions exactly whenerrordoes (cleared tonullon recovery); the sharedWcsIoErrorInfotype and theWCS_NOTIFY_ERROR_CODEconstants are exported.- SSR (
@wcstack/server). Declaresstatic hasConnectedCallbackPromise = trueand exposesconnectedCallbackPromise, so the server renderer waits for the connect-time permission probe before snapshotting.
Configuration
<wcs-notify> ships a click autoTrigger that is enabled by default. When enabled, the first <wcs-notify> to connect installs a single document-level click listener. A click on any element carrying the trigger attribute (data-notifytarget="<id>") calls notify() on the <wcs-notify> with that id:
<wcs-notify id="app-notify"></wcs-notify>
<button data-notifytarget="app-notify" data-notifybody="You have 1 new message">
Notify me
</button>- The title is
data-notifytitleif present, otherwise the trigger element's trimmedtextContent; the body is the optionaldata-notifybody. - Because it is a document-wide click listener installed by default, disable it if you do not use the shortcut. Configuration is applied through
bootstrapNotification(userConfig?)(which registers the element and applies the config in one call);getConfig()reads back the effective, deep-frozen config:
import { bootstrapNotification, getConfig } from "@wcstack/notification";
// Register <wcs-notify> and opt out of the document click listener:
bootstrapNotification({ autoTrigger: false });
// Or rename the trigger attribute:
bootstrapNotification({ triggerAttribute: "data-notify" });
getConfig(); // read the (deep-frozen) effective config| Config key | Type | Default | Description |
| ------------------ | --------- | ------------------- | ----------------------------------------------------------------- |
| autoTrigger | boolean | true | Install the document-level click listener for data-notifytarget. |
| triggerAttribute | string | data-notifytarget | Attribute a clickable element uses to target a <wcs-notify> by id. |
| tagNames.notify | string | wcs-notify | Custom-element tag name to register. |
Call setConfig() / bootstrapNotification() before the elements connect for the change to take effect.
bootstrapNotification() throws on an option it does not have, on a value whose type differs from the option's default (null, or an array where an object is expected, included), and on a tagNames key it does not define or a tag name that is not a string. It checks every option before applying any, so nothing is applied when it throws. An option whose value is undefined is skipped.
Headless usage (NotificationCore)
The Core has no DOM dependency and can be used directly with bind() from @wc-bindable/core:
import { NotificationCore } from "@wcstack/notification";
const notify = new NotificationCore();
await notify.observe(); // start watching permission + click relays
await notify.request(); // ask the user
notify.addEventListener("wcs-notify:click", (e) => {
console.log((e as CustomEvent).detail); // { tag, data, action }
});
const tag = notify.notify("Hello", { body: "world", data: { room: 1 } });
// later:
notify.close(tag);
notify.dispose(); // detach subscriptions (open notifications stay)The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
Accessibility
WCAG 2.2.1 Timing Adjustable: a desktop notification is timed content — the OS decides how long it stays visible, and the user may miss it entirely (permission denied, do-not-disturb, or simply looking elsewhere). Never make a notification the only place information appears: mirror it in the page (a role="log" line, as examples/state-notification-chat does), and let the user reach the same content or action later. requireInteraction in the notify options keeps a notification on screen until dismissed where the platform honors it — use it when the content genuinely must not expire.
License
MIT
