Resolved: light

Banners, alerts & status

The feedback surfaces — the components that tell the user what happened, what’s missing, what’s loading, and where they are in a flow. Four primitives: Alert (an inline banner with semantic tone + sensible ARIA defaults), EmptyState / ErrorState (the two faces of a region with no content), Skeleton / SkeletonText (loading placeholders) and ProgressSteps (a stepper for multi-stage flows). Every one is theme-aware and pulls from the shared semantic palette (good / warn / bad / accent) — flip the theme in the top bar to confirm each tone re-skins. None of them lean on color alone: each carries a word, an icon or a check so the meaning survives for color-blind users and assistive tech.

<Alert>
Inline banner for a single message tied to an action or page state. Four tones (info / success / warning / error). Sets role + aria-live by tone automatically.
<EmptyState>
The calm “nothing here yet” state for a region with no data — icon, title, description and an optional action. NOT an error look.
<ErrorState>
The failure counterpart to EmptyState — same centered layout, a built-in danger glyph, and an optional retry control to recover.
<Skeleton> / <SkeletonText>
Pulsing placeholders shown while content loads. Skeleton is one sized block; SkeletonText stacks N text lines (last shortened).
<ProgressSteps>
A numbered stepper for a multi-stage flow. Each step is completed / current / pending; renders as an ordered list with aria-current on the active step.

Alert — tones (title + body)

The four tone keys, each with a bold title line and a body sentence. tone is the only required-ish prop (defaults to info); it sets the fill, border tint and text color together. Reach for the semantic tones only when the message truly carries that state — success after a save, warning for a recoverable problem, error for a failure. info is the neutral, brand-toned default.

Heads up

Your NVDA form was synced from the response sheet 4 minutes ago.

tone="info"

Saved

Your volunteer profile has been updated and is visible to your team leads.

tone="success"
tone="warning"
tone="error"
tone="info"
bg-accent-soft / border / text-accent — role="status", aria-live="polite" — non-urgent, announced politely.
tone="success"
bg-[--color-good-bg] / border good@35% / text-good — role="status", aria-live="polite".
tone="warning"
bg-[--color-warn-bg] / border warn@35% / text-warn — role="alert", aria-live="assertive" — interrupts; treated as urgent.
tone="error"
bg-[--color-bad-bg] / border bad@35% / text-bad — role="alert", aria-live="assertive".

Alert — body-only & the ARIA contract

The title is just markup you pass as children — an Alert is happy with a single line of body text. What’s NOT optional is the live-region behaviour baked into the component: error and warning render as role=alert / aria-live=assertive (announced immediately), while info and success are role=status / aria-live=polite. Because a live region must already exist in the DOM before the message is inserted to be reliably announced, mount the Alert in a persistent wrapper rather than conditionally on the message — or override role / aria-live at the call site.

Heads up — the roster sync runs every 10 minutes, so freshly added volunteers can take a moment to appear.
info — body only (no title), the simplest banner
error — body only, announced assertively (role=alert)
tone (default info)
keyof tones — info | success | warning | error. Drives fill, border tint and text color from the semantic palette.
role (auto)
Computed from tone: error/warning → "alert", info/success → "status". Pass role={undefined} for a purely decorative banner.
aria-live (auto)
Paired with role: "alert" → "assertive", "status" → "polite". Override explicitly if you need different urgency.
children
The message. Add your own title/body/icon structure as markup — the component only styles the container.
live-region gotcha
Keep the Alert mounted in a persistent region; inserting it only when a message exists can skip the announcement.
…HTMLAttributes
Spreads onto the <div> — className merges via cn, plus any data-*, id, etc.

EmptyState — icon · title · description · action

The canonical “no data yet” block: a faint centered icon, a title, an optional description, and an optional action. It is deliberately calm and neutral — NOT an error look — for a region that simply has nothing in it yet (an empty team list, no notifications, a fresh search with no matches). The icon is decorative (mark it aria-hidden); the title carries the meaning.

No notifications yet

When a team lead posts an update or invites you to an event, it’ll show up here.

full — icon + title + description + action Button

No volunteers match “husky”

Try a broader search, or clear the filters to see everyone.

no-results variant — different icon + action

You’re all caught up

minimal — title only (icon, description & action omitted)
icon
Optional ReactNode. Centered in an h-8 w-8 box at text-faint. Decorative — pass aria-hidden on the glyph.
title
string (required). Rendered as the primary line (text-sm font-medium, primary text color).
description
Optional string. A muted supporting sentence under the title — say what would fill the space, or how to get there.
action
Optional ReactNode (typically a Button). Spaced below the copy. Give people the one move that resolves the emptiness.
vs ErrorState
Use EmptyState for “nothing here yet”; use ErrorState for “something broke”. They share a layout but read very differently.

ErrorState — title · description · retry

