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 stateCentered muted text for empty lists and no-results placeholders.
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.
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:
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.
:user-invalid for the native constraints,
input.setCustomValidity(msg) + form.reportValidity() for a
server-side rejection, and a .field-error <small> under the
input for companion text. All three, with the full state vocabulary:
Form field states.
disabled. Use the same companion
<small>, the same .field-error class, and
no aria-invalid: each state in the
state ladder describes the value a field
holds. An empty control whose options failed to load has no user-entered value to mark invalid. The failure belongs to the page, rather than to the user’s input. It announces differently too
(Accessibility), and needs an explicit way to retry. See the repair below.
toast(msg, {type: 'error'}) makes the assertive
announcement; pair it with an active
notify({id, …, is_current}) entry when the
message has to outlive the toast, and clear(id) it when the state ends. That
entry is what <ui-statusbar> displays; a
page with no statusbar mounted gets the toast alone.
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 pulseGreen text with a background flash and a subtle scale pulse. Under prefers-reduced-motion the same flash plays, lengthened to 1 s.
.status-error — Error shakeRed text with a horizontal shake (shake-x keyframes). Under prefers-reduced-motion the shake is dropped entirely and the red stays.
.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.
.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.
.spin — Loading spinnerThe 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.
.empty-state — no special role needed; the absence of list items is conveyed by the surrounding container.aria-describedby, set once and never swapped, while aria-invalid on the input is what says the value is wrong; Form field states § Accessibility explains why aria-errormessage is the wrong attribute when one message element serves several field states. A submit-level message is announced by the error toast, which carries role="alert" and aria-live="assertive" of its own. An availability message needs its own announcement. Its control is disabled, so it cannot receive keyboard focus. Focus is what triggers the aria-describedby announcement. The companion <small>'s own aria-live="polite" provides the announcement. Without it, screen-reader users receive no automatic notice of the message. None of these three cases needs an additional custom live region..status-success — use role="status" for polite announcements that don't interrupt the user..status-error — use role="alert" for urgent announcements that interrupt the screen reader's current speech..reveal-flash — a flash is not an announcement. The revealed element is usually not the one focus is on, and the colour change alone does not inform screen-reader users. Mark it aria-current="true" so a screen-reader user arriving later finds it, and say what was revealed through announce() at the moment it happens (its live region needs the visually-hidden class). Moving focus is another option, but only where nothing else is competing for it..attention-pulse — the focus move provides the announcement. The pulse highlights a focus move that has already happened, so a screen-reader user is told where they are by the control's own accessible name as the focus arrives, and nothing announces the pulse. Do not add announce() on top — the screen reader is already announcing the focused control, and a live region firing at the same moment competes with it. What the control must have is a name and, where it opens something, aria-haspopup: the control must describe its purpose independently of the visual pulse.prefers-reduced-motion: success plays the same flash lengthened to 1 s; error disables the shake entirely and keeps its red; reveal keeps its tint, lengthens to 1.2 s, and drops the scale bounce; the attention pulse keeps its halo, lengthens to 1.2 s, and drops the scale bounce.toast() — transient announcements that float over the page (info / error).<ui-notifications> — persistent list of warnings surfaced from a header trigger.<ui-statusbar> — inline projection of the top active notification with role="status".