Skip to content

Banner

Persistent, in-flow messages that stay until the situation changes. If it should disappear on its own, it is a toast.

Also called Alert, Inline Notification, Callout, Message Bar, Notice — in this system all of them are Banner.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Certificate expires in 6 days

Renewal is automatic, but the DNS challenge record is missing.

The severity ladder

Four levels, each with a distinct job. If everything is a warning, nothing is — the ladder only works if each level is used sparingly.

Scheduled maintenance

The API will be read-only on Sunday from 02:00 to 04:00 UTC.

Domain verified

app.acme.com now points at this project.

Certificate expires in 6 days

Renewal is automatic, but the DNS challenge record is missing.

Placement

Page-level at the top of the content column, section-level directly above the thing it describes, and inline for a specific control.

Billing

Payment method

Card expires next month

Section level — directly above the thing it describes.

Form error summary

After a failed submit, an alert at the top of the form lists what went wrong and links to each field. Focus moves here, so keyboard and screen-reader users are not left hunting.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Scheduled maintenance

Info

Domain verified

Success

Certificate expires in 6 days

Warning
Danger

New: deployment previews

AccentAnnouncements

This project is archived

Neutral

Quiet

QuietNo tinted fill

Read-only project

Title only
With actions

Dismissible

Dismissible
Greyscale

Certificate expires in 6 days

Scheduled maintenance

Stacked

Anatomy

Every part, every measurement, and the reason it is that number.

Icon, title, body, up to two actions, and an optional dismiss. Everything shares a single left edge below the icon gutter.

  1. Icon17px, tone-coloured

    The redundant encoding for the tone. It is the reason the message still works in greyscale and in high-contrast mode.

  2. Icon gutter12px

    Title, body and actions all align to the same left edge past the icon, so the text block reads as one column.

  3. Fill14% alpha tint

    A tint, not a solid. A solid status colour behind body text cannot reach 4.5:1 in both themes.

  4. Border1px at 34% alpha

    Reaches 3:1 against the surface, which WCAG requires of any meaningful boundary — and it is what survives High Contrast Mode.

  5. Title and body13px / 540 and 13px / 400

    The body is --ds-fg-secondary, not the tone colour. Colouring the whole message red reduces legibility and overstates the severity.

  6. ActionsMax 2, 6px above

    Two actions is the ceiling. A third means the user is making a decision, and decisions belong in a dialog.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-danger-subtle—Tinted fill
--ds-danger-border—Border — ≥3:1 against the surface
--ds-danger-text—Icon, certified against the tint
--ds-fg—Title
--ds-fg-secondary—Body copy — deliberately neutral
--ds-surface—Quiet variant background

Spacing

TokenValueUsed for
paddingAll sides
icon gutterIcon to text column
title to bodyInside the text column

Radius

TokenValueUsed for
--radius-lgCorners

Typography

TokenValueUsed for
--text-label—Title
--text-body-sm—Body

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizePaddingRadiusLabel gapMax widthWhen to use
Quiet14px12px—68chDense pages where a tinted block would be too loud. Border only.
Default14px12px—68chEverything. Tinted fill plus a border.
With actions14px—6px above actions68chUp to two actions. A third means it should be a dialog.
Callout12px 16px0 8px 8px 0—68chEditorial emphasis inside prose. Left rule instead of a full border.

Do

Say what happened and what to doAn alert with no next step is a complaint. "Payment declined" plus "Update card" turns a dead end into a task the user can complete.

Billing page

Card expires next month

Put it where the condition appliesA billing warning belongs on the billing page. A global banner on every screen is read once and then filtered out permanently.
Pair the tone with an icon and a wordColour alone fails in greyscale, for colour-blind users, and in High Contrast Mode. The icon and the title carry the severity independently.

New feature available

Only allow dismissal when it is honestIf the condition is still true after the alert is closed, the close button hides a real problem. Unresolved states should have no dismiss control.

Don't

Scheduled maintenance

Certificate expires in 6 days

New: deployment previews

