Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowLeft,
  Check,
  ChevronRight,
  Copy,
  House,
  Lock,
  RefreshCw,
  Search,
  SearchX,
  ServerCrash,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/error-page.json

Prompt

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 kind ships 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 (or history.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/offline events; when the connection returns, a role="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.

On This Page