You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Ariakit can neither say that something happened nor show it. Apps rebuild both, and the rebuilds fail the same way the shipped toast libraries do.
Why toasts are usually inaccessible
The usual pattern puts aria-live on the container holding the visible toasts, making it both the visual surface and the announcement channel, so every reorder, hide and re-render inside it becomes a candidate utterance. Sonner puts aria-live="polite" on the <section> holding the visible toasts (src/index.tsx); Polaris puts aria-live="assertive" on the toast element itself (Toast.tsx). Base UI puts role="region", aria-live="polite", aria-atomic="false" and aria-relevant="additions text" on the viewport (ToastViewport.tsx), and ships the resulting bug: visually hidden role="alert" mirrors of every priority: "high" toast remount whenever the viewport loses focus, re-announcing what the user heard.
The two-surface split, which is the load-bearing decision
The thing you see and the thing you hear are different elements: no visual surface, neither the region nor any card, carries an author-written live-region attribute. The announcer is a clipped host, created with the store and never removed, that takes text only by appending a new node, never by editing one in place. Fluent ships a clipped AriaLive component separate from its toast containers (react-toast/.../AriaLive/useAriaLive.ts, clipped in useAriaLiveStyles.styles.ts), and Primer ships it standalone as @primer/live-region-element.
With no author-written live region, hiding, reordering, remounting and re-rendering a card carry no announcement of their own. The remaining path is the card's own role, and it was measured: a role="alertdialog" card announced nothing on mount, remount or reorder on NVDA with Chrome 151 on Windows 11 and on VoiceOver with Safari 18.4 on Sequoia, and nothing on hide on NVDA, each silence bracketed by a positive control that spoke.
The utterance is composed from the record, never scraped from the rendered card, so announcements follow the app's causal order rather than the DOM. Radix splits the surfaces but scrapes its text from the rendered card; see "Workaround".
A notification that vanishes is a notification some users never received
Measured on VoiceOver: a live region is announced only while the VoiceOver cursor is inside the web area; parked in the menu bar, the identical DOM update was silent. Element.ariaNotify, the proposed replacement for offscreen live regions and this announcer's deferred backend, suppresses notifications when focus leaves the web content and withholds delivery confirmation on purpose.
The channel drops messages silently and no library can learn what it lost. Retention is not the answer: a record retained where nothing routes the user to it is, for that user, deleted, and a message the user cannot afford to miss must not be on a timer.
WCAG scopes this per message: the Understanding document for SC 2.2.1 works this widget at this duration, asks only whether the same information or function is reachable another way, and names the application's own Inbox as the alternative. So the library owns the timer and the app owns the judgment, once per push, in one field: the timeout removes the record through the dismiss button's path, and timeout: null says the record has no other route.
No operating system archives on a short timer, and each separates the transient banner from the durable record: WNS expires at seven days, iOS has no record timer at all. Two of fourteen surveyed toast libraries retain past dismissal, sonner through toast.getHistory() and react-toastify through useNotificationCenter, both outside the toast lifecycle; sonner's was uncapped and returned as sonner#729, fixed with a hard cap.
Scope
Two things ship: a live-region announcer, private to the module, and one floating notification region. A notification-center page and any store-owned archive are out of scope: the record does not outlive its timer, and the durable list is the app's own data.
v1 is React only, with the framework-agnostic store in @ariakit/components so a Solid port can follow. The module ships from @ariakit/react-components only, with no @ariakit/react re-export, like Tag, so v1 ships no ariakit.com/reference pages; see "Packaging and exports".
Four decisions stay open, each marked where it belongs: a React re-export of createNotificationStore, whether the two log regions share one key map, the exported name of the swipeDirection union, and a Notification outside a NotificationRegion. "Deferred, and why" lists what v1 must not build.
Prior issues this supersedes, and credit
This issue supersedes #464 and #2510: one asks for the visible surface, the other the audible one.
#464 was opened in October 2019 by @artalar, who also supplied the std-toast proposal, the inclusive-components notifications article, and the web.dev toast article as prior art. It carries design input from @folz (the Alert versus LiveRegion analysis, and the role="status" versus role="alert" question), @R-Oscar (proposing two live regions, with error notifications on role="alert" and the rest on role="status"), @gziolo (the WordPress Snackbar and the @wordpress/a11yspeak() function), and @strass (an exploratory implementation in #787).
#2510 was opened by @IanVS, asking for a Downshift-style status region so Select and Combobox announce their state, and suggesting an a11yMessage option on the select store. That request is why the announcer is a separate private module. Two parts are deferred: the wiring that makes Select and Combobox announce on their own, which waits on an i18n module, and a public announcer primitive.
@alvarlagerlof raised the question that decided store ownership, asking on X whether a module-scoped store leaks state between renders on a single server, quoted in #464. It does when app code writes during a render, so push during a server render is unsupported: it warns and does nothing, which leaves a module-scope store empty on a server by construction and is why both spellings ship, module scope for toast() from anywhere and the React hook plus a provider for apps that seed from the server or need two stores.
Usage example
Creating the store
The app owns the store and binds the record type once. The factory ships from @ariakit/components and the components from @ariakit/react-components, one file per subpath (packages/ariakit-components/package.json:46, packages/ariakit-react-components/package.json:264).
Module scope is the toast()-from-anywhere spelling: any module pushes with no hook, context or prop, and push with no DOM warns and does nothing, so the store is empty during SSR by construction, as the Shortcut design argues. Apps that seed from the server or need two stores call useNotificationStore<NotificationData>({ defaultItems: seed }) per tree, wrapping the core factory like useTagStore (packages/ariakit-react-components/src/tag/tag-store.ts:44). See "Store ownership and SSR".
NotificationList renders the <ol> and owns filter and limit, NotificationListItem renders the <li>, and limit defaults to 3, yielding the newest records oldest first. store={notifications} carries the record type into the render prop; custom fields are read from the closure, never spread onto Notification.
The five verbs
// Adds a record and announces it. Returns the id.constid=notifications.push("Message sent.");// `timeout: null` sets no time limit: only a dismiss, a swipe, `remove` or// `clear` takes the record away. Escape does not, because `removeOnEscape`// defaults to false on these unrecoverable records.notifications.push({heading: "Conversation deleted",message: "3 messages moved to Trash.",timeout: null,data: {onUndo: ()=>restoreConversation()},});// An existing id replaces that record in place and announces.notifications.push({id: uploadId,message: "7 of 12 files.",timeout: null});// Silent unless the announcement text changed; `options.announce` overrides// that in both directions.notifications.update(id,{message: "Message sent to 4 people."});notifications.update(uploadId,{message: "8 of 12."},{announce: false});notifications.remove(id);// The id is required.notifications.clear();// An utterance with no record: nothing rendered, nothing retained, no id.notifications.announce(`${results.length} results`);notifications.announce({message: "Disk is full.",priority: "assertive"});
push is the only verb that both stores a record and announces it, and timeout: null is the only retention spelling; see "Lifecycle and timers" for the default timer it opts out of.
Content, and children that win
NotificationHeading renders item.heading in a plain div and NotificationMessage renders item.message in a <p>; both mint the ids aria-labelledby and aria-describedby point at.
{/* Children win over the record value, and hold markup a string cannot. */}<NotificationMessage><strong>3 of 3</strong> files uploaded.</NotificationMessage>{/* Opt in to a real heading level, or to block content. */}<NotificationHeadingrender={<Heading/>}/><NotificationMessagerender={<div/>}/>
Anything durable is the app's own record
addMessage(message);// The app writes its own row first: the durable half.// The notification shares that row's id, so the card points at the row.notifications.push({id: message.id,heading: message.sender,message: message.preview,timeout: null,data: {href: `/threads/${message.threadId}`},});// The app's own UI takes over, so retract the card. The row stays.constonOpenThread=(id: string)=>notifications.remove(id);
No store survives a reload, since data holds closures such as onUndo, so anything durable is the app's own row, and sharing its id makes retraction a single remove(id), the contract the platforms use: the WHATWG tag, ToastNotificationHistory.Remove(tag) on Windows, and removeDeliveredNotifications(withIdentifiers:) on Apple.
Swipe
{/* Swipeable by default, because the card carries a NotificationDismiss, the alternative WCAG 2.5.1 and 2.5.7 each require. A gesture on Undo is ignored. */}<Notificationitem={item}><buttononClick={item.data?.onUndo}>Undo</button><NotificationDismiss>Dismiss</NotificationDismiss></Notification>{/* Opt out, or decide per gesture, which resolves at pointerdown. */}<Notificationitem={item}removeOnSwipe={false}/><Notificationitem={item}removeOnSwipe={(e)=>e.pointerType!=="mouse"}/>{/* touch-action follows swipeDirection, so the browser keeps the unused axis. */}<Notificationitem={item}swipeDirection={["up","down"]}/>
swipeDirection defaults to "end", logical on the inline axis. removeOnSwipe defaults to true whenever the card contains a data-notification-dismiss descendant, the attribute NotificationDismiss sets on itself. See "Gestures" for the styling hooks; Ariakit ships no CSS.
Types that must compile, and types that must fail
These two blocks are one type-test file, which must compile with exactly these errors, every error code measured on tsc 7.0.2 and 6.0.2 except the TS2344 on the failed generic constraint, which must be measured before this ships. data is optional exactly when {} is assignable to T; see "Types".
constplain=createNotificationStore();plain.push("Saved.");// OK: `message` is the last field the author supplies.plain.push({message: "Saved."});// OK: `{}` extends `unknown`, `data` optional.// OK: every field of `NotificationData` is optional, so the string arm stands.constnotifications=createNotificationStore<NotificationData>();notifications.push("Message sent.");notifications.push({message: "Deleted.",data: {onUndo: ()=>{}}});conststrict=createNotificationStore<{userId: string}>();strict.push({message: "Saved.",data: {userId: "1"}});// OK.// @ts-expect-error TS2345. The string arm is withdrawn: `data` is required.strict.push("Saved.");// @ts-expect-error TS2345. `data` is missing.strict.push({message: "Saved."});// Adding a required field to `T` withdraws the string arm from every// `push("text")` call site: a breaking change, not an addition.// @ts-expect-error TS2561, with a "Did you mean `message`?" suggestion.plain.push({mesage: "Saved."});// OK: the seed is validated, but the record type is NOT inferred from it.createNotificationStore<NotificationData>({defaultItems: []});// OK: the record carries the same guard, so `data` follows `T` there too.constseed: NotificationStoreItem<NotificationData>[]=[{id: "n1",message: "Restored.",createdAt: Date.now()}];constbadSeed: NotificationStoreItem<{userId: string}>[]=[// @ts-expect-error TS2322. `data` is required unless `{}` extends `T`.{id: "n1",message: "Restored.",createdAt: Date.now()},];// @ts-expect-error TS2353. `id` is not in the partial: re-keying desynchronizes// the caller's saved id and remounts the card.notifications.update("n1",{id: "n2"});// @ts-expect-error TS2353. `createdAt` is not in the partial: array position is// the only ordering.notifications.update("n1",{createdAt: 0});
NotificationList and NotificationItems take one signature generic over the store, StoreData<S>, not an overload pair; the diagnostics are under "Types". The cost is the one hole the pair closed: <NotificationList<NotificationStore<NotificationData>> limit={3}> stays expressible with no store prop.
// OK with no `store` prop: `item.data` is `unknown` here, never `any`.consta=<NotificationListlimit={3}>{(items)=>items.map((item)=><NotificationListItemkey={item.id}><Notificationitem={item}/></NotificationListItem>)}</NotificationList>;constb=<NotificationListlimit={3}>{(items)=>items.map((item)=>(// @ts-expect-error TS18046. `item.data` is `unknown` without `store`.<buttonkey={item.id}onClick={item.data.onUndo}/>))}</NotificationList>;// @ts-expect-error TS2344. `NotificationData` is not a store.constc=<NotificationList<NotificationData>limit={3}>{()=>null}</NotificationList>;typeWrong=NotificationStore<{userId: string}>;// @ts-expect-error TS2322. The annotation and the `store` prop disagree.constd=<NotificationList<Wrong>store={notifications}>{()=>null}</NotificationList>;// @ts-expect-error TS2322, with a "Did you mean `limit`?" suggestion.conste=<NotificationListstore={notifications}limt={3}>{()=>null}</NotificationList>;// OK: `store` types the render prop, so `item.data` is `NotificationData`.constf=<NotificationListstore={notifications}limit={3}>{(items)=>items.map((item)=><buttonkey={item.id}onClick={item.data?.onUndo}/>)}</NotificationList>;// OK: `NotificationItems` carries the same generic, for a badge count.constg=<NotificationItemsstore={notifications}>{(items)=><span>{items.filter((item)=>item.data?.onUndo).length}</span>}</NotificationItems>;
No library carries the generic through context: react-aria-components 1.20.0 declares UNSTABLE_ToastStateContext as Context<ToastState<any> | null>, and Base UI 1.6.0 and Ark UI 5.38.2 erase it too, so store={notifications} is the whole custom-fields story. Reads then degrade to unknown; writes through the erased store stay unchecked, as "Types" prices.
Requirements
Everything is settled unless a rule says otherwise in place. The four decisions "Scope" names as open are marked here where they belong, and "Deferred, and why" lists what v1 must not build.
Packaging and exports
Ships from @ariakit/react-components only, with no @ariakit/react re-export, matching Tag (no tag entry under packages/ariakit-react/src).
v1 therefore has no ariakit.com/reference pages: the reference index reads only the export * from lines of packages/ariakit-react/src/index.ts (getComponentModules at app/src/lib/jsdoc-loader.ts:1550, aimed there by packagePath at app/src/content.config.ts:99; loadReferences at jsdoc-loader.ts:1576 iterates that list).
Public components: NotificationProvider, NotificationRegion, NotificationList, NotificationListItem, Notification, NotificationHeading, NotificationMessage, NotificationDismiss, NotificationItems. Each element-rendering one also exports its use* twin, useNotificationRegion down to useNotificationDismiss.
NotificationItems has no use* twin because it renders no element, matching TagValues at packages/ariakit-react-components/src/tag/tag-values.tsx:42. Nor does NotificationProvider.
Reach for NotificationList first. NotificationItems stays public as the element-less render prop, for a badge count, a custom container or a virtualized list; see "Rendering".
Public store surface: createNotificationStore, the framework-agnostic factory, which lives in @ariakit/components so a Solid port needs no file moves, and useNotificationStore, the React hook all seventeen store-shipping modules under packages/ariakit-react-components/src also ship; copy useTagStore at packages/ariakit-react-components/src/tag/tag-store.ts:43. The hook, not useStore, is where the declared store props are wired.
The usage example imports the factory from @ariakit/components/notification/notification-store, so v1 ships no React re-export. Open: whether the React module adds one, since no other module re-exports its factory (createTagStore is reachable only through @ariakit/components/tag/tag-store) but this is the first whose blessed spelling puts the factory in app code.
Exported types, matching a collection-shaped store module (tag/tag-store.ts:48-74): NotificationStore, NotificationStoreItem, NotificationStoreState, NotificationStoreFunctions, NotificationStoreOptions, NotificationStoreProps, plus *Options and *Props for every element-rendering component, NotificationProviderProps, NotificationItemsProps, NotificationPushProps and NotificationAnnounceProps.
Context exports from notification-context.tsx, in the shape createStoreContext returns (packages/ariakit-react-components/src/tag/tag-context.tsx:32-40): useNotificationContext, useNotificationScopedContext, useNotificationProviderContext, NotificationContextProvider and NotificationScopedContextProvider. Three contexts in that file stay unexported: the heading and message id setters, shaped like DialogHeadingContext and DialogDescriptionContext (packages/ariakit-react-components/src/dialog/dialog-context.tsx:38-43), and the one handing the record from Notification to its content components.
The live-region machinery lives in a private __-prefixed announcer module that adds no public export, so a future Tag, Combobox or Form can announce without the queue, the timers or the UI; see "Module layout". A public primitive is deferred.
Do not add Notification* components for Title, Description, Icon, Action or Close: those are the content ones, no machinery, plain children of a card.
Notification and NotificationOptions keep their names despite shadowing the DOM globals. Verified on tsc 7.0.2: importing the type alone leaves the Web Notifications API working, importing the component produces one loud error an alias fixes, and the single silent case is a file that writes NotificationOptions without importing it, where DOM's data?: any makes props.data.anything compile.
NotificationListItem ships despite owning no machinery, because structure is a different category from content: it renders the <li> the <ol> requires and keeps the card's role off it.
Store surface
Five verbs, no synonyms, plus four non-verb members following existing store conventions.
Takes a pause hold on every scheduled timer in this store and returns the release.
renderItem
(id: string) => () => void
Reports one rendered record and returns the cleanup.
There is no hide(), dismiss(), expire(), upsert(), removeAll(), archive() or timeOut(). Every verb names a data effect, not visibility or retention, so the set stays true whichever way the timer goes.
remove, not dismiss: grep -rn dismiss packages/ariakit-components/src/ returns zero matches. dismiss names six buttons, each chaining to useDialogDismiss, which calls store?.hide() (packages/ariakit-react-components/src/dialog/dialog-dismiss.tsx:42): the component is named for the gesture, the verb for the data effect.
push and remove are defended on merit: on a plain array in insertion order, both words mean here what they mean in JavaScript. Do not cite pushValue and removeValue (packages/ariakit-components/src/form/form-store.ts:565 and :580), which are namespaced to one field and write null rather than deleting, preserving array length (:569).
NotificationPushProps<T> is the record with createdAt dropped and id optional, and the string arm conditional; see "Types". A plain string | NotificationPushProps<T> union turns the strict.push("Saved.")@ts-expect-error into TS2578 on tsc 7.0.2 and 6.0.2. NotificationAnnounceProps is { message: string; priority?: "polite" | "assertive" }, so an app suppressing intermediate utterances debounces the call.
push with an existing id replaces that record in place and announces. The per-store id counter must skip ids already present, or a minted id silently replaces an author's record.
update's partial is Partial<Omit<NotificationStoreItem<unknown>, "id" | "createdAt" | "data">> & { data?: T }, split that way so it also compiles from generic code: the unsplit form stays an unresolved conditional, and tsc 7.0.2 and 6.0.2 both reject update<T>(id, { message: "x" }) with TS2345. data replaces wholesale. options.announce overrides the text-diff rule in both directions.
item adopts the signature CollectionStoreFunctions.item ships (packages/ariakit-components/src/collection/collection-store.ts:419), so a nullable id needs no call-site guard.
Holds are reference counted and paused is the derived state, true while any hold is open. pause() is not a boolean setter because the hold sources (pointer over the region, focus inside it, document.hidden, window blur) form a disjunction a last writer would collapse. See "Lifecycle and timers".
renderItem(id) is called once per card, from useNotification in a layout effect, and refcounted into renderedIds. It takes one id, not the array a list rendered, which could only report what the component yielded. It is the store's only source of truth for what is on screen, because the store knows neither limit nor filter, which are per-list props two lists can disagree on, and cannot tell whether a NotificationRegion is mounted at all. That second gap is the load-bearing one now that a module-scope store is the toast()-from-anywhere spelling: a push from a fetch interceptor on a route that renders no region would otherwise start a deadline for a record nobody can see, and delete it unseen.
There is no setTimeout and no setPriority store function: setTimeout would shadow the global inside a module that schedules timers, and Hovercard ships these as setter-less state keys (packages/ariakit-components/src/hovercard/hovercard-store.ts:59-81; its HovercardStoreFunctions at :90-98 declares setAutoFocusOnShow and nothing else). Callers write store.setState("timeout", 8000).
pause and renderItem are not new verbs: they return a disposer the way renderItem does on the collection store (packages/ariakit-components/src/collection/collection-store.ts:410), which takes the item and fills renderedItems. This one takes an id and fills renderedIds, because a second copy of every rendered record would go stale against items. The timers live in the store while the events that start and pause them are React-layer ones.
remove or update with an unknown id is a no-op with a development warning, catching a polling update that outlives its record.
Record shape
typeNotificationStoreItem<T=unknown>={id: string;message: string;// Plain string. It IS the announcement.heading?: string;// Plain string. Announced with the message.announceMessage?: string;// Overrides what is announced.timeout?: number|null;// Duration in ms. `null` sets no time limit.priority?: "polite"|"assertive";createdAt: number;// Minted by push as Date.now(). Presentational only.}&({}extendsT ? {data?: T} : {data: T});// The app namespace.
timeout: null is the single retention spelling in the module. See "Lifecycle and timers".
There is no archived field and no archive state anywhere: a record has one life.
The author may supply id, and may never supply createdAt, which update also cannot rewrite.
Order is array position, and no deadline is measured from createdAt, which serves relative timestamps only.
The announcement is announceMessage when present, otherwise heading and message; see "Announcer".
data is the app namespace, which Ariakit never reads. It is optional exactly when {} is assignable to T, at the push parameter too; see "Types".
items is an array in insertion order, oldest first. push appends.
timeout and priority carry default* and the controlled value but no setter; see "Store surface". useNotificationStore wires them three-argument, useStoreProps(store, props, "timeout") and the same for priority (packages/ariakit-react-components/src/hovercard/hovercard-store.ts:16), since the fourth argument names a set* prop neither key has. items takes the four-argument shape at tag/tag-store.ts:16-17: useStoreProps(store, props, "items", "setItems").
Resolution order is the record's timeout, then the store's, then 5000: the record always wins, so a store-level timeout is only a default. priority resolves the same way and ends at "polite".
Read the record's timeout by presence rather than with ??, because null is meaningful at both levels.
renderedIds and paused carry no default*, controlled or setter prop, because both are derived from reference-counted registrations an outside write would fight. Neither do timeout and priority, which carry no default* either: default* seeds a key the module itself writes, and nothing in this module writes those two.
There is no maxItems; see "Deferred, and why".
Announcer
One host per document, created with the store, primed empty, never removed, since push() is synchronous and cannot wait for provider mount. Resolve the document with getDocument (packages/ariakit-utils/src/dom.ts:98) and key the host in a module-level WeakMap<Document, Host>; with no DOM at creation, build it on the first announce that has one. See "Store ownership and SSR".
It holds two role="log" regions, one polite and one assertive, carrying the attributes the table below fixes. aria-relevant omits removals, so retiring an expired node is silent. Neither region carries an accessible name.
The record's priority, or an announce call's, selects the region. Nothing else may select the assertive region.
Hide the host with the values getVisuallyHiddenStyle() returns (packages/ariakit-react-components/src/visually-hidden/visually-hidden.tsx:12), assigned imperatively as prependHiddenDismiss does (packages/ariakit-react-components/src/dialog/utils/prepend-hidden-dismiss.ts:14); that helper moves to @ariakit/utils first, since @ariakit/components cannot import it. Never display: none, visibility: hidden or hidden: each drops the region from the accessibility tree, giving a permanently silent announcer that passes every visual check.
Text nodes only, never cloned elements. Every write appends a new node, and a keyed write is delete-then-append, never an in-place edit, because screen readers notice additions reliably and edits unreliably (Ionic's toast.tsx forces new nodes because NVDA missed changes to a reused one; @wordpress/a11y's speak() assigns textContent). Appending keeps new text at the end, as ARIA's log requires.
Keys are ${storeToken}:r:${recordId}, an opaque per-store token, because every store in the document shares the one host and two can both mint n1. Open, for implementation: whether the two regions share one key map, and what an update moving a record from polite to assertive then does to that key's node in the region it left.
announce takes no id, so app code cannot write into the record namespace.
Nodes are removed after a lifetime that is a module argument, not a constant. Default 350 ms, measured only as received text byte-identical to never removing the node, and unmeasured at realistic write intervals. See "Evidence already gathered, and its limits".
Heading and message append as two text nodes in the same frame, with no join rule: measured, three appends in one frame produce one utterance carrying all three texts. announceMessage replaces both with one node. Hence no punctuation table, which would graft a Latin full stop onto a heading already ending in 。, ।, ؟, ۔ or ።.
Two identical consecutive pushes both announce, on different record keys. Test this.
lang on the record and on the announce props is deferred, its shape fixed now because it is not additive: a per-utterance language wraps each text node in an element carrying lang, changing every append path and the node cleanup.
The two DOM surfaces
This table fixes every attribute the library writes. The card's aria-labelledby and aria-describedby come from the content ids and are specified under "Each notification card".
Surface
Element
Attributes the library writes
data-*
Announcer regions (two, clipped)
div
role="log", aria-live="polite" on one and "assertive" on the other, aria-atomic="false", aria-relevant="additions text"
none, and their host carries data-notifications
NotificationRegion
div
role="region", tabindex="-1"
data-notifications, and data-paused while a hold is open
data-swiping and data-swipe-direction during a gesture
An attribute this table does not list is one the library never writes, which never means role="none": that would strip the list semantics the <ol> and the <li> exist to provide.
NotificationRegion renders only when items is non-empty, so the landmark's presence is itself information. A filter excluding everything still leaves a rendered region, which is the app's choice.
No visual surface carries an author-written live-region attribute; the card's role="alertdialog" is the one role that can supply one by user-agent default. See "Each notification card".
Hiding, reordering, remounting or re-rendering a card produces no announcement. Mount, remount and reorder are measured rather than structural, and so is hide on NVDA only; see "Each notification card". The regression test asserts the DOM half: no element in the visible tree carries aria-live, role="alert", role="status" or role="log", the card's role is exactly alertdialog, and a hide, a reorder and a remount append nothing to either log region.
The app must give NotificationRegion an accessible name: role="region" is a landmark only when named, and an unnamed one collapses to generic in Chromium. Warn in development (warnOnce, keyed on the region element) when it has neither aria-label nor aria-labelledby.
tabindex="-1" on the region is a focus target, not a tab stop, so an app-supplied hotkey can move focus into it; see "Discoverability". It is not the destination when the last card goes; see "Focus".
The <ol> matches Radix's ToastViewport and sonner.
The <li> carries no role, preserving the list semantics that give a card its position in the set, read as "2 of 3" without the library computing anything.
Each notification card
The card's role is settled: the table's alertdialog with aria-modal="false" and tabindex="0", measured before it was chosen. React Aria's useToast returns that same triple for a live toast.
It needed measuring because two engine paths speak with no author attribute: WebKit's role default, "assertive" for ApplicationAlertDialog (Source/WebCore/accessibility/AXCoreObject.h, applied by handleLiveRegionCreated in Source/WebCore/accessibility/AXObjectCache.cpp), and Chromium's Event::ALERT, which IsAlert() covers for kAlertDialog though IsLiveRegion() does not (ui/accessibility/ax_role_properties.cc, ui/accessibility/ax_event_generator.cc) and which ARIA sanctions (wai-aria-1.2). An event is not a property, and getFullAXTree over CDP reports live: null on the card, so only a screen reader could settle it. NVDA with Chrome is blind to both paths (ui/accessibility/platform/ax_platform_node_win.cc; NVDA's event_alert returns unless the role is ALERT, source/NVDAObjects/IAccessible/__init__.py), so the VoiceOver rows carry WebKit's default.
Measured on 2026-08-21 against dad187f6f, on a fixture with no announcer anywhere on the page. Mount, and a clear plus re-insert of three cards, were silent on NVDA with Chrome 151 on Windows 11 and on VoiceOver with Safari 18.4 on Sequoia. Hiding one card with display: none was silent on NVDA, the one case VoiceOver did not run. Re-render was never run. Each case ran three seconds after its button click, each silence sat between two positive controls that appended distinct phrases to the clipped role="log" and spoke, and every mutation was confirmed by reading the rendered cards.
JAWS and Narrator are unreachable on BrowserStack, which offers NVDA on Windows and VoiceOver on macOS only, so both stay unmeasured and both are known risks: JAWS has open defects on alertdialog reading, and Narrator with Edge is the one failing result in a11ysupport.io's role="log" data, with no live-region attribute on the visible surface to fall back on. Mobile VoiceOver and TalkBack are unmeasured too. The result is scoped to the pairings measured, not proof that the role is inert everywhere.
Two fallbacks if an unmeasured configuration speaks: drop the role and keep the card focusable, as Radix does (a Primitive.li with tabIndex={0}, no role, no aria-live, its role="status" node portalled separately); or aria-live="off", which defuses WebKit's default since liveRegionStatusIsEnabled accepts only polite and assertive but does nothing about Event::ALERT.
tabindex="0": four of four surveyed libraries make the card a tab stop, and an alertdialog unreachable by keyboard is not one.
aria-labelledby is headingId || messageId (packages/ariakit-react-components/src/combobox/combobox-list.tsx:177), suppressed when the author passes aria-label (combobox-list.tsx:180, shared with Dialog dialog.tsx:857, Group group.tsx:40, FormControl form-control.tsx:169 and TabPanel tab-panel.tsx:193).
aria-describedby is set only when a heading exists (headingId ? messageId : undefined); without that guard a heading-less card points both relationships at one element and a screen reader reads the sentence twice. No surveyed library gets this right. Test the heading-less card.
Warn in development (warnOnce, keyed on the card element) when a card renders neither NotificationHeading nor NotificationMessage and has no aria-label, leaving a focusable card with no accessible name.
The app passes the record as one prop, <Notification item={item} />, inside NotificationListItem too, so the card behaves identically in and out of a list.
Documented deviations: ARIA says authors SHOULD set focus inside an alert dialog when displayed and SHOULD make alert dialogs modal; this design does neither.
Content components
NotificationHeading renders item.heading, NotificationMessage renders item.message, and both accept children that win over the record value, because children is placed before the prop spread, as FormError does at packages/ariakit-react-components/src/form/form-error.tsx:73-74.
Both mint the ids aria-labelledby and aria-describedby point at, using the DialogHeading mechanism: useId, a setter context, and a useSafeLayoutEffect that clears the id on unmount (packages/ariakit-react-components/src/dialog/dialog-heading.tsx:33-39). That wiring earns these two components: plain children cannot replicate it without hand-wiring useId on every card.
NotificationHeading renders a plain div and must not compose useHeading: HeadingContext defaults to 0 (heading-context.tsx:4) and useHeading falls back to level 1 (heading.tsx:33), so an unwrapped stack of five notifications would inject five <h1> elements into the page outline. render={<Heading />} opts in.
NotificationMessage renders a <p>, matching DialogDescription (dialog-description.tsx:14), with render={<div />} documented for block content.
NotificationDismiss renders a button, sets data-notification-dismiss on itself, and calls remove(item.id) for the record its enclosing Notification carries. It composes the author's onClick and bails on defaultPrevented, from useDialogDismiss (dialog/dialog-dismiss.tsx:39-43), so an app can defer the removal for an exit animation. It has no default children, so the app supplies the label.
Lifecycle and timers
The timeout removes the record through the same internal removal that NotificationDismiss, a completed swipe and Escape call, so only remove(id) and clear() delete and the timer calls the first. The swipe path makes the single-pointer alternative WCAG 2.5.7 Dragging Movements requires the same function.
Warn in development (warnOnce, keyed on the card element) when a resolved timeout is null and the card has no data-notification-dismiss descendant, because that card is permanent for a pointer user.
WCAG 2.2.1 Timing Adjustable claims no exception here: its six satisfying conditions (Turn off, Adjust, Extend, Real-time, Essential, 20 Hour) are exhaustive, retaining the content is not among them, and the Essential arm fails its own glossary test of content that "cannot be achieved in another way that would conform". What applies is the denial of applicability in Understanding SC 2.2.1, worked with this widget at this duration: per message, a disjunction over information and function, naming the application's own inbox rather than a library buffer.
The sentence the docs carry: a default timeout is conformant only when your app gives the user another means of discovering the same information, or performing the same function. Where it does not, push that record with timeout: null. Where the message genuinely requires acknowledgement, make it a real Dialog.
Pause is not a 2.2.1 answer and the docs must never present it as one: Turn off and Adjust must be available before the user meets the time limit, and pausing needs the user to have already found the card.
A deadline starts when the record's id first enters renderedIds, not at push and not from createdAt, so neither a burst tail held behind the visible limit nor a server-supplied record with an old createdAt is born expired. See "Store surface".
Leaving renderedIds neither suspends nor restarts a deadline. A record displaced past limit keeps counting down off screen, is deleted unseen and never reappears; an author who cannot afford that pushes timeout: null. A record a same-tick burst pushed behind limit never entered renderedIds, so it appears for the first time, with its full duration, when a slot opens. Either way the stack is always the newest N.
NotificationRegion carries data-paused while a hold is open. A hold is the only thing that stops a deadline advancing. A record entering renderedIds while one is open starts counting when the last hold is released.
Holds are per source, so moving the mouse away while focus stays on a card releases only the pointer hold. The focus hold is released only when focusout reports a relatedTarget outside the region, because intra-region Tab movement fires focusout before focusin and would otherwise drop the count to zero between two keystrokes.
On touch there is no hover, so a tap that focuses a card is what pauses.
The document.hidden hold is required because the timeout deletes: without it a backgrounded tab destroys everything pushed while the user was away. Six libraries pause on a hidden page or a blurred window, sonner on visibilitychange.
Neither pointerenter nor focusin fires for a screen reader in browse mode, so nothing takes a hold and a timed card can expire mid-sentence. Measure that case before this ships.
A resumed timer continues from the remaining time. Test pause, resume, then expiry at the original total.
A push that replaces a record reschedules its deadline from the newly resolved timeout; an update that changes timeout reschedules from the new total, measured from the update. update(id, { timeout: null }) cancels a pending timer, and moving timeout from null to a number arms one at that moment if the record is visible.
There is no automatic timeout exception for interactive content: that decision would fall at push() time, before the app children have rendered. The author sets timeout: null.
Timed records drain on their own, at roughly limit records per timeout window. Two residues do not: a record no rendered component selects, and an untimed record with no dismiss control, which the warning above catches.
Rendering
NotificationList composes NotificationItems, and both take filter and limit, so record selection is specified once and an app that wants no list element gets the same records through the render prop.
NotificationItems renders no element of its own; children receives the whole array of matching records.
children is required here, where TagValues types it as optional and returns the raw array when absent. With neither a store prop nor a provider, NotificationItems throws the same development invariantTagValues throws at tag/tag-values.tsx:46-50. The hook useNotificationContext() still returns undefined; the component throws.
filter is (item: NotificationStoreItem<T>) => boolean and defaults to a predicate returning true: there is no dead state to hide.
limit selects the newest matching records. It defaults to 3 on NotificationList and is unset on NotificationItems, which yields every matching record, because a visible cap would break those cases.
Records are yielded oldest first, so a new record appends and no existing card moves, keeping DOM, reading and tab order stable. Newest therefore appears at the bottom.
Do not reverse that in CSS: CSS Flexbox 1 §5 Ordering and Orientation says authors "must not use order or the *-reverse values of flex-flow/flex-direction as a substitute for correct source ordering", and column-reverse here makes Tab move visually upward. An app that wants the newest at the top reverses the array it maps over, which moves reading and tab order with it.
limit does not suppress announcements: ten pushes with limit: 3 announce all ten in causal order while displaying three. Test the ten-push burst.
A burst tail that never became visible surfaces newest first as slots open, while each rendered group is still yielded oldest first.
There is no expansion state in the store. filter and limit are component props, so a control revealing the records held behind limit is composed from the Disclosure module that ships.
Focus
One rule covers every case: a card unmounted while it contains the active element moves focus. That is remove(), a dismiss, a completed swipe, an Escape the card handled, clear(), and displacement past limit, which a rule written in terms of removal would miss.
Focus moves to the next card in the same list, or the previous one when there is no next, and outward when no card remains.
Outward is whatever was focused before focus entered the region, overridable with a finalFocus-shaped prop. Dialog ships that prop and default: finalFocus?: HTMLElement | RefObject<HTMLElement | null> | null at packages/ariakit-react-components/src/dialog/dialog.tsx:1165, documented as restoring the element focused before the dialog opened, resolved at dialog.tsx:588.
Focus restores outward rather than parking on the region, because a region that keeps focus fires no focusout, so its pause hold is never released and nothing ever expires again. React Aria comments this state where it moves focus out of the toast region, "Otherwise auto-dismiss timers will appear 'stuck'" (adobe/react-spectrum, packages/react-aria/src/toast/useToastRegion.ts:119-120), and restores focus outward when visibleToasts.length === 0 (useToastRegion.ts:175-190).
Nothing else moves focus: a push never steals it.
One case is open: a Notification rendered outside a NotificationRegion never captures an outward target, so the outward clause has no destination. Decide it before the docs show that shape, such as inside an app's own notification panel.
Test dismissing the focused card by button, by swipe and by Escape; clear() with focus inside a card; a push that displaces the focused card past limit; and the last remaining card, where focus must land outside the region and the store must not be left paused.
Gestures
removeOnSwipe is a BooleanOrCallback (packages/ariakit-utils/src/types.ts:44) over the pointer event, named for the verb it calls, resolved at pointerdown.
Warn in development, through warnOnce on the card element, when removeOnSwipe resolves true with no dismiss descendant: WCAG 2.5.1 Pointer Gestures (A) and 2.5.7 Dragging Movements (AA) demand a single-pointer alternative.
swipeDirection accepts "up", "down", "start" or "end", or an array, default "end", logical on the inline axis, one direction at a time. Open: the exported union name.
A swipe completes past swipeThreshold, a prop defaulting to 45, on an allowed, dominant axis; less snaps back.
touch-action is inline from swipeDirection, horizontal pan-y, vertical pan-x, both none, never from the resolved removeOnSwipe: Pointer Events Level 3 §8.1 and §4.1.3.3 require suppressing the stream before a pan, §4.2.7 a pointercancel, §8.2 barring a pointerdown repair.
pointercancel restores the pre-gesture state and user-select: none covers the drag, element-level declarations like dialog/utils/disable-tree.ts:46-50 assigns.
Styling hooks, module-owned prefix: data-swiping, data-swipe-direction (logical), --notification-swipe-x, --notification-swipe-y, plus at release --notification-swipe-end-x, --notification-swipe-end-y (physical pixels).
Test under touch emulation; no scroll-to-dismiss ships.
Escape
removeOnEscape is a BooleanOrCallback over the keyboard event, in Dialog's hideOnEscape shape (dialog/dialog.tsx:1066), handled on the card, applying only while focus is inside.
It defaults to true when the resolved timeout is a number and false when null, the untimed record being the unrecoverable one. It runs the same removal as a dismiss, a swipe and the timeout, so WCAG 2.5.7 holds.
Test Escape on a card inside an open modal Dialog, timed and untimed.
Store ownership and SSR
The app creates the store with createNotificationStore() and hands it to NotificationProvider or a store prop. Module-scope stores are allowed.
push during a server render is unsupported: it warns in development and does nothing, detecting the absence of a DOM. The opposite model leaked across concurrent requests, measured.
useNotificationStore stays for per-request defaultItems seeds and for two differently typed stores, which read through context at the base type, deferring createNotificationContext<T>().
defaultItems carries a server-rendered record, identical arrays giving identical ids, so hydration keeps each card mounted, not restarting its deadline. It and setItems never announce; the id counter skips ids present.
The store works with no DOM, as createCollectionStore does: DOM work sits in setup() (collection/collection-store.ts:212), run at initialization (packages/ariakit-store/src/index.ts:843-845, propagated at :487) from useStore's layout effect (packages/ariakit-react-store/src/index.tsx:447). The announcer host is created at store creation, or at the first announce.
useNotificationContext() returns undefined with no provider; no global store ships.
Modal Dialog interaction
Dialog exempts the notification region and the announcer host from its inert sweep by default, no getPersistentElements call in app code. Measured before: the container inert, toasts gone.
The exemption covers all three allElements consumers, markTreeInside, markAndDisableTreeOutside and markTreeOutside, so a click on a notification dismisses no open Popover, Menu, Select, Combobox popup or modal Dialog, counting as inside (dialog/utils/tree-cleanup.ts:40-50, use-hide-on-interact-outside.ts:70-79).
One DOM attribute, data-notifications, marks both surfaces, queried on dialog.getRootNode() as portals are at dialog.tsx:139-143; a shadow host carries it too.
A cross-package import cannot do it: the build gives __-prefixed modules no build entry and no exports key, unreopenable by explicit entries (packages/ariakit-scripts/src/build.ts:56-58, :135, :137, :156, :255-261).
isValidTarget gains an isElementInside(target, dialog) arm (dialog.tsx:66, :113-121): an exempted subtree is never marked, so isElementMarked at :766 is false and :764 returns true with no disclosureElement.
Measured on NVDA: swept, the announcement is silent, replicated twice; exempted, NVDA spoke Modal announcement 12. Delta echo foxtrot.
Four tests with a modal Dialog open: push() announces; cards stay in the tree, reachable; a click on a notification leaves it and any open popup open; Escape from a card resolving removeOnEscape to false closes it, with a disclosure and without.
Discoverability
On by default: a named landmark rendered only when non-empty, its presence the signal; a timer any record turns off with timeout: null, defaulted by the store's timeout state key; items, filter, limit.
No default hotkey in v1: four of six surveyed systems default to Alt plus T, but all put the hint in the app-written name.
No spoken route hint: the live region is single-slot and non-replayable, so an appended hint costs 3 words growing to 8, forever.
No auto-rendered fallback trigger: a skip link needs a :focus rule, impossible in a style attribute, and clipPath: inset(50%) at 1x1px leaves hit-testing.
Deferred, and why
A notification-center route: apps build it from their own data.
Archiving on timeout: retention is additive later.
maxItems: timed records drain themselves; oldest first at 100 if it returns (packages/ariakit-utils/src/undo.ts:31).
intent: additive as a field, not as a derived timeout default.
Per-record removal callbacks: telemetry, not retention; the disposer shape exists in onValidate and onSubmit (packages/ariakit-components/src/form/form-store.ts:646, :661).
A public announcer primitive: one sentence costs a store.
lang on the record and announce props: the announcer wraps utterances.
Automatic Tag, Combobox and Form announcements: blocked on an i18n module; FormError may be correct (form/form-error.tsx:72-73).
A hotkey prop, no default: it binds while the region is mounted.
Exit animation: a presence flag needs Disclosure's animated.
Velocity-based swipe completion: the threshold is a prop.
Arrow-key navigation: every card is a tab stop.
Logical block-axis swipeDirection values: only the inline axis flips.
A matching-record count, and unlimited limit: NotificationItems yields all matches.
Multi-region routing: one region ships; a destination field is additive.
A neutral persistent-element registry: the better long-term shape.
Element.ariaNotify as a backend: it takes a plain string.
Solid support: @ariakit/solid-components depends on neither package.
Workaround
app/src/sandbox/dialog-notifications ships the manual half of the Dialog part, repeated at every modal call site.
<><Dialogopen={open}onClose={()=>setOpen(false)}getPersistentElements={()=>document.querySelectorAll("[data-notifications]")}>{/* ... */}</Dialog><divdata-notifications>{notifications.map((notification)=>(<divkey={notification.id}role="alert">
Hello!
<buttonaria-label="close"onClick={()=>closeNotification(notification.id)}>
Close
</button></div>))}</div></>
It shows the failure mode this module removes: the visible card carries role="alert", so every remount and reorder is a candidate utterance. Missing getPersistentElements wiring is invisible in production: the cards stay on screen while assistive technology and keyboard users lose them. The sandbox test presses Shift+Tab from inside the open dialog onto the notification's close button (app/src/sandbox/dialog-notifications/test.ts:18-19), reachable only because the exemption is wired by hand.
Everything else is hand-built, each item failing silently for sighted developers: two clipped role="log" regions per document, whose keyed rewrite deletes the previous text node and appends a new one; a visual container and cards that carry no live-region attributes; per-card useId wiring for aria-labelledby and aria-describedby, with the guard that omits aria-describedby when there is no heading; refcounted pause holds for pointer-over, focus-inside, document.hidden and window blur; and a swipe handler sharing one code path with a required dismiss control.
@wordpress/a11y's speak() is the closest prior art for the announcer alone, with no link to a visible surface and no keyed queue: every call runs clear(), blanking both regions, then writes one message into one, so two messages in the same frame leave only the second. Radix is the instructive toast case: it does split the surfaces, ToastViewport is role="region" with no aria-live, the card is an li with no role and no aria-live, and the utterance goes through a separately portalled role="status" node, but it derives the utterance by walking the rendered card, getAnnounceTextContent(node) in @radix-ui/react-toast@1.2.23, so a record the visible limit suppresses is never announced. Announcing from the store removes that class of bug.
Evidence, and its limits
Measured 2026-08-21 against dad187f6f, on a fixture card, role="alertdialog" and tabindex="0" inside a named role="region", with no announcer on the page, each case read three seconds after its click and bracketed by a clipped role="log" control that spoke.
Mount, and remount and reorder as a clear plus re-insert of three cards, were silent on NVDA with Chrome 151 on Windows 11 and on VoiceOver with Safari 18.4 on Sequoia. A display: none hide was silent on NVDA, the one case VoiceOver did not run, and re-render never ran, inheriting the rule from the structure. NVDA gave the button's focus utterance and no card text in the Speech Viewer transcript; VoiceOver left the caption panel unchanged between controls that spoke. alertdialog stays; card silence is a regression test scoped to these configurations, not proof it is inert everywhere. Engine sources: "Each notification card".
The control announced on both. a11ysupport.io's log role page: eleven of twelve results pass, Narrator with Edge fails, on 2020 and 2021 data, so re-measure Narrator. No role="status" fallback.
Also measured on both: three same-frame appends give one utterance carrying all three texts, so one silence kills all three; a node removed after 350 ms leaves the received text byte-identical, against shipped constants of 350 to 7000 ms; VoiceOver announces only while its cursor is in the web area, a docs requirement. The Modal announcement 12 transcript shows only that an exempted announcer speaks.
Keyed delete-then-append, five writes to one key at 120 ms and at 600 ms, on NVDA and VoiceOver: decides the node lifetime and whether a keyed write replaces or queues.
The assertive region; only the polite log was measured.
TalkBack and mobile VoiceOver.
Whether a timed card survives browse mode, which fires neither pointerenter nor focusin, so nothing holds the pause and a card expires mid-sentence.
Whether speech is truncated mid-word, and the modal case on VoiceOver.
JAWS and TalkBack pass the log role in that dataset, lowering those risks without closing them: neither covers the card. BrowserStack closes the keyed writes and browse mode before v1; JAWS and Narrator ship as documented risks.
Implementation notes follow in a comment.
What changed in review
The timer deletes instead of archiving: archived, the archived prop, maxItems and the archived-review example are gone, timeout: null is the only retention spelling, and the WCAG justification for the default timeout is deleted, not reworded.
The card's role was measured and kept, and gained removeOnEscape; the swipe gained touch-action, pointercancel, logical swipeDirection and prefixed properties; NotificationList and NotificationListItem were added.
pause() is refcounted, renderItem(id) is per card, timeout and priority are state keys with no setters, createdAt is presentational, and announce lost its id for namespaced delete-then-append writes.
Module-scope stores are allowed; the data rule and the NotificationItems overload pair are replaced under "Types" in the implementation notes; twelve further claims did not survive checking, so recheck anything built on the earlier text.
Motivation
Ariakit can neither say that something happened nor show it. Apps rebuild both, and the rebuilds fail the same way the shipped toast libraries do.
Why toasts are usually inaccessible
The usual pattern puts
aria-liveon the container holding the visible toasts, making it both the visual surface and the announcement channel, so every reorder, hide and re-render inside it becomes a candidate utterance. Sonner putsaria-live="polite"on the<section>holding the visible toasts (src/index.tsx); Polaris putsaria-live="assertive"on the toast element itself (Toast.tsx). Base UI putsrole="region",aria-live="polite",aria-atomic="false"andaria-relevant="additions text"on the viewport (ToastViewport.tsx), and ships the resulting bug: visually hiddenrole="alert"mirrors of everypriority: "high"toast remount whenever the viewport loses focus, re-announcing what the user heard.The two-surface split, which is the load-bearing decision
The thing you see and the thing you hear are different elements: no visual surface, neither the region nor any card, carries an author-written live-region attribute. The announcer is a clipped host, created with the store and never removed, that takes text only by appending a new node, never by editing one in place. Fluent ships a clipped
AriaLivecomponent separate from its toast containers (react-toast/.../AriaLive/useAriaLive.ts, clipped inuseAriaLiveStyles.styles.ts), and Primer ships it standalone as@primer/live-region-element.With no author-written live region, hiding, reordering, remounting and re-rendering a card carry no announcement of their own. The remaining path is the card's own role, and it was measured: a
role="alertdialog"card announced nothing on mount, remount or reorder on NVDA with Chrome 151 on Windows 11 and on VoiceOver with Safari 18.4 on Sequoia, and nothing on hide on NVDA, each silence bracketed by a positive control that spoke.The utterance is composed from the record, never scraped from the rendered card, so announcements follow the app's causal order rather than the DOM. Radix splits the surfaces but scrapes its text from the rendered card; see "Workaround".
A notification that vanishes is a notification some users never received
Measured on VoiceOver: a live region is announced only while the VoiceOver cursor is inside the web area; parked in the menu bar, the identical DOM update was silent.
Element.ariaNotify, the proposed replacement for offscreen live regions and this announcer's deferred backend, suppresses notifications when focus leaves the web content and withholds delivery confirmation on purpose.The channel drops messages silently and no library can learn what it lost. Retention is not the answer: a record retained where nothing routes the user to it is, for that user, deleted, and a message the user cannot afford to miss must not be on a timer.
WCAG scopes this per message: the Understanding document for SC 2.2.1 works this widget at this duration, asks only whether the same information or function is reachable another way, and names the application's own Inbox as the alternative. So the library owns the timer and the app owns the judgment, once per push, in one field: the timeout removes the record through the dismiss button's path, and
timeout: nullsays the record has no other route.No operating system archives on a short timer, and each separates the transient banner from the durable record: WNS expires at seven days, iOS has no record timer at all. Two of fourteen surveyed toast libraries retain past dismissal, sonner through
toast.getHistory()and react-toastify throughuseNotificationCenter, both outside the toast lifecycle; sonner's was uncapped and returned as sonner#729, fixed with a hard cap.Scope
Two things ship: a live-region announcer, private to the module, and one floating notification region. A notification-center page and any store-owned archive are out of scope: the record does not outlive its timer, and the durable list is the app's own data.
v1 is React only, with the framework-agnostic store in
@ariakit/componentsso a Solid port can follow. The module ships from@ariakit/react-componentsonly, with no@ariakit/reactre-export, like Tag, so v1 ships no ariakit.com/reference pages; see "Packaging and exports".Four decisions stay open, each marked where it belongs: a React re-export of
createNotificationStore, whether the two log regions share one key map, the exported name of theswipeDirectionunion, and aNotificationoutside aNotificationRegion. "Deferred, and why" lists what v1 must not build.Prior issues this supersedes, and credit
This issue supersedes #464 and #2510: one asks for the visible surface, the other the audible one.
#464 was opened in October 2019 by @artalar, who also supplied the
std-toastproposal, the inclusive-components notifications article, and the web.dev toast article as prior art. It carries design input from @folz (the Alert versus LiveRegion analysis, and therole="status"versusrole="alert"question), @R-Oscar (proposing two live regions, with error notifications onrole="alert"and the rest onrole="status"), @gziolo (the WordPress Snackbar and the@wordpress/a11yspeak()function), and @strass (an exploratory implementation in #787).#2510 was opened by @IanVS, asking for a Downshift-style status region so Select and Combobox announce their state, and suggesting an
a11yMessageoption on the select store. That request is why the announcer is a separate private module. Two parts are deferred: the wiring that makes Select and Combobox announce on their own, which waits on an i18n module, and a public announcer primitive.@alvarlagerlof raised the question that decided store ownership, asking on X whether a module-scoped store leaks state between renders on a single server, quoted in #464. It does when app code writes during a render, so
pushduring a server render is unsupported: it warns and does nothing, which leaves a module-scope store empty on a server by construction and is why both spellings ship, module scope fortoast()from anywhere and the React hook plus a provider for apps that seed from the server or need two stores.Usage example
Creating the store
The app owns the store and binds the record type once. The factory ships from
@ariakit/componentsand the components from@ariakit/react-components, one file per subpath (packages/ariakit-components/package.json:46,packages/ariakit-react-components/package.json:264).Module scope is the
toast()-from-anywhere spelling: any module pushes with no hook, context or prop, andpushwith no DOM warns and does nothing, so the store is empty during SSR by construction, as the Shortcut design argues. Apps that seed from the server or need two stores calluseNotificationStore<NotificationData>({ defaultItems: seed })per tree, wrapping the core factory likeuseTagStore(packages/ariakit-react-components/src/tag/tag-store.ts:44). See "Store ownership and SSR".NotificationListrenders the<ol>and ownsfilterandlimit,NotificationListItemrenders the<li>, andlimitdefaults to 3, yielding the newest records oldest first.store={notifications}carries the record type into the render prop; custom fields are read from the closure, never spread ontoNotification.The five verbs
pushis the only verb that both stores a record and announces it, andtimeout: nullis the only retention spelling; see "Lifecycle and timers" for the default timer it opts out of.Content, and children that win
NotificationHeadingrendersitem.headingin a plaindivandNotificationMessagerendersitem.messagein a<p>; both mint the idsaria-labelledbyandaria-describedbypoint at.Anything durable is the app's own record
No store survives a reload, since
dataholds closures such asonUndo, so anything durable is the app's own row, and sharing its id makes retraction a singleremove(id), the contract the platforms use: the WHATWGtag,ToastNotificationHistory.Remove(tag)on Windows, andremoveDeliveredNotifications(withIdentifiers:)on Apple.Swipe
swipeDirectiondefaults to"end", logical on the inline axis.removeOnSwipedefaults totruewhenever the card contains adata-notification-dismissdescendant, the attributeNotificationDismisssets on itself. See "Gestures" for the styling hooks; Ariakit ships no CSS.Types that must compile, and types that must fail
These two blocks are one type-test file, which must compile with exactly these errors, every error code measured on tsc 7.0.2 and 6.0.2 except the TS2344 on the failed generic constraint, which must be measured before this ships.
datais optional exactly when{}is assignable toT; see "Types".NotificationListandNotificationItemstake one signature generic over the store,StoreData<S>, not an overload pair; the diagnostics are under "Types". The cost is the one hole the pair closed:<NotificationList<NotificationStore<NotificationData>> limit={3}>stays expressible with nostoreprop.No library carries the generic through context: react-aria-components 1.20.0 declares
UNSTABLE_ToastStateContextasContext<ToastState<any> | null>, and Base UI 1.6.0 and Ark UI 5.38.2 erase it too, sostore={notifications}is the whole custom-fields story. Reads then degrade tounknown; writes through the erased store stay unchecked, as "Types" prices.Requirements
Everything is settled unless a rule says otherwise in place. The four decisions "Scope" names as open are marked here where they belong, and "Deferred, and why" lists what v1 must not build.
Packaging and exports
@ariakit/react-componentsonly, with no@ariakit/reactre-export, matching Tag (notagentry underpackages/ariakit-react/src).export * fromlines ofpackages/ariakit-react/src/index.ts(getComponentModulesatapp/src/lib/jsdoc-loader.ts:1550, aimed there bypackagePathatapp/src/content.config.ts:99;loadReferencesatjsdoc-loader.ts:1576iterates that list).NotificationProvider,NotificationRegion,NotificationList,NotificationListItem,Notification,NotificationHeading,NotificationMessage,NotificationDismiss,NotificationItems. Each element-rendering one also exports itsuse*twin,useNotificationRegiondown touseNotificationDismiss.NotificationItemshas nouse*twin because it renders no element, matchingTagValuesatpackages/ariakit-react-components/src/tag/tag-values.tsx:42. Nor doesNotificationProvider.NotificationListfirst.NotificationItemsstays public as the element-less render prop, for a badge count, a custom container or a virtualized list; see "Rendering".createNotificationStore, the framework-agnostic factory, which lives in@ariakit/componentsso a Solid port needs no file moves, anduseNotificationStore, the React hook all seventeen store-shipping modules underpackages/ariakit-react-components/srcalso ship; copyuseTagStoreatpackages/ariakit-react-components/src/tag/tag-store.ts:43. The hook, notuseStore, is where the declared store props are wired.@ariakit/components/notification/notification-store, so v1 ships no React re-export. Open: whether the React module adds one, since no other module re-exports its factory (createTagStoreis reachable only through@ariakit/components/tag/tag-store) but this is the first whose blessed spelling puts the factory in app code.tag/tag-store.ts:48-74):NotificationStore,NotificationStoreItem,NotificationStoreState,NotificationStoreFunctions,NotificationStoreOptions,NotificationStoreProps, plus*Optionsand*Propsfor every element-rendering component,NotificationProviderProps,NotificationItemsProps,NotificationPushPropsandNotificationAnnounceProps.notification-context.tsx, in the shapecreateStoreContextreturns (packages/ariakit-react-components/src/tag/tag-context.tsx:32-40):useNotificationContext,useNotificationScopedContext,useNotificationProviderContext,NotificationContextProviderandNotificationScopedContextProvider. Three contexts in that file stay unexported: the heading and message id setters, shaped likeDialogHeadingContextandDialogDescriptionContext(packages/ariakit-react-components/src/dialog/dialog-context.tsx:38-43), and the one handing the record fromNotificationto its content components.__-prefixed announcer module that adds no public export, so a future Tag, Combobox or Form can announce without the queue, the timers or the UI; see "Module layout". A public primitive is deferred.Notification*components for Title, Description, Icon, Action or Close: those are the content ones, no machinery, plain children of a card.NotificationandNotificationOptionskeep their names despite shadowing the DOM globals. Verified on tsc 7.0.2: importing the type alone leaves the Web Notifications API working, importing the component produces one loud error an alias fixes, and the single silent case is a file that writesNotificationOptionswithout importing it, where DOM'sdata?: anymakesprops.data.anythingcompile.NotificationListItemships despite owning no machinery, because structure is a different category from content: it renders the<li>the<ol>requires and keeps the card's role off it.Store surface
Five verbs, no synonyms, plus four non-verb members following existing store conventions.
push(props: NotificationPushProps<T> | ({} extends T ? string : never)) => stringpush("text")ispush({ message: "text" }).update(id: string, partial, options?) => voidremove(id: string) => voidclear() => voidannounce(props: string | NotificationAnnounceProps) => voidsetItemsSetState<NotificationStoreItem<T>[]>SetStatehalf ofitems. Never announces.item(id: string | null | undefined) => NotificationStoreItem<T> | nullnull.pause() => () => voidrenderItem(id: string) => () => voidhide(),dismiss(),expire(),upsert(),removeAll(),archive()ortimeOut(). Every verb names a data effect, not visibility or retention, so the set stays true whichever way the timer goes.remove, notdismiss:grep -rn dismiss packages/ariakit-components/src/returns zero matches.dismissnames six buttons, each chaining touseDialogDismiss, which callsstore?.hide()(packages/ariakit-react-components/src/dialog/dialog-dismiss.tsx:42): the component is named for the gesture, the verb for the data effect.pushandremoveare defended on merit: on a plain array in insertion order, both words mean here what they mean in JavaScript. Do not citepushValueandremoveValue(packages/ariakit-components/src/form/form-store.ts:565and:580), which are namespaced to one field and writenullrather than deleting, preserving array length (:569).NotificationPushProps<T>is the record withcreatedAtdropped andidoptional, and the string arm conditional; see "Types". A plainstring | NotificationPushProps<T>union turns thestrict.push("Saved.")@ts-expect-errorinto TS2578 on tsc 7.0.2 and 6.0.2.NotificationAnnouncePropsis{ message: string; priority?: "polite" | "assertive" }, so an app suppressing intermediate utterances debounces the call.pushwith an existing id replaces that record in place and announces. The per-store id counter must skip ids already present, or a minted id silently replaces an author's record.update'spartialisPartial<Omit<NotificationStoreItem<unknown>, "id" | "createdAt" | "data">> & { data?: T }, split that way so it also compiles from generic code: the unsplit form stays an unresolved conditional, and tsc 7.0.2 and 6.0.2 both rejectupdate<T>(id, { message: "x" })with TS2345.datareplaces wholesale.options.announceoverrides the text-diff rule in both directions.itemadopts the signatureCollectionStoreFunctions.itemships (packages/ariakit-components/src/collection/collection-store.ts:419), so a nullable id needs no call-site guard.pausedis the derived state, true while any hold is open.pause()is not a boolean setter because the hold sources (pointer over the region, focus inside it,document.hidden, window blur) form a disjunction a last writer would collapse. See "Lifecycle and timers".renderItem(id)is called once per card, fromuseNotificationin a layout effect, and refcounted intorenderedIds. It takes one id, not the array a list rendered, which could only report what the component yielded. It is the store's only source of truth for what is on screen, because the store knows neitherlimitnorfilter, which are per-list props two lists can disagree on, and cannot tell whether aNotificationRegionis mounted at all. That second gap is the load-bearing one now that a module-scope store is thetoast()-from-anywhere spelling: a push from a fetch interceptor on a route that renders no region would otherwise start a deadline for a record nobody can see, and delete it unseen.setTimeoutand nosetPrioritystore function:setTimeoutwould shadow the global inside a module that schedules timers, and Hovercard ships these as setter-less state keys (packages/ariakit-components/src/hovercard/hovercard-store.ts:59-81; itsHovercardStoreFunctionsat:90-98declaressetAutoFocusOnShowand nothing else). Callers writestore.setState("timeout", 8000).pauseandrenderItemare not new verbs: they return a disposer the wayrenderItemdoes on the collection store (packages/ariakit-components/src/collection/collection-store.ts:410), which takes the item and fillsrenderedItems. This one takes an id and fillsrenderedIds, because a second copy of every rendered record would go stale againstitems. The timers live in the store while the events that start and pause them are React-layer ones.removeorupdatewith an unknown id is a no-op with a development warning, catching a pollingupdatethat outlives its record.Record shape
timeout: nullis the single retention spelling in the module. See "Lifecycle and timers".archivedfield and no archive state anywhere: a record has one life.id, and may never supplycreatedAt, whichupdatealso cannot rewrite.createdAt, which serves relative timestamps only.announceMessagewhen present, otherwiseheadingandmessage; see "Announcer".datais the app namespace, which Ariakit never reads. It is optional exactly when{}is assignable toT, at thepushparameter too; see "Types".Store options
itemsis an array in insertion order, oldest first.pushappends.timeoutandprioritycarrydefault*and the controlled value but no setter; see "Store surface".useNotificationStorewires them three-argument,useStoreProps(store, props, "timeout")and the same forpriority(packages/ariakit-react-components/src/hovercard/hovercard-store.ts:16), since the fourth argument names aset*prop neither key has.itemstakes the four-argument shape attag/tag-store.ts:16-17:useStoreProps(store, props, "items", "setItems").timeout, then the store's, then 5000: the record always wins, so a store-leveltimeoutis only a default.priorityresolves the same way and ends at"polite".timeoutby presence rather than with??, becausenullis meaningful at both levels.renderedIdsandpausedcarry nodefault*, controlled or setter prop, because both are derived from reference-counted registrations an outside write would fight. Neither dotimeoutandpriority, which carry nodefault*either:default*seeds a key the module itself writes, and nothing in this module writes those two.maxItems; see "Deferred, and why".Announcer
push()is synchronous and cannot wait for provider mount. Resolve the document withgetDocument(packages/ariakit-utils/src/dom.ts:98) and key the host in a module-levelWeakMap<Document, Host>; with no DOM at creation, build it on the first announce that has one. See "Store ownership and SSR".role="log"regions, one polite and one assertive, carrying the attributes the table below fixes.aria-relevantomitsremovals, so retiring an expired node is silent. Neither region carries an accessible name.priority, or anannouncecall's, selects the region. Nothing else may select the assertive region.getVisuallyHiddenStyle()returns (packages/ariakit-react-components/src/visually-hidden/visually-hidden.tsx:12), assigned imperatively asprependHiddenDismissdoes (packages/ariakit-react-components/src/dialog/utils/prepend-hidden-dismiss.ts:14); that helper moves to@ariakit/utilsfirst, since@ariakit/componentscannot import it. Neverdisplay: none,visibility: hiddenorhidden: each drops the region from the accessibility tree, giving a permanently silent announcer that passes every visual check.toast.tsxforces new nodes because NVDA missed changes to a reused one;@wordpress/a11y'sspeak()assignstextContent). Appending keeps new text at the end, as ARIA'slogrequires.${storeToken}:r:${recordId}, an opaque per-store token, because every store in the document shares the one host and two can both mintn1. Open, for implementation: whether the two regions share one key map, and what anupdatemoving a record frompolitetoassertivethen does to that key's node in the region it left.announcetakes noid, so app code cannot write into the record namespace.announceMessagereplaces both with one node. Hence no punctuation table, which would graft a Latin full stop onto a heading already ending in。,।,؟,۔or።.langon the record and on the announce props is deferred, its shape fixed now because it is not additive: a per-utterance language wraps each text node in an element carryinglang, changing every append path and the node cleanup.The two DOM surfaces
This table fixes every attribute the library writes. The card's
aria-labelledbyandaria-describedbycome from the content ids and are specified under "Each notification card".divrole="log",aria-live="polite"on one and"assertive"on the other,aria-atomic="false",aria-relevant="additions text"data-notificationsNotificationRegiondivrole="region",tabindex="-1"data-notifications, anddata-pausedwhile a hold is openNotificationListolNotificationListItemliNotificationdivrole="alertdialog",aria-modal="false",tabindex="0"data-swipinganddata-swipe-directionduring a gestureAn attribute this table does not list is one the library never writes, which never means
role="none": that would strip the list semantics the<ol>and the<li>exist to provide.NotificationRegionrenders only whenitemsis non-empty, so the landmark's presence is itself information. Afilterexcluding everything still leaves a rendered region, which is the app's choice.role="alertdialog"is the one role that can supply one by user-agent default. See "Each notification card".aria-live,role="alert",role="status"orrole="log", the card's role is exactlyalertdialog, and a hide, a reorder and a remount append nothing to either log region.NotificationRegionan accessible name:role="region"is a landmark only when named, and an unnamed one collapses togenericin Chromium. Warn in development (warnOnce, keyed on the region element) when it has neitheraria-labelnoraria-labelledby.tabindex="-1"on the region is a focus target, not a tab stop, so an app-supplied hotkey can move focus into it; see "Discoverability". It is not the destination when the last card goes; see "Focus".<ol>matches Radix'sToastViewportand sonner.<li>carries no role, preserving the list semantics that give a card its position in the set, read as "2 of 3" without the library computing anything.Each notification card
The card's role is settled: the table's
alertdialogwitharia-modal="false"andtabindex="0", measured before it was chosen. React Aria'suseToastreturns that same triple for a live toast."assertive"forApplicationAlertDialog(Source/WebCore/accessibility/AXCoreObject.h, applied byhandleLiveRegionCreatedinSource/WebCore/accessibility/AXObjectCache.cpp), and Chromium'sEvent::ALERT, whichIsAlert()covers forkAlertDialogthoughIsLiveRegion()does not (ui/accessibility/ax_role_properties.cc,ui/accessibility/ax_event_generator.cc) and which ARIA sanctions (wai-aria-1.2). An event is not a property, andgetFullAXTreeover CDP reportslive: nullon the card, so only a screen reader could settle it. NVDA with Chrome is blind to both paths (ui/accessibility/platform/ax_platform_node_win.cc; NVDA'sevent_alertreturns unless the role isALERT,source/NVDAObjects/IAccessible/__init__.py), so the VoiceOver rows carry WebKit's default.dad187f6f, on a fixture with no announcer anywhere on the page. Mount, and a clear plus re-insert of three cards, were silent on NVDA with Chrome 151 on Windows 11 and on VoiceOver with Safari 18.4 on Sequoia. Hiding one card withdisplay: nonewas silent on NVDA, the one case VoiceOver did not run. Re-render was never run. Each case ran three seconds after its button click, each silence sat between two positive controls that appended distinct phrases to the clippedrole="log"and spoke, and every mutation was confirmed by reading the rendered cards.alertdialogreading, and Narrator with Edge is the one failing result in a11ysupport.io'srole="log"data, with no live-region attribute on the visible surface to fall back on. Mobile VoiceOver and TalkBack are unmeasured too. The result is scoped to the pairings measured, not proof that the role is inert everywhere.Primitive.liwithtabIndex={0}, no role, noaria-live, itsrole="status"node portalled separately); oraria-live="off", which defuses WebKit's default sinceliveRegionStatusIsEnabledaccepts onlypoliteandassertivebut does nothing aboutEvent::ALERT.tabindex="0": four of four surveyed libraries make the card a tab stop, and analertdialogunreachable by keyboard is not one.aria-labelledbyisheadingId || messageId(packages/ariakit-react-components/src/combobox/combobox-list.tsx:177), suppressed when the author passesaria-label(combobox-list.tsx:180, shared with Dialogdialog.tsx:857, Groupgroup.tsx:40, FormControlform-control.tsx:169and TabPaneltab-panel.tsx:193).aria-describedbyis set only when a heading exists (headingId ? messageId : undefined); without that guard a heading-less card points both relationships at one element and a screen reader reads the sentence twice. No surveyed library gets this right. Test the heading-less card.warnOnce, keyed on the card element) when a card renders neitherNotificationHeadingnorNotificationMessageand has noaria-label, leaving a focusable card with no accessible name.<Notification item={item} />, insideNotificationListItemtoo, so the card behaves identically in and out of a list.Content components
NotificationHeadingrendersitem.heading,NotificationMessagerendersitem.message, and both acceptchildrenthat win over the record value, becausechildrenis placed before the prop spread, asFormErrordoes atpackages/ariakit-react-components/src/form/form-error.tsx:73-74.aria-labelledbyandaria-describedbypoint at, using theDialogHeadingmechanism:useId, a setter context, and auseSafeLayoutEffectthat clears the id on unmount (packages/ariakit-react-components/src/dialog/dialog-heading.tsx:33-39). That wiring earns these two components: plain children cannot replicate it without hand-wiringuseIdon every card.NotificationHeadingrenders a plaindivand must not composeuseHeading:HeadingContextdefaults to0(heading-context.tsx:4) anduseHeadingfalls back to level 1 (heading.tsx:33), so an unwrapped stack of five notifications would inject five<h1>elements into the page outline.render={<Heading />}opts in.NotificationMessagerenders a<p>, matchingDialogDescription(dialog-description.tsx:14), withrender={<div />}documented for block content.NotificationDismissrenders abutton, setsdata-notification-dismisson itself, and callsremove(item.id)for the record its enclosingNotificationcarries. It composes the author'sonClickand bails ondefaultPrevented, fromuseDialogDismiss(dialog/dialog-dismiss.tsx:39-43), so an app can defer the removal for an exit animation. It has no default children, so the app supplies the label.Lifecycle and timers
NotificationDismiss, a completed swipe and Escape call, so onlyremove(id)andclear()delete and the timer calls the first. The swipe path makes the single-pointer alternative WCAG 2.5.7 Dragging Movements requires the same function.warnOnce, keyed on the card element) when a resolvedtimeoutisnulland the card has nodata-notification-dismissdescendant, because that card is permanent for a pointer user.timeout: null. Where the message genuinely requires acknowledgement, make it a real Dialog.renderedIds, not atpushand not fromcreatedAt, so neither a burst tail held behind the visiblelimitnor a server-supplied record with an oldcreatedAtis born expired. See "Store surface".renderedIdsneither suspends nor restarts a deadline. A record displaced pastlimitkeeps counting down off screen, is deleted unseen and never reappears; an author who cannot afford that pushestimeout: null. A record a same-tick burst pushed behindlimitnever enteredrenderedIds, so it appears for the first time, with its full duration, when a slot opens. Either way the stack is always the newest N.NotificationRegioncarriesdata-pausedwhile a hold is open. A hold is the only thing that stops a deadline advancing. A record enteringrenderedIdswhile one is open starts counting when the last hold is released.focusoutreports arelatedTargetoutside the region, because intra-region Tab movement firesfocusoutbeforefocusinand would otherwise drop the count to zero between two keystrokes.document.hiddenhold is required because the timeout deletes: without it a backgrounded tab destroys everything pushed while the user was away. Six libraries pause on a hidden page or a blurred window, sonner onvisibilitychange.pointerenternorfocusinfires for a screen reader in browse mode, so nothing takes a hold and a timed card can expire mid-sentence. Measure that case before this ships.pushthat replaces a record reschedules its deadline from the newly resolvedtimeout; anupdatethat changestimeoutreschedules from the new total, measured from the update.update(id, { timeout: null })cancels a pending timer, and movingtimeoutfromnullto a number arms one at that moment if the record is visible.push()time, before the app children have rendered. The author setstimeout: null.limitrecords per timeout window. Two residues do not: a record no rendered component selects, and an untimed record with no dismiss control, which the warning above catches.Rendering
NotificationListcomposesNotificationItems, and both takefilterandlimit, so record selection is specified once and an app that wants no list element gets the same records through the render prop.NotificationItemsrenders no element of its own;childrenreceives the whole array of matching records.childrenis required here, whereTagValuestypes it as optional and returns the raw array when absent. With neither astoreprop nor a provider,NotificationItemsthrows the same developmentinvariantTagValuesthrows attag/tag-values.tsx:46-50. The hookuseNotificationContext()still returnsundefined; the component throws.filteris(item: NotificationStoreItem<T>) => booleanand defaults to a predicate returningtrue: there is no dead state to hide.limitselects the newest matching records. It defaults to 3 onNotificationListand is unset onNotificationItems, which yields every matching record, because a visible cap would break those cases.orderor the*-reversevalues offlex-flow/flex-directionas a substitute for correct source ordering", andcolumn-reversehere makes Tab move visually upward. An app that wants the newest at the top reverses the array it maps over, which moves reading and tab order with it.limitdoes not suppress announcements: ten pushes withlimit: 3announce all ten in causal order while displaying three. Test the ten-push burst.filterandlimitare component props, so a control revealing the records held behindlimitis composed from the Disclosure module that ships.Focus
remove(), a dismiss, a completed swipe, an Escape the card handled,clear(), and displacement pastlimit, which a rule written in terms of removal would miss.finalFocus-shaped prop. Dialog ships that prop and default:finalFocus?: HTMLElement | RefObject<HTMLElement | null> | nullatpackages/ariakit-react-components/src/dialog/dialog.tsx:1165, documented as restoring the element focused before the dialog opened, resolved atdialog.tsx:588.focusout, so its pause hold is never released and nothing ever expires again. React Aria comments this state where it moves focus out of the toast region, "Otherwise auto-dismiss timers will appear 'stuck'" (adobe/react-spectrum,packages/react-aria/src/toast/useToastRegion.ts:119-120), and restores focus outward whenvisibleToasts.length === 0(useToastRegion.ts:175-190).pushnever steals it.Notificationrendered outside aNotificationRegionnever captures an outward target, so the outward clause has no destination. Decide it before the docs show that shape, such as inside an app's own notification panel.clear()with focus inside a card; a push that displaces the focused card pastlimit; and the last remaining card, where focus must land outside the region and the store must not be left paused.Gestures
removeOnSwipeis aBooleanOrCallback(packages/ariakit-utils/src/types.ts:44) over the pointer event, named for the verb it calls, resolved atpointerdown.truewhen the card contains adata-notification-dismissdescendant, never from a tabbable survey. Gestures starting on another interactive descendant are ignored, Base UI's rule ([toast] accessibility: Undo action can't be reached in time with a keyboard or screen reader, or its timing adjusted mui/base-ui#4253 is unrelated).warnOnceon the card element, whenremoveOnSwiperesolvestruewith no dismiss descendant: WCAG 2.5.1 Pointer Gestures (A) and 2.5.7 Dragging Movements (AA) demand a single-pointer alternative.swipeDirectionaccepts"up","down","start"or"end", or an array, default"end", logical on the inline axis, one direction at a time. Open: the exported union name.swipeThreshold, a prop defaulting to 45, on an allowed, dominant axis; less snaps back.touch-actionis inline fromswipeDirection, horizontalpan-y, verticalpan-x, bothnone, never from the resolvedremoveOnSwipe: Pointer Events Level 3 §8.1 and §4.1.3.3 require suppressing the stream before a pan, §4.2.7 apointercancel, §8.2 barring apointerdownrepair.pointercancelrestores the pre-gesture state anduser-select: nonecovers the drag, element-level declarations likedialog/utils/disable-tree.ts:46-50assigns.data-swiping,data-swipe-direction(logical),--notification-swipe-x,--notification-swipe-y, plus at release--notification-swipe-end-x,--notification-swipe-end-y(physical pixels).Escape
removeOnEscapeis aBooleanOrCallbackover the keyboard event, in Dialog'shideOnEscapeshape (dialog/dialog.tsx:1066), handled on the card, applying only while focus is inside.truewhen the resolvedtimeoutis a number andfalsewhennull, the untimed record being the unrecoverable one. It runs the same removal as a dismiss, a swipe and the timeout, so WCAG 2.5.7 holds.isValidTargetarm below. Seven surveyed systems close a focused toast on Escape; Radix cites Focused Toast removal withEscapekey broken after1.1.6-rc.1radix-ui/primitives#2906.Store ownership and SSR
createNotificationStore()and hands it toNotificationProvideror astoreprop. Module-scope stores are allowed.pushduring a server render is unsupported: it warns in development and does nothing, detecting the absence of a DOM. The opposite model leaked across concurrent requests, measured.useNotificationStorestays for per-requestdefaultItemsseeds and for two differently typed stores, which read through context at the base type, deferringcreateNotificationContext<T>().defaultItemscarries a server-rendered record, identical arrays giving identical ids, so hydration keeps each card mounted, not restarting its deadline. It andsetItemsnever announce; the id counter skips ids present.createCollectionStoredoes: DOM work sits insetup()(collection/collection-store.ts:212), run at initialization (packages/ariakit-store/src/index.ts:843-845, propagated at:487) fromuseStore's layout effect (packages/ariakit-react-store/src/index.tsx:447). The announcer host is created at store creation, or at the first announce.useNotificationContext()returnsundefinedwith no provider; no global store ships.Modal Dialog interaction
getPersistentElementscall in app code. Measured before: the container inert, toasts gone.allElementsconsumers,markTreeInside,markAndDisableTreeOutsideandmarkTreeOutside, so a click on a notification dismisses no open Popover, Menu, Select, Combobox popup or modal Dialog, counting as inside (dialog/utils/tree-cleanup.ts:40-50,use-hide-on-interact-outside.ts:70-79).data-notifications, marks both surfaces, queried ondialog.getRootNode()as portals are atdialog.tsx:139-143; a shadow host carries it too.__-prefixed modules no build entry and no exports key, unreopenable by explicit entries (packages/ariakit-scripts/src/build.ts:56-58,:135,:137,:156,:255-261).isValidTargetgains anisElementInside(target, dialog)arm (dialog.tsx:66,:113-121): an exempted subtree is never marked, soisElementMarkedat:766is false and:764returnstruewith nodisclosureElement.Modal announcement 12. Delta echo foxtrot.push()announces; cards stay in the tree, reachable; a click on a notification leaves it and any open popup open; Escape from a card resolvingremoveOnEscapetofalsecloses it, with a disclosure and without.Discoverability
timeout: null, defaulted by the store'stimeoutstate key;items,filter,limit.:focusrule, impossible in astyleattribute, andclipPath: inset(50%)at 1x1px leaves hit-testing.Deferred, and why
maxItems: timed records drain themselves; oldest first at 100 if it returns (packages/ariakit-utils/src/undo.ts:31).intent: additive as a field, not as a derivedtimeoutdefault.onValidateandonSubmit(packages/ariakit-components/src/form/form-store.ts:646,:661).langon the record and announce props: the announcer wraps utterances.FormErrormay be correct (form/form-error.tsx:72-73).hotkeyprop, no default: it binds while the region is mounted.promise: composes frompush,updateanditem.createNotificationContext<T>(): measured additive.animated.swipeDirectionvalues: only the inline axis flips.limit:NotificationItemsyields all matches.Element.ariaNotifyas a backend: it takes a plain string.@ariakit/solid-componentsdepends on neither package.Workaround
app/src/sandbox/dialog-notificationsships the manual half of the Dialog part, repeated at every modal call site.It shows the failure mode this module removes: the visible card carries
role="alert", so every remount and reorder is a candidate utterance. MissinggetPersistentElementswiring is invisible in production: the cards stay on screen while assistive technology and keyboard users lose them. The sandbox test presses Shift+Tab from inside the open dialog onto the notification's close button (app/src/sandbox/dialog-notifications/test.ts:18-19), reachable only because the exemption is wired by hand.Everything else is hand-built, each item failing silently for sighted developers: two clipped
role="log"regions per document, whose keyed rewrite deletes the previous text node and appends a new one; a visual container and cards that carry no live-region attributes; per-carduseIdwiring foraria-labelledbyandaria-describedby, with the guard that omitsaria-describedbywhen there is no heading; refcounted pause holds for pointer-over, focus-inside,document.hiddenand window blur; and a swipe handler sharing one code path with a required dismiss control.@wordpress/a11y'sspeak()is the closest prior art for the announcer alone, with no link to a visible surface and no keyed queue: every call runsclear(), blanking both regions, then writes one message into one, so two messages in the same frame leave only the second. Radix is the instructive toast case: it does split the surfaces,ToastViewportisrole="region"with noaria-live, the card is anliwith no role and noaria-live, and the utterance goes through a separately portalledrole="status"node, but it derives the utterance by walking the rendered card,getAnnounceTextContent(node)in@radix-ui/react-toast@1.2.23, so a record the visible limit suppresses is never announced. Announcing from the store removes that class of bug.Evidence, and its limits
Measured 2026-08-21 against
dad187f6f, on a fixture card,role="alertdialog"andtabindex="0"inside a namedrole="region", with no announcer on the page, each case read three seconds after its click and bracketed by a clippedrole="log"control that spoke.display: nonehide was silent on NVDA, the one case VoiceOver did not run, and re-render never ran, inheriting the rule from the structure. NVDA gave the button's focus utterance and no card text in the Speech Viewer transcript; VoiceOver left the caption panel unchanged between controls that spoke.alertdialogstays; card silence is a regression test scoped to these configurations, not proof it is inert everywhere. Engine sources: "Each notification card".log rolepage: eleven of twelve results pass, Narrator with Edge fails, on 2020 and 2021 data, so re-measure Narrator. Norole="status"fallback.Modal announcement 12transcript shows only that an exempted announcer speaks.Unmeasured, and substantial.
aria-relevant="additions", chore(deps): bump @popperjs/core from 2.0.5 to 2.0.6 #588 skipsalertdialogcontent, Is it possible to determine default 'as' based on options in reakit-system? #756 reads alert dialog text two or three times. Narrator fails the published log-role result, and the visible surface carries no live-region attribute, so such a user gets nothing.pointerenternorfocusin, so nothing holds the pause and a card expires mid-sentence.JAWS and TalkBack pass the log role in that dataset, lowering those risks without closing them: neither covers the card. BrowserStack closes the keyed writes and browse mode before v1; JAWS and Narrator ship as documented risks.
Implementation notes follow in a comment.
What changed in review
archived, thearchivedprop,maxItemsand the archived-review example are gone,timeout: nullis the only retention spelling, and the WCAG justification for the default timeout is deleted, not reworded.rolewas measured and kept, and gainedremoveOnEscape; the swipe gainedtouch-action,pointercancel, logicalswipeDirectionand prefixed properties;NotificationListandNotificationListItemwere added.pause()is refcounted,renderItem(id)is per card,timeoutandpriorityare state keys with no setters,createdAtis presentational, andannouncelost itsidfor namespaced delete-then-append writes.datarule and theNotificationItemsoverload pair are replaced under "Types" in the implementation notes; twelve further claims did not survive checking, so recheck anything built on the earlier text.