Skip to content

Add Notification component #7235

Description

@ariakit-bot

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-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/a11y speak() 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).

// notifications.ts
import { createNotificationStore } from "@ariakit/components/notification/notification-store";

export interface NotificationData {
  href?: string;
  onUndo?: () => void;
}

export const notifications = createNotificationStore<NotificationData>();

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".

// app-shell.tsx
<NotificationProvider store={notifications}>
  {children}
  <NotificationRegion aria-label="Notifications">
    <NotificationList store={notifications} limit={3}>
      {(items) => items.map((item) => (
        <NotificationListItem key={item.id}>
          <Notification item={item}>
            <NotificationHeading />
            <NotificationMessage />
            {item.data?.onUndo ? <button onClick={item.data.onUndo}>Undo</button> : null}
            <NotificationDismiss>Dismiss</NotificationDismiss>
          </Notification>
        </NotificationListItem>
      ))}
    </NotificationList>
  </NotificationRegion>
</NotificationProvider>

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.
const id = 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. */}
<NotificationHeading render={<Heading />} />
<NotificationMessage render={<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.
const onOpenThread = (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. */}
<Notification item={item}>
  <button onClick={item.data?.onUndo}>Undo</button>
  <NotificationDismiss>Dismiss</NotificationDismiss>
</Notification>

{/* Opt out, or decide per gesture, which resolves at pointerdown. */}
<Notification item={item} removeOnSwipe={false} />
<Notification item={item} removeOnSwipe={(e) => e.pointerType !== "mouse"} />

{/* touch-action follows swipeDirection, so the browser keeps the unused axis. */}
<Notification item={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".

const plain = 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.
const notifications = createNotificationStore<NotificationData>();
notifications.push("Message sent.");
notifications.push({ message: "Deleted.", data: { onUndo: () => {} } });

const strict = 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.
const seed: NotificationStoreItem<NotificationData>[] = [{ id: "n1", message: "Restored.", createdAt: Date.now() }];

const badSeed: 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`.
const a = <NotificationList limit={3}>{(items) => items.map((item) => <NotificationListItem key={item.id}><Notification item={item} /></NotificationListItem>)}</NotificationList>;

const b = <NotificationList limit={3}>{(items) => items.map((item) => (
  // @ts-expect-error TS18046. `item.data` is `unknown` without `store`.
  <button key={item.id} onClick={item.data.onUndo} />
))}</NotificationList>;

// @ts-expect-error TS2344. `NotificationData` is not a store.
const c = <NotificationList<NotificationData> limit={3}>{() => null}</NotificationList>;

type Wrong = NotificationStore<{ userId: string }>;

// @ts-expect-error TS2322. The annotation and the `store` prop disagree.
const d = <NotificationList<Wrong> store={notifications}>{() => null}</NotificationList>;

// @ts-expect-error TS2322, with a "Did you mean `limit`?" suggestion.
const e = <NotificationList store={notifications} limt={3}>{() => null}</NotificationList>;

// OK: `store` types the render prop, so `item.data` is `NotificationData`.
const f = <NotificationList store={notifications} limit={3}>{(items) => items.map((item) => <button key={item.id} onClick={item.data?.onUndo} />)}</NotificationList>;

// OK: `NotificationItems` carries the same generic, for a badge count.
const g = <NotificationItems store={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.

Member Signature Behavior
push (props: NotificationPushProps<T> | ({} extends T ? string : never)) => string Adds a record and announces it, the only verb that does both. Returns the id. push("text") is push({ message: "text" }).
update (id: string, partial, options?) => void Changes a record in place. Silent unless the announcement text changed; then it announces the new text at the record's priority.
remove (id: string) => void Deletes one record. The id is required, so it can never wipe the list by accident.
clear () => void Deletes every record.
announce (props: string | NotificationAnnounceProps) => void An utterance with no record. Nothing is rendered or retained.
setItems SetState<NotificationStoreItem<T>[]> The SetState half of items. Never announces.
item (id: string | null | undefined) => NotificationStoreItem<T> | null Gets one record by id. A miss is null.
pause () => () => void 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

type NotificationStoreItem<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.
} & ({} extends T ? { 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".

Store options

interface NotificationStoreState<T = unknown> {
  items: NotificationStoreItem<T>[];
  renderedIds: string[];
  paused: boolean;
  timeout: number | null; // Default 5000.
  priority: "polite" | "assertive"; // Default "polite".
}

interface NotificationStoreProps<T = unknown> {
  defaultItems?: NoInfer<NotificationStoreItem<T>>[];
  items?: NoInfer<NotificationStoreItem<T>>[];
  setItems?: (items: NoInfer<NotificationStoreItem<T>>[]) => void;
  timeout?: number | null;
  priority?: "polite" | "assertive";
}
  • 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
NotificationList ol none none
NotificationListItem li none none
Notification div role="alertdialog", aria-modal="false", tabindex="0" 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 invariant TagValues 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.
  • It defaults to true when the card contains a data-notification-dismiss descendant, 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).
  • 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.
  • Propagation stops only when a record is removed; a declined Escape reaches the enclosing Dialog through the isValidTarget arm below. Seven surveyed systems close a focused toast on Escape; Radix cites Focused Toast removal with Escape key broken after 1.1.6-rc.1 radix-ui/primitives#2906.
  • 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.
  • promise: composes from push, update and item.
  • createNotificationContext<T>(): measured additive.
  • 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.

<>
  <Dialog
    open={open}
    onClose={() => setOpen(false)}
    getPersistentElements={() =>
      document.querySelectorAll("[data-notifications]")
    }
  >
    {/* ... */}
  </Dialog>
  <div data-notifications>
    {notifications.map((notification) => (
      <div key={notification.id} role="alert">
        Hello!
        <button
          aria-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.

Unmeasured, and substantial.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    a11yAccessibility-related issuefeatureNew feature or enhancement request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions