Error Page
A full-viewport error page for 404 / 403 / 500 / offline / maintenance, with per-type copy, history-aware recovery actions and a copyable error ID.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/error-page.jsonPrompt
Build a React + TypeScript + Tailwind "ErrorPage" block (lucide-react icons)
plus two tiny hooks it composes: useCopyToClipboard (copied flag + timed
reset + non-throwing error channel) and useOnline (useSyncExternalStore over
the window online/offline events).
Contract
- Props extend React.ComponentPropsWithoutRef<"main"> minus "title";
className merges through cn() and the rest spread on the root.
- kind: "404" | "403" | "500" | "offline" | "maintenance" (default "404")
selects a preset. title / description / hint / statusCode / supportLabel
each override one preset field; statusCode={null} hides the number and
renders the type's glyph instead.
- Recovery props: homeHref (default "/"), onBack?, onRetry?, retrying?
(default false), supportHref?.
- Extras: suggestions?: { label, href, description? }[], onSearch?:
(query: string) => void, errorId?, timestamp? (a PRE-FORMATTED string —
the component never calls new Date(), so it never renders a value the
server and the client disagree about), details?: { label, value }[].
- One PRESETS table holds every preset string: statusCode, icon, title,
description, hint, retryLabel, homeLabel, supportLabel and which action
gets the primary fill. Unknown kinds fall back to the 404 preset.
Behavior
- The copy must genuinely differ per type, not just the digits: 404 says the
URL may be mistyped and points at search; 403 says the account lacks
permission and points at switching account / asking an admin; 500 states
outright that it is not the user's fault and to retry, then quote the
error ID; offline names Wi-Fi / mobile data / airplane mode and notes that
already-loaded pages still work; maintenance says the page recovers on its
own and points at a status page.
- Primary action follows the type: retry is primary for 500 / offline /
maintenance when onRetry exists, otherwise "go home" takes the slot — a
retry-shaped button with nothing behind it is never rendered.
- "Go back" is only rendered when the browser really has an entry to go back
to. Read that through useSyncExternalStore: subscribe to popstate (plus
the Navigation API's currententrychange when present), snapshot
navigation.canGoBack when the Navigation API exists and fall back to
history.length > 1 otherwise, and return false as the server snapshot so
the first paint never shows a button that would do nothing. When there is
no history the button is simply absent and "Go to homepage" takes over the
primary slot — degrade to a working exit, never to a dead button. onBack
replaces the default history.back() for router-driven apps.
- Retry pending: aria-busy + aria-disabled + spinner + "Retrying…", and the
handler returns early while pending. Do NOT use the native disabled
attribute — the browser blurs a disabled element instantly, so a keyboard
user who pressed the button loses their place the moment it goes pending.
- kind="offline" subscribes to the real online/offline events: a pill under
the copy reads "No connection detected" / "Back online" inside a
role="status" region, and once the connection returns the hint line swaps
from "check Wi-Fi" to "your connection is back, retry" — stale advice is
worse than no advice. The retry button stays enabled while offline, on
purpose: navigator.onLine only reports whether a network interface exists,
not whether your server is reachable, so it must not be allowed to veto a
user's retry.
- Technical details are a controlled disclosure, collapsed by default, and
the collapsed body is unmounted (not just height-zero) so it holds no
invisible tab stops. The error ID stays visible on the collapsed header
next to a one-click copy button — support asks for the ID, so it must not
be two clicks deep. Copying shows a visible "Copied" (or "Copy failed" in
the destructive color) plus an aria-live mirror, resetting after 2s.
- The drawer only renders when there is something diagnostic to show
(errorId / timestamp / details). The status code alone does not open it:
a drawer containing one "Status 404" row is noise for an end user.
- Search renders only when onSearch is given: a role="search" form with an
sr-only label, a controlled input and a submit button that is disabled
while the query is blank; submit trims before calling back.
Rendering & styling
- Semantic tokens only: bg-background page, bg-primary/text-primary-
foreground for the single primary action, border + hover:bg-foreground/10
for secondary ones (hover:bg-accent would be a no-op in the light theme,
where accent/muted/secondary are the same value), bg-muted/40 diagnostics
drawer, text-muted-foreground for supporting copy.
- Structure: <main> landmark, min-h-dvh, one centred max-w-md column, and a
single <h1>. The big status number is aria-hidden decoration — the real
information is the h1, and the code is repeated as a "Status" row inside
the diagnostics drawer for anyone who needs the digits.
- Long values: the error ID is break-all inside a min-w-0 flex group that
keeps the copy button beside it (as separate flex items a long ID eats the
row and strands the button on its own line); detail labels are
whitespace-nowrap and values break-words.
- Motion: only the retry spinner and the chevron rotation, both
motion-reduce-gated; focus-visible:ring-2 ring-ring on every control.
Customization levers
- Copy and localisation: every preset string lives in the one PRESETS table
— translate or re-voice all five types there without touching the render
tree. Per-instance overrides go through title / description / hint /
supportLabel.
- Add a type: add a key to PRESETS (e.g. "429" rate-limited, "410" gone) and
it inherits the whole layout, action logic and diagnostics drawer.
- Height: min-h-dvh is the only sizing rule — pass min-h-[560px] (or
min-h-screen) through className and tailwind-merge replaces it, which is
how the preview on this page frames six variants on one screen.
- Visual weight: swap the big number for the type glyph with
statusCode={null}, or drop the number entirely and lead with the h1.
- Which extras appear is purely presence-driven: no onSearch → no search
box; empty suggestions → no link list; no errorId/timestamp/details → no
diagnostics drawer; no onRetry → no retry button.
- Diagnostics payload: push request path, trace id, build sha or region into
details[] — they render as extra rows and travel with the error ID when a
user reports the problem.Concepts
- Per-type copy, not per-type digits — each
kindships its own explanation and its own next step (check the URL / switch account / it's not your fault / check Wi-Fi / it fixes itself). Reusing one sentence across five codes is the failure mode this component exists to prevent. - Graceful back degradation — "Go back" is rendered only when
navigation.canGoBack(orhistory.length > 1) says there is an entry to return to; with no history the button is absent and "Go to homepage" takes the primary slot, so the exit path is never a button that does nothing. - Decorative status code — the oversized number is
aria-hidden; screen readers get the<h1>, and the digits reappear as a "Status" row inside the diagnostics drawer for anyone who needs to quote them. - Live offline status — the offline page subscribes to the browser's real
online/offlineevents; when the connection returns, arole="status"pill announces it and the stale "check your Wi-Fi" advice is replaced by "retry now". - Copyable error ID — the ID stays visible on the collapsed drawer header with a one-click copy button and a visible Copied / Copy failed confirmation, because it is the one thing support will ask for.
- Diagnostics behind a closed door — technical rows are collapsed by default and unmounted while collapsed (no invisible tab stops), and the drawer only exists when there is something worth reporting.
Metric Overview
A dashboard KPI row — one shared range switcher over comparable cards, deltas coloured by each metric's polarity, and a sparkline per card.
Roadmap Board
A public product roadmap in planned / in-progress / shipped lanes, where each entry can be upvoted optimistically — and the count rolls back with a visible error when the server refuses.