Component
alert
Demo
tokens.css,
base.css and alert.css. No scripts.
Source
Copy both files, or run npx nojsui add alert.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| @starting-style | newly | 117 | 117 | 129 | 17.5 | 117 | 129 | 17.5 |
| :has() | widely | 105 | 105 | 121 | 15.4 | 105 | 121 | 15.4 |
| Masks | widely | 120 | 120 | 53 | 15.4 | 120 | 53 | 15.4 |
Usage
<p class="sk-alert" data-tone="danger" role="alert">
<span class="sk-alert__icon" aria-hidden="true"></span>
Enter an email address we can reply to.
</p>
data-tone | Use it for |
|---|---|
| (omitted) | info — neutral context |
success | A completed action |
danger | A validation error or a failure |
warning | Something that will go wrong if unchanged |
data-variant="panel" is the larger centred block that replaces a form after
a successful submission, as opposed to the one-liner that sits above a field.
Which role, and why it matters
The component does not choose. You do, and the wrong choice is the most common accessibility mistake made with messages like these:
| Role | Announced | Use for |
|---|---|---|
role="alert" | Immediately, interrupting whatever is being read | Errors that block the user. A failed submit. A validation message they must act on. |
role="status" | At the next pause, without interrupting | Success confirmations, saved-state notices, counts, anything informational. |
role="alert" is assertive. Using it for “Saved” interrupts a screen-reader
user mid-sentence to tell them something they did not need immediately.
role="status" on a blocking error can be missed entirely.
Both are live regions, so the element must be in the DOM before the message appears in it, or inserted whole — which is what this component is for. An element that already contained text and then had it changed announces reliably; one created and populated in the same frame sometimes does not.
No timer, no popover, no dismiss button
That is the entire reason this exists next to toast. toast auto-dismisses
on a CSS animation and cannot be shown a second time without a manual
hide-popover reset, which makes it wrong for form errors — its own README
says so. This one renders in normal flow and stays until you remove it.
The tone icon is a mask, not a background image
.sk-alert__icon {
background: var(--_accent);
mask-image: var(--_icon);
}
A background-image cannot inherit currentColor, so a coloured SVG would be
the one part of the component that ignores the theme — it would stay indigo on
a re-themed alert and stay dark in dark mode. A mask has no colour of its own:
it punches the shape out of a background painted with the tone colour, so it
follows every token override for free. The technique generalises to any
decorative icon in a themeable component.
:has(.sk-alert__icon) switches the layout to two columns, so an alert without
an icon is not left with an empty gutter. Supply your own icon by putting it
inside .sk-alert__icon and setting both --sk-alert-icon: none and
background: none on that element — --sk-alert-icon: none alone only drops
the mask-image, and .sk-alert__icon still paints its background in the
tone colour, so the box fills solid behind whatever icon you put there.
Entrance
The entrance is @starting-style plus a transition — the styles the element
animates from when it is first inserted, with no script and no class toggle.
There is no @supports around it and there does not need to be one. The base
state is the visible state, so an engine that ignores @starting-style renders
the alert immediately with no animation. Nothing is hidden by a feature that
might not be there.
Theming
Set any of these anywhere above the component. alert.css only ever reads them, so the nearest declaration wins (ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-alert-bg | --sk-color-surface | Background (each tone re-points it to its *-subtle token) |
--sk-alert-fg | --sk-color-text | Body text colour |
--sk-alert-accent | --sk-color-text-muted | Icon and edge colour (each tone re-points it) |
--sk-alert-border-color | --sk-color-border | The border |
--sk-alert-radius | --sk-radius-md | Corner radius |
--sk-alert-padding | --sk-space-sm | Inner padding (--sk-space-xl on panel) |
--sk-alert-icon | per tone | The icon <image>. none to supply your own |
Keyboard contract
None. The alert holds no interactive control of its own — it is a message. If
you put a link or a button inside it, that control keeps its native behaviour
and the ring base.css draws.
Verified manually in VoiceOver and NVDA: not yet — do this before marking
the component done. What to check: that inserting an alert with role="alert"
interrupts and is read, that one with role="status" is read at the next
pause, and that neither takes focus — moving focus to a message the user did
not ask for is its own accessibility problem.
Accessibility notes
- Nothing about the tone is conveyed by colour alone: the icon shape differs per tone, and the message text says what happened.
- The icon is
aria-hidden="true". It is decoration; the text carries the meaning. - The entrance transition is inside
@media (prefers-reduced-motion: no-preference). Underreducethe alert simply appears. - Nothing opts out of
forced-colors.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
@starting-style | newly | The alert appears immediately instead of fading and rising into place. Nothing is hidden — the base state is the visible one. |
:has() | widely | The icon column is not applied, so an alert with an icon lays out as a single column with the icon above the text. Readable, just not aligned. |
| CSS masks | widely | The default tone icon is not drawn. The icon element collapses to nothing and the message reads normally. |
The message itself needs only normal flow and a border, so the part that matters is the part with no support caveat at all.