Skip to main content

Alert Center

A single, top-level entry point for one-to-many account alerts (usage limits, hard limits, billing problems) and the upgrade CTAs that resolve them. It replaces a stack of independent window banners with one severity-colored bar that surfaces the highest-priority alert inline (its message and its call-to-action), plus a compact count-and-caret trigger that expands the remaining alerts as full-width banners, ranked by severity.

Compose it into AppLayout.Notice, the single top-of-window composition slot for app-wide messaging. Alerts are authored as JSX: each AlertCenter.Item composes the normal Alert banner parts as children and registers its coordination facts (id, intent) with the center, which derives the count, the top alert, and the severity ranking from the registrations. Mount an item to show its alert; unmount it to dismiss — dismissal is your state flipping a condition off. The demo runs in its own framed document. Choose the count-and-caret control to expand or collapse the full-width banners, or dismiss alerts (including the top one) to watch the next-highest severity promote into the bar.

Composition

Compose the parts of an AlertCenter together to build your own. Items render null in place; their children are projected into the bar or an expansion row by rank:

AlertCenter.Root├── AlertCenter.Bar        ← the top-ranked item's children, in Alert chrome├── AlertCenter.Content    ← every other item's children, as ranked rows└── AlertCenter.Item (0..n, renderless)    └── Alert.Icon / Alert.Content / Alert.Title / Alert.Description /        AlertCenter.DismissIconButton

Polymorphism

Only one part takes asChild, and deliberately so:

  • AlertCenter.DismissIconButton inherits it from the IconButton it renders through Alert.DismissIconButton. Reach for it when dismissal is a link or a form submit rather than a click handler. The required onClick still runs, so keep the unmount there.

The rest have no asChild on purpose. AlertCenter.Root and AlertCenter.Item render no DOM at all, so there is nothing to swap: Root is the state owner, and an Item registers its facts and projects its children (see Projection). AlertCenter.Bar and AlertCenter.Content each render a fixed structure with no single default element a swap could coherently replace: the bar is a presence wrapper that owns the exit animation around an Alert.Root banner (swap the wrapper and the animation breaks; swap the banner and the chrome the part exists to render is gone), and the expansion is a focus-within container that owns the collapse animation around the <ul> of rows. Both also take children?: never, since the content is the registered items' projected children. The element a consumer actually wants to reach is each alert's banner chrome, an Alert.Root whose parts you already author as that item's children.

How it works

  • The bar surfaces the top alert inline. AlertCenter.Bar renders the highest-severity item's authored children (icon, title, description, and call-to-action) directly in the strip, so the most urgent CTA is one glance and zero extra clicks away. Hiding every CTA behind a click is exactly what makes banners fail to convert.
  • The count-and-caret control expands the full list. When two or more alerts are active, the bar renders a compact control that expands AlertCenter.Content beneath it. The additional alerts render as compact, full-width one- or two-line banners, push the app layout down, and can be collapsed by the same control.
  • Content is composed; facts are registered. Each AlertCenter.Item renders no DOM where it's authored: it registers { id, intent } and portals its children into a stable host the bar or an expansion row adopts (see Projection). You get real anchors, router links, stateful and context-aware JSX in every banner, while the count, the top alert, and the ordering stay derived and deterministic.
  • Ranking is severity, then order, then arrival. danger › warning › important › info › success; within an intent, alerts that share an order keep the order they first appeared: items mounting together rank in authored order, later arrivals append after the peers that share their order, and a dismissed alert that returns resumes its original position. When two same-intent alerts come from independent sources, arrival order depends on which source answers first. Set order on each item to pin which one the bar shows.
  • It collapses when empty. With no items mounted the bar animates out and renders nothing, mirroring AppLayout.Notice, so Root, Bar, and Content can all stay mounted unconditionally.
  • It announces politely. A persistent, visually-hidden role="status" live region (mounted by AlertCenter.Root) announces the top alert and how many more as alerts arrive or re-rank. A live region must exist before its text changes to be announced reliably. Its headline is the top alert's rendered Alert.Title text (inline CTA anchors stripped), re-read on every commit so the announcement stays in lockstep with the visible copy. The bar itself claims no ARIA banner landmark.

