States
A component set shows every part working. A product spends a lot of its life in the three states where nothing is working yet: there is no data, the data has not arrived, and the data will not arrive. Those three screens are where a design system is actually tested, so they are specified here rather than left to whoever hits them first.
Every state on this page follows the same shape:
What is true → why → the one thing to do about it.
If a state cannot name the one thing to do, it is an error state with an optimistic title.
Empty states
Three kinds, and they are not interchangeable:
| Kind | When | The action |
|---|---|---|
| First run | The feature works; nothing has been created yet | The accent button that creates the first one |
| Filtered to nothing | There is data; the current filter excludes all of it | Clear the filter — and show what the filter is |
| Nothing to report | Empty is the good outcome — no open findings | No action. Say so plainly |
The first-run empty state is the only one that gets an accent button — it is the only one where creating something is what the user came to do. “Filtered to nothing” gets a ghost button, because the fix is to undo, not to create.
An empty state is body text, not an illustration. The system’s answer to a blank screen is a diagram or nothing, never a mascot or a spot illustration of a person holding a magnifying glass.
“No results” while a request is still in flight is a lie the user acts on. A region is in the loading state until the response arrives, and only then resolves to empty, populated, or error. Empty is a result, not a default.
Loading states
Two mechanisms, chosen by what you know about the shape of what is coming:
| Use | Why | |
|---|---|---|
| Skeleton | You know the layout — a table of rows, a card grid, a KPI row | The layout does not jump when content arrives |
| Spinner | You know nothing — an action in flight, an indeterminate wait | There is no shape to promise |
Prefer the skeleton. A spinner in the middle of a region that is about to become a table tells the user nothing and then reflows the page underneath them.
A skeleton is midnight-2 blocks at the real dimensions of the content,
with the real gaps and the real number of rows where that is known. It carries a
100ms shimmer at --duration-standard, and it does not shimmer at all under
prefers-reduced-motion: reduce — it simply sits there as static blocks.
Timing rules, so the loading state does not become its own flicker:
- Under 200ms — show nothing. A skeleton that appears and vanishes is worse than a brief pause.
- 200ms to 10s — skeleton or spinner.
- Over 10s — a determinate
pj-progressbar with a count (“3 of 12 checks”), or the state moves to a background job with its own row in the list.
Every loading region carries role="status" and an accessible label, or a
screen-reader user gets silence where a sighted user gets motion. Never animate
a skeleton in a way that hides that the operation has stalled — a shimmer that
runs for two minutes says “working” when the truth is “stuck”.
Error pages
Full-page errors — 404 and 500 — are the brand’s worst-case first impression, so they use the same page shell and the same voice as everything else. No apology paragraph, no cartoon, no error code as a headline.
| 404 | 500 | |
|---|---|---|
| Headline | “That page does not exist” | “Something failed on our side” |
| Body | What might have happened, in one sentence | What we know, and whether it is being worked on |
| Action | Back to a real destination + search | Retry, and a route to a status page or support |
| Blame | The link, not the reader | Us, explicitly |
The code (404, 500) appears as an overline above the headline, in the muted
foreground — findable when someone is reporting the problem, never the loudest
thing on the page.
req_8f2c14.A 500 says what did not happen. “Nothing was deployed” is the sentence the reader needs; “an unexpected error occurred” is the sentence they cannot act on. If the outcome is genuinely unknown, say that instead — it is a different fact and it changes what they do next.
Inline errors
Not every failure takes the page. A failure scoped to one region stays in that region, so the rest of the screen remains usable:
- A field fails with
pj-input--errorand a message below it — never a tooltip, never colour on the border alone. - A panel fails with a
pj-alert--dangerinside the panel and a retry control, while the surrounding page keeps working. - A background job fails into a row in the activity feed with a
pj-status --errdot and the word “Failed”.
Say what is true, why, and the one thing to do. Use a skeleton wherever the layout is known. Label every loading region for screen readers. Tell a reader what did not happen when something failed.
Show “no results” before the response arrives, put an accent button on a “filtered to nothing” state, headline an error with its status code, or let a region-scoped failure take the whole page.