Skip to content

Component

alert

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and alert.css. No scripts.

Source

Copy both files, or run npx nojsui add alert.

Source for alert
<!-- The role is the consumer's choice and it is the thing people get wrong:
     role="alert" is assertive and interrupts, role="status" is polite and waits
     for a pause. Errors that block the user get "alert"; confirmations get
     "status". See the README.

     The icon is aria-hidden — it is decoration, and the text carries the
     meaning. Nothing here depends on colour alone: each tone has its own
     shape. -->
<div class="sk-alert__stack">
  <label class="sk-alert__label">
    Email address
    <input class="sk-alert__input" type="email" name="email" value="not-an-email" />
  </label>

  <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>

  <p class="sk-alert" data-tone="warning" role="status">
    <span class="sk-alert__icon" aria-hidden="true"></span>
    This form is not encrypted end to end. Do not send credentials.
  </p>

  <p class="sk-alert" role="status">
    <span class="sk-alert__icon" aria-hidden="true"></span>
    We usually reply within two working days.
  </p>

  <!-- The panel: the block that replaces a form after a successful submit. -->
  <div class="sk-alert" data-tone="success" data-variant="panel" role="status">
    <span class="sk-alert__icon" aria-hidden="true"></span>
    <h3 class="sk-alert__title">Thanks for reaching out</h3>
    <p class="sk-alert__text">
      Your message is with us. Nothing else to do — we will reply to the address
      you gave.
    </p>
  </div>
</div>
/* .sk-alert — a message that stays until it is removed.

   The counterpart to .sk-toast, and the difference is the whole point: toast
   auto-dismisses on a CSS timer and cannot be shown twice without a manual
   hide-popover reset, which makes it wrong for form errors — its own README
   says so. This one is in normal flow, has no timer, no popover and no dismiss.

   THE ICON IS A MASK, NOT A BACKGROUND IMAGE. A background-image cannot
   inherit currentColor, so a coloured SVG would be the one part of this
   component that ignores the theme. 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 `%23000` inside the data URIs is
   meaningless — a mask reads alpha, not hue.

   Specs:
   - @starting-style ........ https://drafts.csswg.org/css-transitions-2/#defining-before-change-style
   - :has() ................. https://drafts.csswg.org/selectors-4/#relational
   - mask-image ............. https://drafts.fxtf.org/css-masking-1/#the-mask-image
   - role=alert ............. https://w3c.github.io/aria/#alert */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-alert {
  --_bg: var(--sk-alert-bg, var(--sk-color-surface));
  --_fg: var(--sk-alert-fg, var(--sk-color-text));
  --_accent: var(--sk-alert-accent, var(--sk-color-text-muted));
  --_border-color: var(--sk-alert-border-color, var(--sk-color-border));
  --_radius: var(--sk-alert-radius, var(--sk-radius-md));
  --_padding: var(--sk-alert-padding, var(--sk-space-sm));
  --_icon: var(
    --sk-alert-icon,
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 11v5M12 7.5h.01'/%3E%3C/svg%3E")
  );

  display: grid;
  gap: var(--sk-space-xs);
  padding: var(--_padding);
  border: var(--sk-border-width) solid var(--_border-color);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  font-size: var(--sk-text-sm);
}

/* Only give the icon a column when there is an icon. Without :has() the alert
   is a single column and the icon sits above the text — readable, just not
   aligned, which is the correct thing to lose. */
.sk-alert:has(.sk-alert__icon) {
  grid-template-columns: auto 1fr;
  align-items: start;
}

/* --- tones -------------------------------------------------------------- */

.sk-alert[data-tone="info"] {
  --_bg: var(--sk-alert-bg, var(--sk-color-accent-subtle));
  --_accent: var(--sk-alert-accent, var(--sk-color-accent));
  --_border-color: var(--sk-alert-border-color, var(--sk-color-accent));
}

.sk-alert[data-tone="success"] {
  --_bg: var(--sk-alert-bg, var(--sk-color-success-subtle));
  --_accent: var(--sk-alert-accent, var(--sk-color-success));
  --_border-color: var(--sk-alert-border-color, var(--sk-color-success));
  --_icon: var(
    --sk-alert-icon,
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5l5 5L20 7'/%3E%3C/svg%3E")
  );
}

.sk-alert[data-tone="danger"] {
  --_bg: var(--sk-alert-bg, var(--sk-color-danger-subtle));
  --_accent: var(--sk-alert-accent, var(--sk-color-danger));
  --_border-color: var(--sk-alert-border-color, var(--sk-color-danger));
  --_icon: var(
    --sk-alert-icon,
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7v6M12 16.5h.01'/%3E%3C/svg%3E")
  );
}