Basic usage

Author one AlertCenter.Item per alert, composing the Alert banner parts as its children. The consumer owns dismissal (including any persistence, e.g. sessionStorage): compose an AlertCenter.DismissIconButton whose onClick unmounts the item; dismissing the top alert promotes the next-highest severity into the bar. An item authored without one (say, a hard-stop billing failure the user must resolve) is not dismissable; see Dismissability for choosing which alerts offer it.

import { Alert } from "@ngrok/mantle/alert";import { AlertCenter } from "@ngrok/mantle/alert-center";import { useState } from "react"; export function AccountAlerts() {	const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());	const dismiss = (id: string) => setDismissed((previous) => new Set(previous).add(id)); 	return (		<AlertCenter.Root>			<AlertCenter.Bar />			<AlertCenter.Content />			{/* authored order is irrelevant — the center ranks by severity, then `order`, then arrival */}			{!dismissed.has("new-region") && (				<AlertCenter.Item id="new-region" intent="info">					<Alert.Icon />					<Alert.Content>						<Alert.Title>							New region available: eu-west <a href="/regions">Learn more</a>						</Alert.Title>						<Alert.Description>							You can now create endpoints in the eu-west region.						</Alert.Description>						<AlertCenter.DismissIconButton onClick={() => dismiss("new-region")} />					</Alert.Content>				</AlertCenter.Item>			)}			{!dismissed.has("tunnel-limit") && (				<AlertCenter.Item id="tunnel-limit" intent="warning">					<Alert.Icon />					<Alert.Content>						<Alert.Title>							Tunnel limit reached <a href="/billing/choose-a-plan">Upgrade</a>						</Alert.Title>						<Alert.Description>							Free accounts are limited to 4 simultaneous tunnels.						</Alert.Description>						<AlertCenter.DismissIconButton onClick={() => dismiss("tunnel-limit")} />					</Alert.Content>				</AlertCenter.Item>			)}			{!dismissed.has("transfer-limit") && (				<AlertCenter.Item id="transfer-limit" intent="warning">					<Alert.Icon />					<Alert.Content>						<Alert.Title>							You've used 92% of your monthly data transfer{" "}							<a href="/billing/choose-a-plan">Upgrade</a>						</Alert.Title>						<Alert.Description>Free accounts include 5 GB of transfer per month.</Alert.Description>						<AlertCenter.DismissIconButton onClick={() => dismiss("transfer-limit")} />					</Alert.Content>				</AlertCenter.Item>			)}			{!dismissed.has("payment-failed") && (				<AlertCenter.Item id="payment-failed" intent="danger">					<Alert.Icon />					<Alert.Content>						<Alert.Title>							Payment failed — update your card <a href="/billing">Update payment method</a>						</Alert.Title>						<Alert.Description>We couldn't charge the card ending in 4242.</Alert.Description>						<AlertCenter.DismissIconButton onClick={() => dismiss("payment-failed")} />					</Alert.Content>				</AlertCenter.Item>			)}		</AlertCenter.Root>	);}

Dismissability

Dismissability is composition, not configuration: an alert can be dismissed exactly when its children include an AlertCenter.DismissIconButton. Choose per alert, with the intent as your default policy:

  • danger alerts should generally not be dismissable. They represent hard-stop conditions (a failed payment, an expired certificate) that only resolving the underlying problem should clear. A dismissable danger alert trains users to swat away the one banner that genuinely blocks them.
  • warning and info alerts should generally be dismissable. They are acknowledgeable by nature (usage nags, rollout notices), and a nag that cannot be acknowledged erodes trust in the whole bar.
  • Both are best practices, not constraints the API enforces; dismissability stays a per-alert composition choice in either direction. A warning that describes an active blockage (a hard limit already being enforced) can withhold the dismiss button like a danger alert, and a danger alert can still compose one when your product calls for it. The framed shell demo at the top of this page does exactly that, so you can watch the top slot re-rank when its danger alert is dismissed.

Because the bar always surfaces the top-ranked alert, a non-dismissable danger alert keeps the bar pinned to the most urgent call-to-action while acknowledgeable alerts come and go beneath it. This demo opens expanded; note the top danger alert and the hard-limit warning render no dismiss button:

