---
title: "AlertBanner"
description: "A message that appears at the top of a screen because something needs your attention now, saying what has happened and what to do about it."
url: "https://opsinjs.pensievelabs.org/components/alert-banner"
source: "https://opsinjs.pensievelabs.org/components/alert-banner.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-communication"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["warning", "notification", "alert", "banner", "warning banner", "notification banner", "inline alert"]
governedBy: ["two-colour-axes", "clinical-status-semantics", "alarm-fatigue", "motion-in-health-ui", "emergency-and-escalation", "notifications-and-off-screen-alerts", "crisis-and-self-harm", "delivering-difficult-results"]
usedIn: ["alert-escalation", "choose-a-component", "forms/error-summaries", "offline-and-stale-data", "result-disclosure", "results-screen", "staged-alert"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="alert-banner"
  status="shipped"
  questions="[
  &#x22;The budget of one urgent banner and two per screen is not enforced at runtime, because a banner only sees itself.&#x22;,
  &#x22;Nobody has listened in a screen reader: whether a heading survives navigation inside role=\&#x22;alert\&#x22;, and what in-place escalation announces, are untested.&#x22;,
  &#x22;`steady` is legal for de-escalation, yet a banner that can say nothing needs attention is one products will reach for to be noticed.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="alert-banner" />

## Installation [#installation]

<ComponentInstall name="alert-banner" unbuilt="false" importPath="@/components/ui/alert-banner" registryDependencies="[&#x22;status-pill&#x22;, &#x22;button&#x22;, &#x22;link&#x22;]" />

## Usage [#usage]

```tsx
import { AlertBanner } from "@/components/ui/alert-banner"
```

```tsx
<AlertBanner
  status="attention"
  heading="Your example measurement is outside the range your clinic set"
  detectedAt="2026-03-14T09:40:00+00:00"
  actions={[
    { label: "Contact your clinic", href: "/clinic" },
    { label: "See the reading and the range", href: "/results/example" },
  ]}
>
  Contact your clinic before your next appointment. This reading is outside the range they
  asked us to tell you about.
</AlertBanner>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Something has changed since the reader last looked, and they need to know before carrying on.&#x22;,
  &#x22;A reading has crossed a threshold the product owns, with a specific action attached.&#x22;,
  &#x22;A condition affects the whole screen: stale data, a disconnected device, an unfinished sync.&#x22;,
]"
  avoid="[
  { case: &#x22;Information the reader can take or leave, where nothing changes if they do not read it.&#x22;, instead: &#x22;callout&#x22; },
  { case: &#x22;The status belongs to one result, not the screen.&#x22;, instead: &#x22;status-pill&#x22; },
  { case: &#x22;The content is an instruction with steps and timing, not an announcement.&#x22;, instead: &#x22;care-card&#x22; },
  { case: &#x22;The reader must respond before anything else can happen.&#x22;, instead: &#x22;dialog&#x22; },
  { case: &#x22;Someone has disclosed thoughts of self-harm or suicide, which is not the status axis. See Crisis and self-harm.&#x22;, instead: &#x22;care-card&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** That the product has decided this screen needs the reader's attention at one
of four levels, with something specific to do at `attention` and `urgent`. The component
compares nothing: `status` is assigned from a range the product owns.

**Never read as.** A clinical judgement about the reader, an instruction from a person, or
a substitute for emergency services. A finding routed *stop and call for help now* leaves
the banner. See [Emergency and escalation](../health/emergency-and-escalation.mdx).

**Colour axis.** Status only. There is no `category` prop, so a banner never carries
category colour. Urgency is never carried by motion either, and the component ships no
transition. See [Motion in health UI](../health/motion-in-health-ui.mdx).

**Thresholds.** None. No staleness default and no number in the component. The level's
word comes from the composed [StatusPill](./status-pill.mdx), never from `heading`.

**Budget.** At most one `urgent` banner and two banners per screen, per
[Alarm fatigue](../health/alarm-fatigue.mdx). The component cannot count across a screen.
No live region mounts at `steady` or `watch`, a named departure from
[Clinical status semantics](../health/clinical-status-semantics.mdx) rule 6.

<SafetyCallout severity="urgent" evidence="opinion">
  An `urgent` banner must never be the only place a serious message appears, and never
  dismissible without an acknowledgement the product records.
</SafetyCallout>

## Anatomy [#anatomy]

<Anatomy
  name="alert-banner"
  parts="[
  {
    name: &#x22;AlertBanner&#x22;,
    describes: &#x22;The root, carrying data-status and the level's surface, ink and line roles.&#x22;,
    prop: &#x22;status&#x22;,
  },
  {
    name: &#x22;AlertBanner.Announcement&#x22;,
    describes: &#x22;The live region around the heading and body only. Its role follows the level.&#x22;,
  },
  {
    name: &#x22;AlertBanner.Heading&#x22;,
    describes: &#x22;A real h2 to h6: the level's word, then the caller's subject.&#x22;,
    prop: &#x22;headingLevel&#x22;,
  },
  {
    name: &#x22;StatusPill&#x22;,
    describes: &#x22;The level's word and glyph, composed as the heading's first child.&#x22;,
  },
  {
    name: &#x22;AlertBanner.Body&#x22;,
    describes: &#x22;One or two sentences, second person. Longer is a CareCard.&#x22;,
    prop: &#x22;children&#x22;,
  },
  {
    name: &#x22;AlertBanner.Actions&#x22;,
    describes: &#x22;At most two, each a composed Link or Button. The first takes the emphasis.&#x22;,
    prop: &#x22;actions&#x22;,
  },
  {
    name: &#x22;AlertBanner.Dismiss&#x22;,
    describes: &#x22;A quiet Button, last in the DOM. Rendered only with dismissible and onAcknowledge.&#x22;,
    prop: &#x22;dismissible&#x22;,
  },
]"
/>

## Examples [#examples]

### Two is the ceiling [#two-is-the-ceiling]

Two banners, the more serious first, because a reader meets them in DOM order. A third
teaches the reader to scroll past the second.

<ComponentPreview name="alert-banner-two-is-the-ceiling" kind="example" align="start" />

### An urgent banner is acknowledged, not dismissed [#an-urgent-banner-is-acknowledged-not-dismissed]

`dismissible` renders nothing without `onAcknowledge`, and at `urgent` that callback is the
record. One action, because the route is same-day. Removing the banner drops focus to
`<body>`, which the product must recover.

<ComponentPreview name="alert-banner-acknowledging-an-urgent-banner" kind="example" align="start" />

### `steady` is for de-escalation [#steady-is-for-de-escalation]

The one honest use of the quietest level: withdrawing an alert the product raised earlier.
No `actions`, because only `attention` and `urgent` require one.

<ComponentPreview name="alert-banner-steady-is-for-de-escalation" kind="example" align="start" />

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA** in a source pass and a rendered pass. This is an author-run
audit, not an independent review, and clinical review is still pending. The rendered pass
was clean, with no axe violation and no target-size or focus-visible gap. The source pass
found nothing to change.

Confirmed: colour never carries meaning alone, because the word and glyph come from the
composed [StatusPill](./status-pill.mdx) and shape and position separate the levels. The
live region wraps the heading and body only, `role="alert"` at `urgent` and
`aria-live="polite"` with `aria-atomic` at `attention`. Focus is never moved and every
control clears the 44pt floor.

Two residuals. At `steady` and `watch` the component mounts no live region, so a
de-escalation banner inserted after a condition resolves is not announced to a reader
already on the page. That is a deliberate departure from
[Clinical status semantics](../health/clinical-status-semantics.mdx) rule 6 on
alarm-fatigue grounds, a doctrine call left for a human reviewer. Escalating in place also
relies on the caller passing `key={status}` to remount, which the component cannot
enforce.

Still ungated: any real screen reader, `forced-colors`, and the root's missing accessible
name.

<KeyboardTable
  name="alert-banner"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves through the actions, then dismiss&#x22;,
    notes: &#x22;The banner itself is not a tab stop.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;The same in reverse&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Follows a link action or activates a button&#x22;,
    notes: &#x22;href renders an anchor, onSelect a real button.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates a button action or the dismiss&#x22;,
    notes: &#x22;On a link action Space scrolls the page.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;The dismiss control is the only way out.&#x22;,
  },
]"
/>

<ContrastReport component="alert-banner" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="alert-banner"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root, always&#x22;,
    value: &#x22;alert-banner&#x22;,
  },
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On inner parts&#x22;,
    value: &#x22;alert-banner-announcement, -heading, -heading-text, -body, -actions, -action, -dismiss&#x22;,
  },
  {
    attribute: &#x22;data-status&#x22;,
    condition: &#x22;On the root, always&#x22;,
    value: &#x22;steady | watch | attention | urgent&#x22;,
  },
  {
    attribute: &#x22;data-detected-at&#x22;,
    condition: &#x22;On the root, when detectedAt is non-empty&#x22;,
    value: &#x22;The ISO 8601 instant, verbatim. Absent otherwise.&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="AlertBannerProps" />

A `status` outside the four levels renders nothing and reports OPSIN-0021 (OPSIN-0011 for
`unknown`). `heading` names the subject, because the component renders the level's word.
`headingLevel` outside 2 to 6 falls back to `h2`. A third action renders and is reported.
`dismissible` needs `onAcknowledge` at every level. `now`, `locale` and `timeZone` render
nothing. `AlertAction` is also exported:

```ts
export interface AlertAction {
  label: string
  href?: string
  onSelect?: () => void
  recommended?: boolean
}
```

## Related [#related]

* [Callout](./callout.mdx) carries no clinical level and interrupts nothing.
* [CareCard](./care-card.mdx) is the instruction itself, with steps and timing.
* [StatusPill](./status-pill.mdx) is the same vocabulary on one item, not the screen.
* [Toast](./toast.mdx) is transient confirmation. Nothing clinical belongs on a timer.
* [Dialog](./dialog.mdx) is for when the reader cannot continue until they respond.