.sk-alert[data-tone="warning"] {
  --_bg: var(--sk-alert-bg, var(--sk-color-warning-subtle));
  --_accent: var(--sk-alert-accent, var(--sk-color-warning));
  --_border-color: var(--sk-alert-border-color, var(--sk-color-warning));
  --_icon: var(
    --sk-alert-icon,
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3L1.5 21h21z'/%3E%3Cpath d='M12 9v5M12 17.5h.01'/%3E%3C/svg%3E")
  );
}

/* --- panel -------------------------------------------------------------- */

/* The block that replaces a form after a successful submit, as opposed to the
   one-liner above a field. */
.sk-alert[data-variant="panel"] {
  --_padding: var(--sk-alert-padding, var(--sk-space-xl));

  gap: var(--sk-space-sm);
  justify-items: center;
  text-align: center;
}

.sk-alert[data-variant="panel"]:has(.sk-alert__icon) {
  grid-template-columns: 1fr;
}

/* --- parts -------------------------------------------------------------- */

/* Everything about the icon lives inside @supports, so an engine without masks
   collapses it to nothing rather than painting a coloured square. */
@supports (mask-image: none) {
  .sk-alert__icon {
    inline-size: 1.25rem;
    block-size: 1.25rem;
    background: var(--_accent);
    mask-image: var(--_icon);
    mask-repeat: no-repeat;
    mask-position: center;
    mask-size: contain;
  }

  .sk-alert[data-variant="panel"] .sk-alert__icon {
    inline-size: 2rem;
    block-size: 2rem;
  }
}

.sk-alert__title {
  font-size: var(--sk-text-lg);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-alert__text {
  margin: 0;
  color: var(--_fg);
}

/* --- entrance -----------------------------------------------------------
   @starting-style is the style the element transitions FROM the first time it
   is rendered — an entrance animation on insertion with no script and no class
   toggle.

   No @supports around it, deliberately. The base state is the visible state,
   so an engine that ignores the at-rule renders the alert immediately. There
   is nothing to fall back to because nothing was hidden. */

@media (prefers-reduced-motion: no-preference) {
  .sk-alert {
    transition:
      opacity var(--sk-motion-base) var(--sk-ease-out),
      translate var(--sk-motion-base) var(--sk-ease-out);
  }

  @starting-style {
    .sk-alert {
      opacity: 0;
      translate: 0 calc(var(--sk-space-xs) * -1);
    }
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The stack and the mock field below are the demo's layout — they show the
   inline error where it really goes. Delete them when copying .sk-alert.
--------------------------------------------------------------------------- */

.sk-alert__stack {
  display: grid;
  gap: var(--sk-space-md);
  max-inline-size: 34rem;
}

.sk-alert__label {
  display: grid;
  gap: var(--sk-space-2xs);
  font-size: var(--sk-text-sm);
  color: var(--sk-color-text-muted);
}

.sk-alert__input {
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-sm);
  border: var(--sk-border-width) solid var(--sk-color-border-strong);
  border-radius: var(--sk-radius-md);
  background: var(--sk-color-surface-raised);
  color: var(--sk-color-text);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
@starting-stylenewly11711712917.511712917.5
:has()widely10510512115.410512115.4
Maskswidely1201205315.41205315.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-toneUse it for
(omitted)info — neutral context
successA completed action
dangerA validation error or a failure
warningSomething 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:

RoleAnnouncedUse for
role="alert"Immediately, interrupting whatever is being readErrors that block the user. A failed submit. A validation message they must act on.
role="status"At the next pause, without interruptingSuccess 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).

PropertyDefaultControls
--sk-alert-bg--sk-color-surfaceBackground (each tone re-points it to its *-subtle token)
--sk-alert-fg--sk-color-textBody text colour
--sk-alert-accent--sk-color-text-mutedIcon and edge colour (each tone re-points it)
--sk-alert-border-color--sk-color-borderThe border
--sk-alert-radius--sk-radius-mdCorner radius
--sk-alert-padding--sk-space-smInner padding (--sk-space-xl on panel)
--sk-alert-iconper toneThe 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). Under reduce the alert simply appears.
  • Nothing opts out of forced-colors.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
@starting-stylenewlyThe alert appears immediately instead of fading and rising into place. Nothing is hidden — the base state is the visible one.
:has()widelyThe 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 maskswidelyThe 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.