import { Alert } from "@ngrok/mantle/alert";import { AlertCenter } from "@ngrok/mantle/alert-center";import { useState } from "react"; export function Example() {	const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());	const dismiss = (id: string) => setDismissed((previous) => new Set(previous).add(id)); 	return (		<AlertCenter.Root defaultOpen>			<AlertCenter.Bar />			<AlertCenter.Content />			{/* hard-stop danger: no dismiss button — resolving it is the only way out */}			<AlertCenter.Item id="payment-failed" intent="danger">				<Alert.Icon />				<Alert.Content>					<Alert.Title>						Payment failed — update your card <a href="/billing">Update payment method</a>					</Alert.Title>				</Alert.Content>			</AlertCenter.Item>			{/* an actively-enforced hard limit: also not dismissable */}			<AlertCenter.Item id="tunnel-limit" intent="warning">				<Alert.Icon />				<Alert.Content>					<Alert.Title>						Tunnel limit reached — new tunnels are blocked{" "}						<a href="/billing/choose-a-plan">Upgrade</a>					</Alert.Title>				</Alert.Content>			</AlertCenter.Item>			{/* acknowledgeable warning and info alerts compose a dismiss button */}			{!dismissed.has("transfer-limit") && (				<AlertCenter.Item id="transfer-limit" intent="warning">					<Alert.Icon />					<Alert.Content>						<Alert.Title>							You've used 92% of your monthly data transfer{" "}							<a href="/billing/choose-a-plan">Upgrade</a>						</Alert.Title>						<AlertCenter.DismissIconButton onClick={() => dismiss("transfer-limit")} />					</Alert.Content>				</AlertCenter.Item>			)}			{!dismissed.has("new-region") && (				<AlertCenter.Item id="new-region" intent="info">					<Alert.Icon />					<Alert.Content>						<Alert.Title>							New region available: eu-west <a href="/regions">Learn more</a>						</Alert.Title>						<AlertCenter.DismissIconButton onClick={() => dismiss("new-region")} />					</Alert.Content>				</AlertCenter.Item>			)}		</AlertCenter.Root>	);}

Persisting dismissals

Dismissal is your state, so persistence is your choice of store. Session scope is usually the right default for acknowledgements: useSessionStorage keeps a dismissal for the rest of the tab session (a reload doesn't resurrect acknowledged nags) while a fresh session starts clean.

Most real alert bars render server state: the backend reports the currently-true conditions on every fetch, and the client authors one AlertCenter.Item per condition. Two rules make dismissal behave when the same alerts keep arriving as state:

  • Key dismissals on stable condition ids. Use the id of the condition ("transfer-limit"), not a per-occurrence UUID; otherwise every refetch mints a "new" alert and dismissal never sticks. Filter the server's list against the dismissed set before mounting items: the server keeps reporting the condition, and the client keeps suppressing it.
  • Prune dismissals the server no longer reports. When a condition clears server-side and later recurs, it is a new occurrence the user hasn't acknowledged. Drop stale ids from the dismissed set whenever the server list changes, so recurrences resurface instead of staying silenced forever. (If your backend versions occurrences, say a raisedAt timestamp, key dismissals on id:raisedAt and skip the pruning.) Thanks to sticky ranking, a returning condition resumes its original position within its intent tier.

The demo simulates the fetch loop: dismiss the transfer warning, then resolve and re-raise the condition on the "server". The alert returns even though its id was acknowledged, because the dismissal was pruned when the condition cleared. Reload the page mid-session and acknowledged alerts stay away.

import { Alert } from "@ngrok/mantle/alert";import type { AlertCenterIntent } from "@ngrok/mantle/alert-center";import { AlertCenter } from "@ngrok/mantle/alert-center";import { useSessionStorage } from "@ngrok/mantle/hooks";import { useEffect, useMemo } from "react"; type ServerAlert = {	id: string;	intent: AlertCenterIntent;	title: string;	cta: string;	href: string;}; /** Decode the sessionStorage entry (a JSON array of alert ids); corrupt entries mean "nothing dismissed". */function parseDismissedIds(raw: string): ReadonlyArray<string> {	try {		const parsed: unknown = JSON.parse(raw);		if (!Array.isArray(parsed)) {			return [];		}		return parsed.filter((entry): entry is string => typeof entry === "string");	} catch {		return [];	}} export function AccountAlerts({ serverAlerts }: { serverAlerts: ReadonlyArray<ServerAlert> }) {	const [dismissedRaw, setDismissedRaw] = useSessionStorage("dismissed-alerts", "[]");	const dismissed = useMemo(() => new Set(parseDismissedIds(dismissedRaw)), [dismissedRaw]);	const dismiss = (id: string) => setDismissedRaw(JSON.stringify([...dismissed, id])); 	// Reconcile: a dismissal only suppresses the occurrence the server is still	// reporting. When a condition clears server-side, forget its dismissal so a	// future recurrence resurfaces.	useEffect(() => {		const activeIds = new Set(serverAlerts.map((alert) => alert.id));		const pruned = [...dismissed].filter((id) => activeIds.has(id));		if (pruned.length !== dismissed.size) {			setDismissedRaw(JSON.stringify(pruned));		}	}, [serverAlerts, dismissed, setDismissedRaw]); 	return (		<AlertCenter.Root>			<AlertCenter.Bar />			<AlertCenter.Content />			{serverAlerts				.filter((alert) => !dismissed.has(alert.id))				.map((alert) => (					<AlertCenter.Item key={alert.id} id={alert.id} intent={alert.intent}>						<Alert.Icon />						<Alert.Content>							<Alert.Title>								{alert.title} <a href={alert.href}>{alert.cta}</a>							</Alert.Title>							{/* this app's dismissability policy, applied in one place: its danger alerts don't offer dismissal */}							{alert.intent !== "danger" && (								<AlertCenter.DismissIconButton onClick={() => dismiss(alert.id)} />							)}						</Alert.Content>					</AlertCenter.Item>				))}		</AlertCenter.Root>	);}

Supporting copy in the bar

The bar renders the top alert as authored (icon, title, Alert.Description, call-to-action), so it is a single line for a title alone and a two-line banner once a description is composed, exactly like the same alert renders as an expansion row. Nothing is suppressed by placement, so a CTA reads naturally from the description (Please update your payment method.) instead of being crammed into the title.

import { Alert } from "@ngrok/mantle/alert";import { AlertCenter } from "@ngrok/mantle/alert-center"; export function AccountAlerts() {	return (		<AlertCenter.Root defaultOpen>			<AlertCenter.Bar />			<AlertCenter.Content />			<AlertCenter.Item id="payment-failed" intent="danger">				<Alert.Icon />				<Alert.Content>					<Alert.Title>The last payment attempt for your account failed</Alert.Title>					<Alert.Description>						Please <a href="/billing/payment-overdue">update your payment method</a> to avoid a						service interruption.					</Alert.Description>				</Alert.Content>			</AlertCenter.Item>			<AlertCenter.Item id="ip-restriction" intent="warning">				<Alert.Icon />				<Alert.Content>					<Alert.Title>Your IP does not match an IP policy for this account</Alert.Title>					<Alert.Description>						While IP restrictions are enforced you won't be able to reach the dashboard.{" "}						<a href="/ip-restrictions">Review your IP restrictions</a>.					</Alert.Description>				</Alert.Content>			</AlertCenter.Item>		</AlertCenter.Root>	);}

Two things still argue for a title on every alert, and for keeping bar copy short:

  • The title is the announcement. The live region headlines with the top alert's rendered Alert.Title (CTA anchors stripped); a description is not announced. An alert with no title announces as a bare count.
  • The bar pushes the app shell down. It is the topmost thing in the window, so every line it grows costs the page. Detail that only matters once someone is reading the full list belongs in the expansion: the chrome around your children carries data-placement="bar" in the bar and data-placement="list" in the expansion, so in-data-[placement=bar]:hidden on any element (a description included) keeps it out of the bar and shows it only in a row.

Projection

AlertCenter.Item renders no DOM where it's authored; its children render through a portal into a stable per-item host element that the Bar/Content chrome physically adopts (typically inside AppLayout.Notice). The children's React tree stays where the item is written (only their DOM moves), which is what makes the projection behave like ordinary composition:

  • Context and events flow from the authoring site. Providers and error boundaries wrapping an individual item work, router links resolve against the route that renders the item, and the children's React events (clicks, focus) bubble to the author's tree; a wrapper around one item observes only its own alert.
  • Content keeps its state across re-ranks. The host element is moved between the bar and a row (never remounted), so component state in your alert content survives promotions and demotions. In engines with Element.moveBefore even focus and CSS transitions survive the move; elsewhere the center's focus redirects re-establish keyboard position.
  • Don't compose Alert.Root in children. The center renders the banner chrome, with the item's intent, at whichever placement the item ranks into. Children are the banner's content: Alert.Icon, Alert.Content, Alert.Title, Alert.Description, AlertCenter.DismissIconButton.
  • Ids identify the projection, so they must be unique. Both placements adopt the one stable host an id owns, so a second mounted item claiming an already-registered id throws rather than silently stealing the first item's content. Unmounting and remounting the same id is fine: that is what dismissal and a server-reported condition recurring are, and the returning alert resumes its original position.

The chrome around your children is also a styling hook. It carries data-placement="bar" in the bar and data-placement="list" in an expansion row (plus data-alert-id for the alert it holds), so in-data-[placement=bar]:hidden on any element inside an item's children keeps that element out of the bar and shows it only in a row; in-data-[placement=list]:hidden does the reverse.

A mounted item's children always render (React-wise), even while they rank nowhere visible (e.g. below the bar with no expansion composed, where the host stays detached from the document). Because items register from effects, avoid authoring them inside Suspense boundaries that can hide them (hiding unregisters the alert and the bar animates out), and server-rendered HTML contains no bar: it enters with its height animation after hydration, the same entrance every arriving alert gets by design.

Where AlertCenter.Root goes

Every other example on this page puts AlertCenter.Root inside AppLayout.Notice with its items as siblings, which is all a shell-authored alert needs. But projection's whole point is that a route can author an alert. AlertCenter.Item throws outside a Root, so for that to work Root has to be an ancestor of whatever renders your routes.

So wrap the shell, and leave only the chrome in the notice slot. Any route below can then raise an alert into the shell's bar by rendering an item: no shell state, no prop drilling, no context of your own. Choose Endpoints to have the route raise a DNS warning that outranks the account notice, then Agents to watch it clear:

app.example.com

import { Alert } from "@ngrok/mantle/alert";import { AlertCenter } from "@ngrok/mantle/alert-center";import { AppLayout } from "@ngrok/mantle/app-layout";import { Button } from "@ngrok/mantle/button";import { useState } from "react"; type RoutePath = "/agents" | "/endpoints"; const routes: ReadonlyArray<{ label: string; path: RoutePath }> = [	{ label: "Endpoints", path: "/endpoints" },	{ label: "Agents", path: "/agents" },]; /** A route that raises its own alert: authored here, rendered in the shell's bar. */function EndpointsRoute() {	return (		<div className="space-y-2 p-6">			<AlertCenter.Item id="dns-error" intent="warning">				<Alert.Icon />				<Alert.Content>					<Alert.Title>						This domain still needs DNS targets <a href="/domains">Review domains</a>					</Alert.Title>				</Alert.Content>			</AlertCenter.Item>			<p className="text-strong text-sm font-medium">app.example.com</p>		</div>	);} /** A route with nothing to report — leaving the endpoint route clears its alert. */function AgentsRoute() {	return <p className="text-strong p-6 text-sm font-medium">2 agents online</p>;} export function Shell() {	const [pathname, setPathname] = useState<RoutePath>("/endpoints"); 	return (		<AlertCenter.Root>			<AppLayout.Root>				<AppLayout.Notice>					{/* only the chrome lives here — items are authored anywhere below */}					<AlertCenter.Bar />					<AlertCenter.Content />					{/* an account-level alert the shell itself authors */}					<AlertCenter.Item id="new-region" intent="info">						<Alert.Icon />						<Alert.Content>							<Alert.Title>								New region available: eu-west <a href="/regions">Learn more</a>							</Alert.Title>						</Alert.Content>					</AlertCenter.Item>				</AppLayout.Notice>				<AppLayout.Workspace>					<AppLayout.Content>						<AppLayout.Header>							{routes.map((route) => (								<Button									key={route.path}									type="button"									appearance={pathname === route.path ? "filled" : "outlined"}									intent="neutral"									size="sm"									onClick={() => setPathname(route.path)}								>									{route.label}								</Button>							))}						</AppLayout.Header>						{/* Why asChild: the docs page already owns the main landmark. */}						<AppLayout.Body asChild>							<div>{pathname === "/endpoints" ? <EndpointsRoute /> : <AgentsRoute />}</div>						</AppLayout.Body>					</AppLayout.Content>				</AppLayout.Workspace>			</AppLayout.Root>		</AlertCenter.Root>	);}

The item unmounts with the route, so the alert clears on navigation for free. The route buttons stand in for a router. A real shell renders its Outlet inside AppLayout.Body, which is the main landmark, and pins the shell with className="fixed inset-0", exactly as in the example at the top of this page. This demo is embedded in a docs page that already owns the main landmark, so it composes AppLayout.Body onto a plain <div> with asChild and does not pin.

One consequence worth knowing: AlertCenter.Root renders the sr-only live region at its own position in the tree, so hoisting it also moves that element out of the notice slot. It is visually hidden either way, and it must stay mounted to announce anything, so this is harmless. But it is why Root is renderless rather than a wrapper you can style.

A single alert

With exactly one alert there is nothing hidden, so the bar shows it inline with its CTA and omits the count-and-caret control.

import { Alert } from "@ngrok/mantle/alert";import { AlertCenter } from "@ngrok/mantle/alert-center"; export function AccountAlerts() {	return (		<AlertCenter.Root>			<AlertCenter.Bar />			<AlertCenter.Content />			<AlertCenter.Item id="payment-failed" intent="danger">				<Alert.Icon />				<Alert.Content>					<Alert.Title>						Payment failed — update your card <a href="/billing">Update payment method</a>					</Alert.Title>				</Alert.Content>			</AlertCenter.Item>		</AlertCenter.Root>	);}

Custom content

Every banner is authored JSX, so anything composes: a custom icon via Alert.Icon's svg prop, inline code, or placement-aware extras using the data-placement styling hook.

import { Alert } from "@ngrok/mantle/alert";import { AlertCenter } from "@ngrok/mantle/alert-center";import { LightbulbIcon } from "@phosphor-icons/react/Lightbulb"; export function AccountAlerts() {	return (		<AlertCenter.Root defaultOpen>			<AlertCenter.Bar />			<AlertCenter.Content />			<AlertCenter.Item id="transfer-limit" intent="warning">				<Alert.Icon />				<Alert.Content>					<Alert.Title>						Approaching your data transfer limit <a href="/billing/choose-a-plan">Upgrade</a>					</Alert.Title>				</Alert.Content>			</AlertCenter.Item>			<AlertCenter.Item id="usage-tip" intent="info">				<Alert.Icon svg={<LightbulbIcon />} />				<Alert.Content>					<Alert.Title>						Tip: agent version <code translate="no">3.24</code> reconnects dropped tunnels						automatically.					</Alert.Title>					<p className="in-data-[placement=bar]:hidden text-sm">						Run <code translate="no">ngrok update</code> to get the latest agent.					</p>				</Alert.Content>			</AlertCenter.Item>		</AlertCenter.Root>	);}

Need a fully custom bar (different chrome, your own expand affordance)? That's outside AlertCenter's scope by design: compose Alert's banner parts (including Alert.ExpandButton) directly and manage your own state.

Testing

An alert's title is in the accessibility tree more than once, by design. AlertCenter.Root mounts an sr-only polite live region that headlines with the top alert's title, and the bar renders that same title visibly. If the item composes an AlertCenter.DismissIconButton, its accessible name ("Dismiss &lt;title&gt;") is a third copy. None of them is aria-hidden: a polite region has to pre-exist in the tree to announce anything, and hiding the visible title would break the bar.

So getByText(/Payment failed/) throws Found multiple elements. Scope the query instead of matching text globally. Four patterns cover everything:

// 1. Assert the bar's content by scoping to its data-slotconst bar = container.querySelector('[data-slot~="alert-center-bar"]');expect(bar).toContainElement(screen.getByRole("heading", { level: 5, name: /Payment failed/ })); // 2. Or assert text on the element itself rather than querying the documentexpect(bar).toHaveTextContent("Payment failed"); // 3. Read the live region through its role, with an ANCHORED regex — an//    unanchored one also matches the "Payment failed, and 2 more alerts" formexpect(screen.getByRole("status")).toHaveTextContent(/^Payment failed$/);expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); // 4. Reach controls by role + accessible nameawait user.click(screen.getByRole("button", { name: "Dismiss Payment failed" }));

getByText is still fine for a description, which the live region never contains; it headlines with the title only.

Two more things worth knowing when writing these tests:

  • The live region's text is derived from the bar's rendered DOM, not from your props, so it settles in an effect. Assert it with await waitFor(...) if you are checking it immediately after a state change.
  • Use data-alert-id to target one specific alert when several are registered, and data-placement to assert where an item's children ended up (bar vs list) rather than inferring it from ordering.

API Reference

AlertCenter.Root

The renderless state owner. It creates the registration store the items register into, owns the expanded state of the inline list, and mounts the persistent role="status" live region. Compose its Bar and Content inside AppLayout.Notice, alongside any other window-level notice.

PropTypeDefaultDescription
openboolean—Controlled expanded state of the additional-alert list. Pair with onOpenChange.
defaultOpenbooleanfalseUncontrolled initial expanded state of the additional-alert list.
onOpenChange(open: boolean) => void—Called when the additional-alert list expands or collapses.
childrenReactNode—The Bar, the Content, and every Item. Items may be authored anywhere under the root — their children stay in this tree and are projected into the bar or an expansion row by rank.

Root renders no DOM of its own beyond the live region it mounts:

Data AttributeValueDescription
data-slotalert-center-announcerOn the persistent visually-hidden role="status" live region Root mounts.

AlertCenter.Bar

The always-visible, full-width strip. It renders the highest-severity item's children inside a banner Alert chrome (with that item's intent), appends the count-and-caret expansion control when more alerts exist and an AlertCenter.Content is composed (without one there is nothing to expand, so no control renders), and collapses to nothing when there are no alerts. It claims no ARIA banner landmark.

When the top alert changes while keyboard focus was on a control that the change removed (dismissing alert after alert), focus is redirected to the new bar's first trailing control instead of dropping to <body>; a control that survives the swap keeps focus.

All props from div are forwarded to the banner chrome. appearance and intent are the component's own: the chrome is always a banner Alert.Root carrying the top item's intent.

PropTypeDefaultDescription
classNamestring—Merged onto the banner chrome after the bar's own classes, so yours win. The presence wrapper that animates the bar in and out is not stylable.
data-slotstring—Joined ancestors-first with alert-center-bar on the chrome, so a wrapping component can add its own slot without dropping this one.
refRef<HTMLDivElement>—Forwarded to the banner chrome, not to the presence wrapper.
childrennever—Not accepted — the bar renders the top-ranked item's authored children.
Data AttributeValueDescription
data-slotalert-center-bar-wrapperOn the presence wrapper that owns the enter/exit animation.
data-slotalert-center-bar alertOn the banner chrome the top item's children render inside, joined ahead of Alert.Root's own slot.
data-slotalert-center-bar-mountOn the layout-transparent element the top item's host is adopted into.
data-state"open" | "closed"On the wrapper; drives the enter/exit transition, and stays closed while the exit plays.
data-placement"bar"On the chrome — where the top item's children are currently rendering.
data-alert-idthe top item's idOn the chrome; per-alert styling and testing hook.
data-alert-dismisspresent on the dismiss controlRead, not stamped — Alert emits it; the bar's CSS centers and offsets the control with it.
data-alert-expandpresent on the expand controlRead, not stamped — Alert emits it; the bar's CSS centers the control with it.

AlertCenter.Content

The inline expansion beneath the bar. It lists every alert hidden by the bar, ranked highest-severity-first, as full-width banner rows. It renders only when additional alerts exist.

All props from div except children and id — id is reserved, since it anchors the expand control's aria-controls — plus:

PropTypeDefaultDescription
aria-labelstring"More alerts"Names the row list (<ul>). The wrapper around it is role="generic", where ARIA prohibits an accessible name, so the label lands on the list itself.
aria-labelledbystring—Names the row list from another element's id; when set, aria-label is not applied.
classNamestring—Merged onto the expansion wrapper after the collapse-animation classes.
data-slotstring—Joined ancestors-first with alert-center-content on the wrapper.
refRef<HTMLDivElement>—Forwarded to the expansion wrapper.
childrennever—Not accepted — the rows are the registered items' authored children.
Data AttributeValueDescription
data-slotalert-center-contentOn the expansion wrapper that owns the collapse animation.
data-slotalert-center-item alertOn each row's banner chrome, joined ahead of Alert.Root's own slot.
data-state"open" | "closed"On the wrapper; drives the collapse transition.
data-placement"list"On each row's chrome — where that item's children are currently rendering.
data-alert-idthe row item's idOn each row's chrome; per-alert styling and testing hook.
data-alert-mountthe row item's idOn the row's layout-transparent mount point — the element that item's host is adopted into.

AlertCenter.Item

One authored alert: the coordination facts as props, the banner content as children. Renders null in place and registers with the center; mount to show, unmount to dismiss. Two items mounted at once may not share an id — the second registration throws, since both would project into that id's single host.

children render inside a <div data-slot="alert-center-item-label">, which is the only node the portal puts in the host. A portal owns no node of its own, so React removes each portal child from the host by itself. A browser translation engine reparents a bare text child. A removal aimed at one throws, so the wrapper keeps the removed node an element. The wrapper is display: contents, so it adds no box: your children stay direct flex items of the banner chrome and keep its gap. Browser translation covers the mechanism and the rules your own components need.

PropTypeDefaultDescription
idstring—Stable identity: keys the projection, pins arrival order (a returning id resumes its spot), and stamps data-alert-id. Unique among the items mounted under one AlertCenter.Root; unmounting and remounting the same id is fine.
intent"danger" | "important" | "info" | "success" | "warning"—The tone the alert communicates. Drives severity ranking and the banner chrome's intent.
classNamestring—Optional classes for this item's chrome Alert.Root, in both placements.
ordernumber—The position among same-intent peers. Lower comes first. Items that share an order keep arrival order. An item with no order follows every item that declares one. Infinity counts as declared. intent still decides the tier.
childrenReactNode—The banner content — Alert parts, not Alert.Root. Rendered at the Bar/Content position, not where the item is authored.
Data AttributeValueDescription
data-slotalert-center-item-hostOn the stable per-id host element the item's children portal into.
data-slotalert-center-item-labelOn the layout-transparent element inside the host that wraps the item's children.
data-alert-hostthe item's idOn that same host — which alert's projected children it holds.

AlertCenter.DismissIconButton

The per-item dismiss affordance: a thin wrapper over Alert.DismissIconButton that defaults its label to Dismiss ${title} — derived from the enclosing banner's rendered Alert.Title text with inline CTA anchors stripped and re-read on every commit, so the accessible name stays in lockstep with the visible copy. Compose it — or leave it out — per alert; its presence is what makes that alert dismissable. Where it sits is the chrome's business: the bar centers its trailing controls while it is a single line and top-aligns them once the top alert has a description, matching every expansion row.

All props from Alert.DismissIconButton, plus:

PropTypeDefaultDescription
onClickMouseEventHandler—Required. Unmount the item (update your state); the center re-ranks from what remains.
labelstringDismiss ${title}Accessible name override — e.g. when the rendered title is long.
data-slotstring—Joined ancestors-first with alert-center-dismiss-icon-button on the rendered button.
Data AttributeValueDescription
data-slotalert-center-dismiss-icon-button alert-dismiss-icon-button icon-buttonOn the rendered button, joined ahead of Alert.DismissIconButton's own slot and IconButton's.
data-alert-dismisspresentRead, not stamped — Alert emits it, and AlertCenter's bar CSS positions the control with it.