Do not stack four alertsA wall of banners pushes the actual content below the fold and guarantees none of them is read. Consolidate, or move the low-priority ones somewhere else.
Do not use danger for something that is merely notableRed means broken or irreversible. Spending it on "your trial ends in 14 days" means users stop believing red when a service actually fails.

Verify your email

Do not put a form inside an alertAn alert states a condition. Once it contains inputs it is a task surface, and it should be a card, a section, or a dialog with proper form semantics.
<div role="alert">Saved successfully</div>
Do not use role="alert" for non-urgent messagesrole="alert" interrupts a screen reader mid-sentence. Using it for a success message makes the product hostile to listen to.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.1Use of ColorA1.4.3Contrast (Minimum)AA3.3.1Error IdentificationA3.3.3Error SuggestionAA4.1.3Status MessagesAA

Contrast

  • Title and body sit on a tint, so both must be verified against the composited colour, not against the raw surface.
  • The icon uses the -text token because it sits on the tint. Using the solid tone colour there lands around 3:1 and fails.
  • The border must reach 3:1 — it is the boundary that makes the alert a distinct region.

Keyboard

TabReaches the actions and the dismiss button. The alert body itself is not focusable.
Enter / SpaceActivates the focused action.
EscDoes nothing. An alert is in-flow, not an overlay — Escape belongs to dialogs.

Screen readers

  • A live region must exist in the DOM before its content changes. Rendering the whole alert at once means many screen readers announce nothing.
  • Do not announce a static alert that was present on page load — it is read in normal document order and a live region would duplicate it.
  • Keep the title short and put it first. Screen-reader users hear the whole alert; front-loading the point matters more than in visual reading.

Focus & touch

  • After a failed form submit, move focus to the error summary alert with tabIndex={-1}. It is the one case where an alert should take focus, and it saves keyboard users from hunting for what went wrong.
  • The dismiss button is 28px visually and padded to 44px on coarse pointers. Keep it clear of the action buttons so a mis-tap does not dismiss instead of acting.
AttributeApplied toNotes
role="alert"Danger onlyInterrupts immediately. Correct for errors, hostile for anything else.
role="status"Info, success, warningWaits for a natural pause in the screen reader’s output.
aria-liveThe containerassertive for danger, polite for everything else. Must be in the DOM before the content changes, or nothing is announced.
aria-labelledbyThe alertPoints at the title, so the alert has a name in the landmarks list.
aria-labelThe dismiss button"Dismiss payment warning", not just "Dismiss". A page with three alerts needs three distinct names.

Code

Example usage

tsx
1import { Alert } from '@/ui/Feedback'23// Persistent condition with a next step4<Alert5  tone="danger"6  title="Payment declined"7  actions={<Button size="sm" variant="danger" onClick={updateCard}>Update card</Button>}8>9  Card ending 4242 was refused. Services pause in 3 days.10</Alert>1112// Dismissible — only when dismissing is honest13<Alert tone="info" title="New: deployment previews" onDismiss={() => setSeen(true)}>14  Every pull request now gets its own URL.15</Alert>1617// Form error summary. Focus it after a failed submit.18const summaryRef = useRef<HTMLDivElement>(null)1920async function onSubmit(e) {21  e.preventDefault()22  const errs = validate(values)23  if (Object.keys(errs).length) {24    setErrors(errs)25    summaryRef.current?.focus()      // saves keyboard users from hunting26    return27  }28  await save(values)29}3031<div ref={summaryRef} tabIndex={-1}>32  <Alert tone="danger" title={count + ' fields need attention'}>33    <ul>{fields.map((f) => <li key={f.id}><a href={'#' + f.id}>{f.label}</a> — {f.error}</li>)}</ul>34  </Alert>35</div>

Framework-free HTML

