Status messages

ui.css · APG: Alert

Overview

Inline CSS patterns for communicating state — empty lists, success/error animations, and loading spinners. Use these for feedback that stays in place. For transient toasts, see toast(); for persistent system warnings, see <ui-notifications>. For form error messages, see Form errors below to choose the appropriate location.

.empty-state — Empty state

Centered muted text for empty lists and no-results placeholders.

No items to display.
Show code

With an icon and a subtitle

For more detail, add an icon, a title and a subtitle. No additional attributes are needed: the <ui-icon> is sized and spaced by .empty-state, the first <p> is the title, and a second one is the subtitle.

No members yet

Invite someone to get started.

Show code

Don't set the icon's size or display from a style attribute

<ui-icon> is inline-flex, which is what lets .empty-state's text-align: center centre it. Setting display: block on the host makes it a full-width box with no width to centre, and the glyph inside aligns to the left — the shadow root's <svg> is itself display: block, so neither text-align nor margin-inline: auto can reach it. An inline style attribute also overrides normal stylesheet rules, preventing the app’s stylesheet from changing the size.

To vary an instance, write a rule rather than an attribute. Both stylesheets are linked in order — ui.css then the app's — so an equal-specificity rule in the app's sheet takes precedence without !important or a more specific selector:

/* app stylesheet, linked after ui.css */ :where(app-productions) .empty-state > ui-icon { font-size: var(--font-size-7); opacity: 0.3; }

Form errors — where they go

Choose a form-error message's location according to what failed. Use the field's companion message for an invalid value or an unavailable control, and the notification system for a failed submission. The cases below explain the markup and announcements each needs.

When a message asks the user to retry, make sure the suggested action works. Often, one control loads its options based on another control’s selection. Asking the user to select the same value again will not work if only a change event starts the reload: selecting the current value again does not fire that event. Return that other control to its placeholder as you write the message, and the same choice counts as a change again. An invalid-value message does not need this reset, because the user can already edit the value.

The shared notification store lets messages be announced, remain on screen, and be revisited later. Use these components for submission errors so each page does not need its own status-message implementation.

.status-success — Success pulse

Green text with a background flash and a subtle scale pulse. Under prefers-reduced-motion the same flash plays, lengthened to 1 s.

Show code

.status-error — Error shake

Red text with a horizontal shake (shake-x keyframes). Under prefers-reduced-motion the shake is dropped entirely and the red stays.

Show code

.reveal-flash — Reveal pulse

.reveal-flash highlights an item the user has located, such as a search result or a row associated with a clicked word. It uses an --info tint while preserving the element's text colors. Unlike the success and error animations, it does not report an outcome.

Play it through reveal(el) from lib/animation.mjs, which also brings the element into view with the minimum necessary scrolling. play_once(el, class) is the underlying helper. It restarts the animation and removes the class when it ends, so each call produces a flash.

Under prefers-reduced-motion the tint stays and lengthens to 1.2 s; the scale bounce is removed. Keeping the tint ensures that reduced-motion users can still identify the revealed element after it scrolls into view. reveal() also reads the motion preference when choosing smooth or instant scrolling. It does not rely on the container’s scroll-behavior, whose default is auto. Otherwise, pages would need additional CSS to enable smooth scrolling while respecting reduced-motion preferences.

Show code
Show the call import { reveal } from '/ui/lib/animation.mjs' reveal(document.querySelector('#row-42'))

.attention-pulse — Attention pulse

.attention-pulse draws attention to a control after a pointer gesture moves focus there. Such a focus move may not match :focus-visible, leaving no visible indicator. The pulse uses --brand, matching the usual focus ring.

It animates ::after to avoid conflicts with the control’s own animation. The three animations above run on the element itself, so a host rule can override them by declaring animation — or all — at equal or greater specificity, without an error. The class is applied but no animation plays; an animationstart listener can detect the failure. This occurs on a status dot whose host stylesheet, loaded after the library’s, resets the control with all: unset and gives it a state-dependent animation. Either declaration on its own overrides the class’s animation. Animating the pseudo-element avoids these conflicts and leaves the control’s background unchanged. This matters when the background communicates state, as on a status dot, swatch or colour-coded chip: .reveal-flash would obscure that information with its tint.

Play it through play_once(el, 'attention-pulse', {pseudo: '::after'}) from lib/animation.mjs. The pseudo argument is required: getComputedStyle(el) reports no animation for one running on a pseudo-element, so without it the helper calculates a zero-duration animation and removes the class after about 100 ms. There is no reveal()-style wrapper — the element is the one the user just clicked, so it is already on screen and there is nothing to scroll.

Under prefers-reduced-motion the ring stays and lengthens to 1.2 s; the outward growth is removed, preserving the visual cue without motion. At scale 1 the keyframes are a ring fading out in place.

Two custom properties on the host adjust the ring. --attention-pulse-color replaces --brand when the ring should carry the host's own state colour. --attention-pulse-delay starts the ring later, to time it to another animation on the same control. The ring is transparent outside its keyframes, so nothing shows while it waits, and await_animation() counts the delay in its fallback timer, so play_once() keeps the class until a delayed ring has finished. <ui-notifications> uses both: its ring takes the bell's severity colour and starts at the first swing of the bell.

The host has to be a containing block for that ring, so :where() sets this at zero specificity for statically positioned hosts. Any explicit position declaration takes precedence, preserving the control’s existing positioning.

That includes an explicit position: static, which takes the containing block away. A host rule that puts a positioned control back in the flow — placing the same control differently in two contexts, for instance — outranks the :where() above, and the ring then insets itself to the nearest positioned ancestor and is drawn around that element instead. Nothing reports an error: the class lands, the animation plays, and the ring is simply the wrong size. Write position: relative; inset: auto in place of static. The control lays out in the flow exactly as before, inset: auto drops any offsets the positioned rule set, and the ring stays on the control. To check which box a ring took, read getComputedStyle(el, '::after').width while it is playing and compare it with the control’s own.

Show code
Show the call import { play_once } from '/ui/lib/animation.mjs' const dot = row.querySelector('.status-dot') dot.focus() play_once(dot, 'attention-pulse', { pseudo: '::after' })

.spin — Loading spinner

The loading spinner has its own page: Loading & spinners — the shared inline .spin icon, the view/dialog busy overlay, and which loading indicator to use when.

Accessibility

Related feedback