busy-office-ui
Menu

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

Heads up. Approvals sync runs at 18:00.
Saved. All changes stored.
Budget at 92%. CC-4021 nears its limit.
Posting failed. Period 2026-07 is closed. Open period settings

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.

Rate card expires in 3 days Renew it before the next billing run.

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.

Import finished 240 rows staged for review.
Approval overdue PO-88213 · 2 days

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

ClassRole
.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)
TextonLightDark
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
↑↓ move Enter open Esc close