Error States
Five levels, from a single field to a whole system. Every one of them has to answer the same three questions: what happened, whose fault it is, and what to do now.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Recent deployments
Could not load deployments
The request timed out after 10 seconds. The rest of this page is unaffected.
One region failed to load. The rest of the page still works.
The five levels
Scope the treatment to the failure. Each of these is the right answer at one level and wrong at every other.
| Field | One input is wrong. Inline, next to the input, on blur. |
| Form | Submission failed. A summary at the top, focused, linking to each field. |
| Section | One region failed to load. The rest of the page still works. |
| Page | The whole route failed. Full-page state with a route out. |
| System | The service is degraded. Persistent banner plus a status link. |
Writing the message
The same failure, written two ways. The difference is not tone — it is whether the user knows what to do next.
The common failures
Four errors that every product ships. Each one gets a specific message and a specific route out.
Page not found
This URL does not match anything. It may have been renamed or deleted.
Something went wrong on our end
We have logged this and the team has been notified.
You do not have access
Ask an administrator to grant you the deploy:read scope.
You are offline
Changes are saved locally and will sync when you reconnect.
Partial failure
One widget failed; the rest of the dashboard is fine. Blanking the whole page for one failed request is the most common over-escalation there is.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
2 fields need attention
Failed to load
Server error
Service degraded
You are offline
Every part, every measurement, and the reason it is that number.
Something went wrong on our end
We have logged this and the team has been notified. Nothing you did caused it.
A page-level error. Icon, plain-language title, an explicit statement of fault, a primary recovery action, a route out, and a copyable reference.
- IconDanger-tinted container
Tinted, not solid. It signals severity without turning the whole screen into an emergency.
- TitlePlain language, no codes
"Something went wrong on our end", not "HTTP 500". The code belongs in the reference line, not the headline.
- Attribution"Nothing you did caused it"
When the fault is ours, saying so measurably reduces frustration and support contact. When it is not, do not imply that it is.
- Primary actionRetry, not "OK"
The action that has the best chance of resolving it. "OK" acknowledges a failure without doing anything about it.
- Escape routeSecondary, always present
If the retry keeps failing, the user needs somewhere to go. An error page with one button that does not work is a trap.
- ReferenceMonospace, copyable
Selectable and with a copy button. A support reference the user has to transcribe from a screenshot is a reference nobody uses.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-danger-subtle | — | Icon container, alert fill |
| --ds-danger-border | — | Alert border |
| --ds-danger-text | — | Icon and inline error text |
| --ds-warning-subtle | — | Degraded rather than failed |
| --ds-fg | — | Error title |
| --ds-fg-muted | — | Explanation and reference |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-y | Vertical space |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Alert corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-h3 | — | Page error title |
| --text-code | — | Reference identifier |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Padding | Radius | Type | Max width | When to use |
|---|---|---|---|---|---|
| Field | 4px 0 | — | 12px | — | Inline, below the input, with an icon and aria-describedby. |
| Form summary | 14px | 12px | — | 68ch | Top of the form, focusable, one link per invalid field. |
| Section | 40px 24px | — | — | 24rem | Inside the failed region only. The rest of the page keeps working. |
| Page | 64px 32px | — | — | 28rem | Full route failure. Primary retry plus a route out plus a reference. |
| System banner | 14px | — | — | full | App shell, above the content, persistent until resolved. |
Could not save your changes
Save failed — your draft is intact
retry: 2, backoff: 300ms → 900ms
then surface the errorTypeError: Cannot read properties of undefined (reading 'map') at DeployList (index-8f21c.js:4:19204) at renderWithHooks (react-dom.js:12:8813)
Invalid input
Access denied
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Error text on a tinted fill uses --ds-danger-text, the pair certified at 4.5:1. The solid danger colour there lands around 3:1 and fails.
- Never signal an error with a red border alone. The border plus an icon plus a message is the minimum.
- A red field border must reach 3:1 against the surface — it is a meaningful boundary under WCAG 1.4.11.
Keyboard
| Tab | Reaches the retry and escape actions. Error text itself is not focusable. |
| Enter | Activates the focused recovery action. |
| Focus on submit failure | Move focus to the error summary, or to the first invalid field. |
Screen readers
- Announce the number of errors, not just that there was one: "3 fields need attention" tells the user how much work is ahead.
- Each field error must be reachable from the summary as a link. Hunting through a twenty-field form for the invalid one is a real barrier.
- Do not announce a failure repeatedly on every keystroke while the user is fixing it. Clear the error on input and re-validate on blur.
Focus & touch
- On submit failure, move focus to the error summary and announce the count. Leaving focus on the submit button means a keyboard user has no idea anything happened.
- Recovery actions must be at least 44px and should be the largest thing on an error screen. On mobile, put Retry above the escape route rather than beside it.
| Attribute | Applied to | Notes |
|---|---|---|
| role="alert" | Error messages | Interrupts. Correct here and almost nowhere else. |
| aria-invalid="true" | The failing input | Announced on focus. Remove it as soon as the value becomes valid. |
| aria-describedby | The input | Points at the error message so it is read with the field. |
| aria-errormessage | The input | Newer and more precise than describedby for errors, though support is still uneven — use both. |
| tabIndex={-1} | The error summary | Lets you move focus to it programmatically after a failed submit. |
| aria-live="assertive" | A system banner | For an outage that appears while the user is working. |
Example usage
1// 1. Scope the boundary to the region, not the app2<ErrorBoundary fallback={<SectionError onRetry={refetch} />}>3 <DeploymentList />4</ErrorBoundary>56// 2. Retry transient failures silently before surfacing anything7const { data, error, refetch } = useQuery({8 queryKey: ['deployments'],9 queryFn: fetchDeployments,10 retry: (attempt, err) => err.status >= 500 && attempt < 2,11 retryDelay: (attempt) => 300 * 3 ** attempt,12})1314// 3. Map the failure to a specific message and a possible action15function describe(err: ApiError) {16 switch (err.status) {17 case 403: return { title: 'You do not have access',18 body: 'Ask an administrator for the deploy:read scope.',19 action: 'request-access' } // NOT retry20 case 404: return { title: 'This deployment no longer exists',21 body: 'It may have been deleted.', action: 'back' }22 case 429: return { title: 'Too many requests',23 body: 'Wait ' + err.retryAfter + 's and try again.', action: 'retry' }24 default: return { title: 'Something went wrong on our end',25 body: 'We have logged this.', action: 'retry' }26 }27}2829// 4. Never clear the form on failure30async function onSubmit(values: Values) {31 try {32 await save(values)33 } catch (err) {34 setError(describe(err)) // values stay exactly as typed35 summaryRef.current?.focus()36 }37}Framework-free HTML
<!-- Field level -->
<label for="email">Work email</label>
<input id="email" type="email" aria-invalid="true"
aria-describedby="email-error" value="ada.example.com" />
<p id="email-error" role="alert" class="ds-field__error">
Enter an email that includes an @ — for example, ada@example.com
</p>
<!-- Form summary: focusable, one link per invalid field -->
<div class="ds-alert ds-alert--danger" role="alert" tabindex="-1" id="summary">
<p class="ds-alert__title">2 fields need attention</p>
<ul>
<li><a href="#email">Work email</a> — must include an @</li>
<li><a href="#password">Password</a> — at least 12 characters</li>
</ul>
</div>
<!-- Page level: reference must be selectable -->
<p class="ds-error__ref">
Error <code>4f21c-8821-9de3</code>
<button type="button" aria-label="Copy error reference">Copy</button>
</p>CSS
/* Field level: border + icon + message. Never colour alone. */
.ds-input[aria-invalid='true'] {
border-color: var(--ds-danger-border);
}
.ds-input[aria-invalid='true']:focus {
border-color: var(--ds-danger);
box-shadow: 0 0 0 3px var(--ds-danger-subtle);
}
.ds-field__error {
display: flex;
gap: 6px;
font-size: 12px;
color: var(--ds-danger-text); /* certified against the tint */
}
/* The summary must be focusable but must not show a ring on click */
.ds-alert[tabindex='-1']:focus { outline: none; }
.ds-alert[tabindex='-1']:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
/* References are selectable by default and monospaced so characters
like 0/O and 1/l are distinguishable when read aloud. */
.ds-error__ref code {
font-family: var(--font-mono);
user-select: all;
}
@media (forced-colors: active) {
.ds-input[aria-invalid='true'] { border: 2px solid; }
}Professional tips
- Write the error copy at the same time as the happy path. Errors written afterwards are always generic, because by then nobody remembers what could go wrong.
- Include a timestamp on page-level errors. Support can correlate it with logs far faster than they can from a description.
- For offline, keep the app usable read-only and queue the writes. "You are offline, changes will sync" is a much better product than a modal that blocks everything.
- Distinguish "failed" from "stale". A chart showing four-minute-old data with a warning is more useful than an empty box.
Performance
- Error boundaries should wrap regions, not the app. One boundary at the root turns every component failure into a white screen.
- Cap retries and use exponential backoff. An aggressive retry loop against a struggling service is how a partial outage becomes a full one.
- Do not re-render the whole page on error. Keep the failed region isolated so the rest of the tree is untouched.
- Log errors with a sampled rate in production. A failing component in a virtualised list can otherwise emit thousands of identical reports per second.
Common mistakes
- Clearing the form on submit failure, turning a five-second retry into ten minutes of retyping.
- Rendering the raw error message from the API, which is written for developers and often leaks internals.
- Offering Retry for a 403 or a 404, neither of which will change on a second attempt.
- One root-level error boundary, so any component failure blanks the entire application.
- Leaving focus on the submit button after a failed submit, so keyboard users are not told anything happened.
Real-world recommendations
- Instrument which errors users actually see, not which ones are thrown. The two lists are very different, and the visible one is where the design effort belongs.
- A support reference that the user can copy reduces average resolution time more than almost any other single change to an error screen.
- For anything destructive that failed halfway, say explicitly what did and did not happen. "3 of 5 deleted" is far more useful than "Partially failed".
- Run a quarterly review of your error copy. It ages badly, and the generic strings written under deadline are still there years later.