html
<!-- Danger: interrupts -->
<div class="ds-alert ds-alert--danger" role="alert" aria-live="assertive">
  <svg class="ds-alert__icon" aria-hidden="true">…</svg>
  <div class="ds-alert__content">
    <p class="ds-alert__title">Payment declined</p>
    <p class="ds-alert__body">Card ending 4242 was refused by the issuer.</p>
    <div class="ds-alert__actions">
      <button class="ds-btn ds-btn--danger ds-btn--sm">Update card</button>
    </div>
  </div>
  <button class="ds-alert__dismiss" aria-label="Dismiss payment warning">
    <svg aria-hidden="true">…</svg>
  </button>
</div>

<!-- Everything else: waits for a pause -->
<div class="ds-alert ds-alert--success" role="status" aria-live="polite">…</div>

CSS

css
.ds-alert {
  display: flex;
  gap: 12px;                         /* the icon gutter */
  padding: 14px;
  border: 1px solid;
  border-radius: var(--radius-lg);
}

/* A 14% tint, not a solid. Body text cannot reach 4.5:1 on a solid
   status colour in both themes. */
.ds-alert--danger {
  background: var(--ds-danger-subtle);
  border-color: var(--ds-danger-border);
}
.ds-alert--danger .ds-alert__icon { color: var(--ds-danger-text); }

.ds-alert__title { font-size: 13px; font-weight: 540; color: var(--ds-fg); }

/* Body stays neutral — colouring the whole message reduces legibility
   and overstates the severity. */
.ds-alert__body {
  font-size: 13px;
  line-height: 1.6;
  color: var(--ds-fg-secondary);
}

.ds-alert__actions { display: flex; gap: 8px; margin-block-start: 6px; }

/* Quiet: keep the border, drop the fill */
.ds-alert--quiet { background: var(--ds-surface); }

/* High Contrast Mode removes the tint — the border is the fallback */
@media (forced-colors: active) {
  .ds-alert { border: 1px solid CanvasText; }
}

Component API

Alert

PropTypeDefaultDescription
tone'info' | 'success' | 'warning' | 'danger' | 'accent' | 'neutral''info'Also selects the icon and the ARIA role.
titleReactNode—Short and first. Screen-reader users hear the whole alert.
childrenReactNode—Body copy. Rendered in neutral foreground, not the tone colour.
actionsReactNode—Up to two. A third means this should be a dialog.
onDismiss() => void—Adds a close button. Omit when the condition is unresolved.
iconReactNode—Overrides the tone icon. Keep it at 17px.
quietbooleanfalseRemoves the tinted fill, keeps the border. For dense pages.

Notes

Professional tips

  • Write the action label before the message. If you cannot name a next step, the alert probably should not exist.
  • For an alert that appears after page load, render the live region first and populate it a tick later — otherwise many screen readers announce nothing.
  • Consolidate related conditions into one alert with a list. Three separate warnings about the same domain is three times the noise for the same information.
  • Give dismissals a lifetime. "Do not show again" belongs in preferences; a session dismissal that reappears tomorrow is usually the right default.

Performance

  • An alert inserted at the top of a page shifts everything below it. Reserve the space or animate it in, or you get a large cumulative layout shift score.
  • Do not poll for conditions on an interval just to keep an alert current. Push the state change and re-render once.
  • A live region that re-renders on every keystroke floods the screen-reader queue. Debounce anything driven by input.

Common mistakes

  • Using role="alert" for success messages, so the screen reader interrupts the user to say "Saved".
  • Rendering the live region and its content at the same moment, so nothing is announced at all.
  • Colouring the entire message body in the tone colour, which reduces legibility and makes every alert feel like an emergency.
  • Putting a dismiss button on a condition that is still true, so the problem is hidden rather than resolved.
  • Global banners that appear on every page. They are read once, then permanently filtered out.

Real-world recommendations

  • Count the alerts on your busiest screen. More than two at once almost always means the information architecture is doing the alerting instead of the design.
  • Alert fatigue is real and measurable: track dismissal rate versus action rate. An alert dismissed 95% of the time is noise, not information.
  • For system-wide incidents, use one persistent banner in the app shell with a link to a status page, and keep the page-level alerts for things the user can actually act on.
  • Error summaries at the top of long forms materially improve completion rates. It is one of the highest-return accessibility patterns there is.