States
Empty, loading, and error states — the screens a component set never shows you.
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.
First run, filtered to nothing, nothing to report
No pipelines yet
A pipeline runs your policy checks before a deployment is promoted.
No pipelines match
48 pipelines exist. None are both strict and failing.
No open findings
The last audit completed 4 minutes ago and found nothing.
Healthy
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.
Never show an empty state you are not sure about
“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.
Skeleton — real dimensions, real row count
Spinner — indeterminate work, with its label
Applying changes…
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-progress bar 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.
404 and 500 — same shell, different obligation
Error 404
That page does not exist
The link may be out of date, or the pipeline may have been deleted.
Error 500
Something failed on our side
The request did not complete. Nothing was deployed. Reference 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--error and a message below
it — never a tooltip, never colour on
the border alone. - A panel fails with a
pj-alert--danger inside 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 --err dot and the word “Failed”.
Panel-scoped failure — the page keeps working
Agent activity
Could not load activity
The activity service did not respond. Everything else on this page is current.
Do
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.
Don't
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.