The failure counterpart to EmptyState: the same centered layout, but with a built-in danger glyph (AlertTriangle in text-bad — you don’t pass an icon) and a subtle danger read. title defaults to “Something went wrong”; pass a retry control (usually a Button) to let the user recover. Use it when a fetch or action genuinely failed — not for an empty-but-fine region.

Something went wrong

We couldn’t load your teams. Check your connection and try again.

full — default title + description + retry Button

Couldn’t load representatives

The officials service didn’t respond. This usually clears up on its own.

custom title + description + primary retry

Something went wrong

minimal — default title, no description, no retry
title
Optional string, defaults to "Something went wrong". Primary text line.
description
Optional string. A muted sentence explaining the failure in plain language — and ideally that it may be temporary.
retry
Optional ReactNode (a Button). Shown below the copy. Wire it to re-run the failed fetch/action so people can recover in place.
built-in icon
AlertTriangle in text-bad, centered above the title (aria-hidden). You do NOT pass an icon — it’s fixed, unlike EmptyState.

Skeleton & SkeletonText — loading placeholders

Pulsing grey placeholders that hold a region’s shape while its content loads, so the layout doesn’t jump when data arrives. Skeleton is one block you size yourself with className (h-* / w-* / rounded-*); SkeletonText stacks N equal text lines and shortens the last one to read as prose. Both are aria-hidden — they convey nothing to assistive tech, so pair the loading region with an aria-busy / status message elsewhere. The pulse is a plain animate-pulse.

h-10 w-10 rounded-full (avatar)
h-4 w-40 (a line)
h-24 w-full rounded-lg (a card / image block)
Skeleton — single blocks sized via className
SkeletonText — default 3 lines (last shortened to w-2/3)
SkeletonText lines={5}
composed — a loading card (chip + title line + body)
Skeleton className
Size + shape the block yourself with utilities (h-* / w-* / rounded-*). Base is animate-pulse rounded-md bg-surface-3.
SkeletonText lines
number (default 3). Renders that many h-4 lines, space-y-2; the final line is w-2/3 so it reads like the end of a paragraph.
SkeletonText className
Merged onto the stack wrapper (e.g. constrain width). The lines themselves are fixed-height h-4 blocks.
aria-hidden
Both are aria-hidden — purely visual. Announce the load with aria-busy on the region or a separate status message.
match the real layout
Mirror the eventual content’s shape (avatar circle, title line, body lines) so nothing shifts when data swaps in.

ProgressSteps — completed · current · pending

A numbered stepper for a multi-stage flow, rendered as a semantic ordered list. Each step has one of three statuses — completed | current | pending (the exact ProgressStepStatus union) — and the active step carries aria-current=step. A completed step shows a check and its trailing connector turns accent (the trail behind you is “done”); the current step shows its number in an accent circle; pending steps are muted with neutral connectors. Below is a realistic four-step volunteer onboarding flow, shown both horizontally (the default) and vertically.

  1. Sign up
  2. Verify email
  3. Add address
  4. Join a team
orientation="horizontal" (default) — 4-step onboarding
  1. Sign up
  2. Verify email
  3. Add address
  4. Join a team
orientation="vertical" — same 4 steps, stacked
  1. Done
status="completed"
  1. Doing
status="current"
  1. To do
status="pending"
status anatomy — one step in each status
steps
ProgressStep[] (required). Each: { label: ReactNode; status; ariaLabel? }. Order defines the run and the numbering.
status="completed"
Filled accent circle with a check glyph; the connector that follows it is accent (the trail is “done”). Label in primary text.
status="current"
Filled accent circle showing its number; carries aria-current="step". The trailing connector stays neutral. Label in primary text.
status="pending"
Muted surface-3 circle with a bordered edge, showing its number; neutral connector. Label in muted text.
orientation
"horizontal" (default) lays steps in a row with horizontal connectors; "vertical" stacks them with vertical connectors.
aria-label
Names the whole process on the <ol> (default "Progress"). Set it to the flow’s name, e.g. "Volunteer onboarding".
step ariaLabel
Per-step accessible name. Falls back to `${label}, ${statusSuffix}` when label is a string; pass it when label is a non-string node.
connector logic
The connector after a step is accent only when that step is completed — so the colored trail stops at the current step.

When to use which

These feedback surfaces overlap in spirit but answer different questions. Pick by what the user needs to know right now.

Alert
“Here’s what just happened / what to know about this page.” An inline message attached to an action or page state — transient or standing.
EmptyState
“There’s nothing here yet, and that’s fine.” For a region with no data — and tell people how to fill it.
ErrorState
“We tried and it broke.” For a genuine failure — give a plain reason and a retry. Don’t use it for a merely-empty region.
Skeleton / SkeletonText
“It’s coming.” While data loads, hold the shape so the layout doesn’t jump. Replace with content (or Empty/Error) when the request settles.
ProgressSteps
“You’re here in the flow.” For a known, ordered multi-step process — onboarding, an application, a checkout.
never color alone
Across all of them, meaning rides on words, icons or a check — the hue only reinforces it (for color-blind users and AT).