Alerts & toasts
Inline alerts announce only what arrives: add
role="alert" (errors) or role="status" when the
alert is inserted after load, and no
role at all when it is already in the HTML the browser parsed.
Toasts are alerts injected into a persistent aria-live region — the
region must exist before the first toast, or nothing is announced.
Without the JS: everything above still works — the role,
the live region and the styling are all markup. initAlerts()
adds dismissal only, so the one thing you lose is the ✕ button;
injecting toasts was always your code.
Not for a field that failed validation — an error about
one input belongs beside that input, as
the form field's own message,
where it is programmatically tied to the control by
aria-describedby; an alert floated above the form makes the
reader hunt for which field it means. Use an alert for what happened to
the request ("Save failed, nothing was changed"), and
the validation summary
when several fields failed at once and the reader needs a route to each.
Inline alerts
The most-copied real variant after plain text: an alert whose body carries the way out — a link or retry, in the text where a screen reader meets it.
Dismissible — the button is the whole API
The page said "dismiss buttons work via initAlerts()" and showed
none. Here is one, live: click the × and this alert is removed from the DOM.
Any .bo-alert__dismiss inside a
.bo-alert removes it — no id, no wiring, no per-alert init. The
behaviour is document-delegated, which is why a toast injected a minute from now is
covered too.
Elevated — for a list of alerts, not a message about this page
The default alert is a flat strip with an accent bar: it belongs to the
thing you are looking at, in the flow of the page.
.bo-alert--elevated swaps that for a raised card, so a column of
them reads as separate objects you scan rather than one long banner.
Combine it with a severity variant or leave it
plain — the card look and the accent colour are independent settings.
The raised surface does replace the severity tint, so on an
elevated card severity reads through the 3px accent bar alone, not the fill —
which is the channel that survives forced-colors anyway.
Not for a single message inside a form or a page section:
there the flat inline alert is correct, because a shadow makes one message
look like it floated in from somewhere else.
And not the same as a toast, though they share the raised
background — a toast sits higher (a larger shadow, because it floats
over the page rather than sitting in it) and .bo-toast adds an
entrance animation, which says this just arrived. An elevated alert is for entries already on the
page at load, so animating them in would announce arrivals that never
happened. Rule of thumb: a toast interrupts, an elevated alert is
scanned. Used together on
the notification pattern.
Toast recipe
Toasts are ephemeral, so the sample is a trigger, not a static block — click it to see the real thing render, dismiss, and go away.
<!-- Region ships in your layout ONCE, before any toast exists,
so injected toasts are announced: -->
<div class="bo-toast-region" role="status" aria-live="polite"></div>
<!-- Server/HTMX/JS injects: -->
<div class="bo-alert bo-alert--success bo-toast">
<div><span class="bo-alert__title">Saved.</span> PO-88213 was approved.</div>
<button class="bo-btn bo-btn--ghost bo-btn--icon bo-alert__dismiss"
aria-label="Dismiss">✕</button>
</div> Dismiss buttons work via initAlerts()
(document delegation — injected toasts covered automatically). A dismissed toast
fades and collapses over --bo-motion-duration-fast before it leaves the
DOM, and the toasts above it close the gap over that same window rather than
snapping — so dismissing the middle of a stack does not teleport the rest. An inline
alert is still removed instantly: it sits in the document flow, where a collapse
would move the page under you. Under prefers-reduced-motion the toast
goes instantly too.
There is no auto-dismiss timer here, by design — the framework
never removes a toast the reader did not dismiss, because it cannot know whether
they have read it. If you add one, keep toasts visible ≥ 5s, pause the timer on
hover/focus, and never auto-dismiss one that carries the only route to an action
(WCAG 2.2.1). Toasts do
not float above an open modal dialog (the dialog owns the top
layer) — render confirmations inside the dialog instead.
A save confirmation on a form with a sticky action bar
Not a floating toast here. Both a
toast and a sticky form action bar anchor
to the viewport's bottom-end corner, and a toast always wins the stacking order —
measured, on /patterns/detail-form at 390×844: the Save button
100% covered. A fixed clearance offset was tried and measured wrong too: pushed
tall enough to clear the widest shipped action bar, it moved the SAME obstruction
onto a real field instead — Tab from Vendor through the form and
Quantity for Standing desk landed 100% behind the shifted region, an
editable input the reader could no longer see. No fixed offset is safe: the toast
is pinned to the viewport, the form scrolls its own content, and any constant
either misses the action bar or migrates the collision onto whatever field native
scroll-into-view happens to land on next.
The composition that measured clean is
in-flow: the result is written into a region that sits in the
form's own document flow, so the page reflows around it rather than a fixed layer
painting over it. Two details carry that. The region ships empty and exposed — not
hidden, because a hidden node is not rendered and so is not in the
accessibility tree at all, which makes filling it and revealing it a change
nothing was in a position to hear. And a save replaces the region's
contents, never the region, so the second save behaves exactly
like the first.
<form>
<!-- Ships EMPTY and EXPOSED, like the toast region above. Not
`hidden`: a hidden node is not rendered, so it is not in the
accessibility tree — filling it while hidden and revealing it
afterwards changes nothing anyone was listening to. -->
<div id="save-result" role="status" aria-live="polite"></div>
<!-- ...your fields... -->
<div class="bo-form-actions">
<button class="bo-btn" type="button">Cancel</button>
<button class="bo-btn" type="submit">Save</button>
</div>
</form>
<script type="module">
// On your real save success (fetch/htmx response), not a click demo.
// Replace the region's CONTENTS; never remove the region itself.
// That is what makes the second save behave like the first.
const box = document.createElement('div');
box.className = 'bo-alert bo-alert--success';
box.innerHTML =
'<div><span class="bo-alert__title">Saved.</span> PO-88213 approved.</div>';
document.getElementById('save-result').replaceChildren(box);
</script> No dismiss button, and that is deliberate: dismissing
removes the node the reader is focused on, which drops focus to the document body
(initAlerts() removes its .bo-alert and hands focus
nowhere). A result that is replaced by the next result does not need one.
What was measured, and only this: on
/patterns/detail-form, real Tab and Shift+Tab traversal between
Vendor and Save purchase order — every field and both
action buttons, each with a positive rendered box and its own centre hit-testing
to itself — at 390 and 1440, light and dark, all three densities, and a 2-row
wrapped action bar. No measured control intersected the message. That is a result
about this composition on this form, not a general property of in-flow content,
and it does not transfer to a floating overlay elsewhere on the page — the
attempt above is what that costs. Whether a screen reader actually speaks the
change is a separate question these checks do not answer; they assert the region
is exposed before it is written to, which is the precondition for it.
Quick reference generated
| Class | Role |
|---|---|
| .bo-alert | block (root) |
| .bo-alert--danger | variant: danger |
| .bo-alert--elevated | variant: elevated |
| .bo-alert--success | variant: success |
| .bo-alert--warning | variant: warning |
| .bo-alert__dismiss | part: dismiss |
| .bo-alert__title | part: title |
| .bo-toast | block (root) |
| .bo-toast-region | block (root) |
State attributes: data-state
API generated from CSS
| Blocks | bo-alertbo-toastbo-toast-region |
|---|---|
| Parts | bo-alert__dismissbo-alert__title |
| Variants | bo-alert--dangerbo-alert--elevatedbo-alert--successbo-alert--warning |
| data-* hooks | data-state="closing" |
| Component tokens | --bo-alert-accent--bo-alert-bg--bo-alert-fg |
| JS required | Optional: initAlerts() — dismiss buttons remove their alert/toast (delegation covers injected toasts); a toast fades and collapses out first, an inline alert goes instantly. |
- Arrival decides the role, not severity — the live-region rule is stated once there; short form: none at load,
role="alert"/role="status"only when inserted after load. - The toast region must exist (with
aria-live) BEFORE the first toast is injected. - Toasts do not float above an open modal dialog (top layer).
Contrast — 16 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| bg-surface | text-primary | 17.74 | 16.15 |
| accent-text | accent-subtle | 7.27 | 8.38 |
| danger-text | danger-subtle | 5.91 | 8.06 |
| warning-text | warning-subtle | 4.84 | 9.21 |
| success-text | success-subtle | 4.79 | 10.17 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| accent | bg-selected | 5.25 | 6.66 |
| accent | accent-subtle | 5.25 | 6.66 |
| accent | bg-muted | 4.97 | 7.72 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-23
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | --bo-density-font-size and a weight token; no raw font-size |
| Colour | 3 / 3 | per-variant local custom props (--bo-alert-accent/bg/fg), zero raw colour; the 3px accent is a BORDER so it survives forced-colors, where backgrounds flatten |
| Spacing | 3 / 3 | the 3px accent bar and the 24rem toast measure both carry their reason (94.6b) |
| Interaction | 3 / 3 | the opener draws the line outright — role, live region and styling are all markup, and initAlerts() adds DISMISSAL ONLY, so the ✕ is the single thing lost without it (94.14) |
| Content | 3 / 3 | opener carries the wrong-choice clause: "Not for a field that failed validation" — a per-input error belongs beside its control via aria-describedby (form field message), the alert is for what happened to the REQUEST, and validation-summary is named for the many-field case. Blind re-scored 2026-08-23 (Polish round 1) and removed from check:wrong-choice's TODO, so the gate now enforces it. |
| Fit | 3 / 3 | inline alerts and toasts; the page names which role each case takes |