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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Certificate expires in 6 days
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.
Placement
Page-level at the top of the content column, section-level directly above the thing it describes, and inline for a specific control.
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.
3 fields need attention
- Work email — must include an @
- Password — at least 12 characters
- Terms — must be accepted
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
Domain verified
Certificate expires in 6 days
Payment declined
New: deployment previews
This project is archived
Quiet
Read-only project
Failed
Dismissible
Payment declined
Certificate expires in 6 days
Scheduled maintenance
Every part, every measurement, and the reason it is that number.
Payment declined
Icon, title, body, up to two actions, and an optional dismiss. Everything shares a single left edge below the icon gutter.
- Icon17px, tone-coloured
The redundant encoding for the tone. It is the reason the message still works in greyscale and in high-contrast mode.
- Icon gutter12px
Title, body and actions all align to the same left edge past the icon, so the text block reads as one column.
- Fill14% alpha tint
A tint, not a solid. A solid status colour behind body text cannot reach 4.5:1 in both themes.
- 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.
- 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.
- ActionsMax 2, 6px above
Two actions is the ceiling. A third means the user is making a decision, and decisions belong in a dialog.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| padding | All sides | |
| icon gutter | Icon to text column | |
| title to body | Inside the text column |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | — | Title |
| --text-body-sm | — | Body |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Padding | Radius | Label gap | Max width | When to use |
|---|---|---|---|---|---|
| Quiet | 14px | 12px | — | 68ch | Dense pages where a tinted block would be too loud. Border only. |
| Default | 14px | 12px | — | 68ch | Everything. Tinted fill plus a border. |
| With actions | 14px | — | 6px above actions | 68ch | Up to two actions. A third means it should be a dialog. |
| Callout | 12px 16px | 0 8px 8px 0 | — | 68ch | Editorial emphasis inside prose. Left rule instead of a full border. |
Payment declined
Billing page
Card expires next month
Payment declined
New feature available
Payment declined — no dismiss
Scheduled maintenance
Certificate expires in 6 days
New: deployment previews
Payment declined
Your trial ends in 14 days
Verify your email
<div role="alert">Saved successfully</div>Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Reaches the actions and the dismiss button. The alert body itself is not focusable. |
| Enter / Space | Activates the focused action. |
| Esc | Does 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.
| Attribute | Applied to | Notes |
|---|---|---|
| role="alert" | Danger only | Interrupts immediately. Correct for errors, hostile for anything else. |
| role="status" | Info, success, warning | Waits for a natural pause in the screen reader’s output. |
| aria-live | The container | assertive for danger, polite for everything else. Must be in the DOM before the content changes, or nothing is announced. |
| aria-labelledby | The alert | Points at the title, so the alert has a name in the landmarks list. |
| aria-label | The dismiss button | "Dismiss payment warning", not just "Dismiss". A page with three alerts needs three distinct names. |
Example usage
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
<!-- 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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | 'info' | 'success' | 'warning' | 'danger' | 'accent' | 'neutral' | 'info' | Also selects the icon and the ARIA role. |
| title | ReactNode | — | Short and first. Screen-reader users hear the whole alert. |
| children | ReactNode | — | Body copy. Rendered in neutral foreground, not the tone colour. |
| actions | ReactNode | — | Up to two. A third means this should be a dialog. |
| onDismiss | () => void | — | Adds a close button. Omit when the condition is unresolved. |
| icon | ReactNode | — | Overrides the tone icon. Keep it at 17px. |
| quiet | boolean | false | Removes the tinted fill, keeps the border. For dense pages. |
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.