Pattern: error pages
Who uses it: every user, eventually — a stale bookmark,
a link shared before a record moved, a permission that changed since
yesterday, a request that failed server-side. How often:
rare per user, universal per app — every ERP hits all three of 404, 403
and 500 sooner or later. What "done" looks like: the
reader is never at a dead end — one clear sentence for what happened,
and at least one real place to go next. Three status codes, ONE shape:
icon, title, description, actions — .bo-state and
.bo-state--error, already shipped, nothing new.
Not for a section failing to load inside a page you're still
on — a table or panel that can't fetch its own data is
.bo-state--error
used inline, right where the section was, with the rest of the
screen still usable. This pattern is for when the NAVIGATION itself
failed, not one region of a page that otherwise worked.
This page doesn't exist
PO-99999 was not found — check the link, or it may have been moved.
Three codes, one shape
Same structure every time — only the icon, the sentence, and which actions make sense change. A reader who has seen one of these has seen all three.
You don't have access to this page
Cost-centre approvals for CC-9001 aren't in your role. Ask your manager to add it, or open a different cost centre.
Something went wrong on our end
The page failed to load. Nothing here is your fault — try again, or come back shortly.
The description always names the specific thing — "PO-99999", "CC-9001" — never a bare "Not found" or "Access denied". Same bar the field-error and validation- summary patterns already hold: name the datum, not just the category. The action set is the one real difference between codes: 404 and 403 offer navigation, never a retry — the resource genuinely isn't there or genuinely isn't yours, and a retry button that can't possibly help is a false promise. 500 is the one case retry can actually fix, so it's the primary action there instead.
Markup
<div class="bo-state bo-state--error">
<span class="bo-state__icon" aria-hidden="true">🔍</span>
<p class="bo-state__title">This page doesn't exist</p>
<p class="bo-state__description">PO-99999 was not found — check the
link, or it may have been moved.</p>
<div class="bo-state__actions">
<a class="bo-btn" href="/">Go to dashboard</a>
<a class="bo-btn bo-btn--secondary" href="/pos">Search purchase orders</a>
</div>
</div> Anatomy
- Canvas, no persistent chrome — like
login, this page does not
assume the rest of the app is reachable: a 403 in particular may
mean the sidebar's own links would also fail. Centered on
--bo-color-bg-canvas, same as any screen outside normal navigation. - The state block —
.bo-state bo-state--error, unchanged from the fragment-level primitive. Full navigation and inline-swap failure share one component on purpose — see States below. - Icon —
aria-hidden="true"; the title and description carry the accessible signal, never the glyph alone. - Title + description — what happened, in one sentence each, naming the specific record or resource where one exists.
- Actions — one or two buttons, chosen per code (see above); never zero. A dead end is the one failure mode this pattern exists to prevent.
Data contract
| Request | Returns |
|---|---|
| GET /pos/:id (missing) | a real 404, full HTML page — never a 200 with "not found" text; a search engine, a monitoring probe and a browser's own error handling all key off the status line, not the copy |
| GET /pos/:id (no access) | a real 403, same page shape, the permission-specific copy |
| any request, server fault | a real 500; the copy never guesses at a cause the server doesn't actually know |
| an htmx fragment request that 404s/403s/500s | the SAME .bo-state--error markup, sized for the swap target rather than the whole viewport — the shape doesn't change, only its container does. htmx 4 swaps non-2xx responses by default; exclude a status explicitly via htmx.config.noSwap if the swap target should NOT show it |
States
| State | What ships |
|---|---|
| Full navigation (404/403/500 at the URL) | this page's own shape, canvas-centered, real HTTP status |
| Inside a fragment swap | the identical .bo-state--error markup sized to the swap target — a table that fails to load shows this INSIDE itself, the rest of the screen stays usable |
| 500, retryable | Retry is the primary action; a repeat failure shows the same page again rather than a different, more alarming one — consistency over escalation |
| 404 / 403, not retryable | no Retry button at all — offering one for a resource that is genuinely gone or genuinely not yours is a false promise, not politeness |
| Not authenticated, vs. authenticated-but-forbidden | two different things a 403 must never conflate: a signed-out visitor gets redirected to login, never this page; THIS page is only for a signed-in user whose role genuinely lacks the permission |
| No JS | fully functional — every element here is a plain link or the browser's own navigation; nothing on this page depends on JS running at all |
Components used
complexity 1 of 4