Error Pages
Three immersive, animated error screens — 404, 500, and 503 — built on one shared ErrorLayout scaffold with a shimmering error code, themed watermarks, and a searchable popular-pages panel.
Three immersive, animated error screens — 404 Not Found, 500 Internal Server Error, and
503 Under Maintenance — built on one shared ErrorLayout scaffold: a giant shimmering error code
over an animated themed watermark, copy + CTAs, and a searchable "popular pages" panel. Full-bleed
routes at /error/404, /error/500, and /error/503.
Overview
Each error page is a thin wrapper around ErrorLayout, which renders the whole immersive
scene: a HeroCanvas glow backdrop plus a masked dot-grid texture, then a two-column split — on the
left an oversized error code in JetBrains Mono with a slow gradient shimmer sweep (over a per-page
animated watermark), the title/description, a gradient Back to home + outline Go back button
pair, and a "Need help? → Contact support" line linking to /apps/chat; on the right a search
panel that live-filters a list of popular destinations. The columns stack on mobile.
What differs per page is the tone, the decoration, and (for 503) an extra block:
primary tone; a rotating dashed orbit ring with a moon dot, a faint inner ring, and seven
twinkling stars scattered around the number.
Every color comes from the semantic tokens (tone gradients, bg-surface/70 panels, token-var dot
grid), all copy comes from the errors i18n namespace (destination labels reuse nav), and
every animation is useReducedMotion-gated — under reduced motion the shimmer, orbit, scanline,
gears, and stars render as static frames.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/errors/ErrorLayout.tsx | The shared full-bleed scaffold: HeroCanvas glow + masked dot grid, the shimmering code (ShimmerNumber), title/description/CTA stack (Stagger), the support link, the TONE map (canvas colors / shimmer gradient / link color per tone), and the SidePanel (filterable search over the POPULAR destination list). |
src/pages/errors/Error404Page.tsx | The 404 page: primary tone + Deco404 (rotating dashed orbit ring with a moon dot, faint inner ring, seven twinkling stars). |
src/pages/errors/Error500Page.tsx | The 500 page: danger tone + Deco500 (pulsing danger glow, vertical glitch scanline, five warning sparks). |
src/pages/errors/Error503Page.tsx | The 503 page: warning tone + Deco503 (two counter-rotating gears) + MaintenanceCard (progress, ETA, notify-me form) passed via the extra slot. |
src/routes.tsx | The three route entries — /error/404, /error/500, /error/503, all fullBleed: true. |
src/data/menu.ts | The sidebar entries: Pages → Error Pages group (error404 / error500 / error503). |
src/locales/en/errors.json | All error-page copy: titles/descriptions per code, CTA labels, side-panel strings, and the 503 maintenance-card strings. |
Tones
ErrorLayout accepts an ErrorTone ('primary' | 'danger' | 'warning' | 'info'). The TONE map
resolves each tone to its HeroCanvas color pair, the shimmer gradient (with a repeated first stop
so the loop is seamless), and the support-link color:
const TONE: Record<ErrorTone, {canvas: CanvasTone[]; gradient: string; link: string}> = {
primary: {canvas: ['primary', 'info'], gradient: 'from-primary via-info to-primary', link: 'text-primary'},
danger: {canvas: ['danger', 'warning'], gradient: 'from-danger via-warning to-danger', link: 'text-danger'},
warning: {canvas: ['warning', 'primary'], gradient: 'from-warning via-primary to-warning', link: 'text-warning'},
info: {canvas: ['info', 'primary'], gradient: 'from-info via-primary to-info', link: 'text-info'},
}The gradients use only semantic token utilities, so every skin and both themes recolor the
pages automatically. The info tone ships unused — it's ready for a fourth page (e.g. 403).
The shimmer numeral
ShimmerNumber renders the code with bg-clip-text over a 200%-wide tone gradient and animates
backgroundPositionX from 0% to -200% on a 6-second linear loop. The face is the .font-numeral
utility (src/styles/index.css) — JetBrains Mono, deliberately fixed and skin-independent, unlike
.font-display/.font-data which follow the active skin.
Usage
The pages are already wired — navigate to /error/404, /error/500, or /error/503
(Pages → Error Pages in the sidebar). They register as full-bleed routes in src/routes.tsx:
{path: '/error/404', element: <Error404Page />, fullBleed: true},
{path: '/error/500', element: <Error500Page />, fullBleed: true},
{path: '/error/503', element: <Error503Page />, fullBleed: true},Being full-bleed, they render no PageHeader — ErrorLayout calls useDocumentTitle(docTitle)
directly (so the tab reads "404 · <brand>") and sizes itself with
min-h-[calc(100dvh-var(--app-header-height))]. They render inside the app shell (header, sidebar,
footer stay per the layout config), which suits the demo/template context.
Use 404 as your real router catch-all
Out of the box the catch-all in src/App.tsx renders a titled Placeholder:
<Route path="*" element={<Placeholder titleKey="pages:pageNotFound" />} />To make the immersive 404 your real not-found screen, swap that element:
import {Error404Page} from '@/pages/errors/Error404Page'
<Route path="*" element={<Error404Page />} />The catch-all sits inside the <AppLayout /> route, so unknown URLs get the full animated 404 with
the app chrome around it. (Note the catch-all path isn't an appRoutes entry, so it won't get the
fullBleed main-padding removal that /error/404 does — the page still renders correctly, just
inset by the standard main padding.)
API / Props
ErrorLayoutProps (ErrorLayout.tsx)
| Prop | Type | Description |
|---|---|---|
docTitle | string | Passed to useDocumentTitle — the browser-tab title (e.g. "404"). |
tone | 'primary' | 'danger' | 'warning' | 'info' | Picks the HeroCanvas colors, shimmer gradient, and support-link color from the TONE map. |
code | string | The oversized error code rendered by ShimmerNumber. |
title | string | The headline (pass an already-translated string). |
description | string | The supporting paragraph (already translated). |
decoration? | ReactNode | Animated themed watermark rendered centred behind the number (absolutely positioned, aria-hidden). |
extra? | ReactNode | Optional block between the action buttons and the support line — the 503 page passes MaintenanceCard here. |
The side panel
SidePanel is internal to ErrorLayout (not a prop). It filters the module-level POPULAR array —
{to, icon, navKey} entries whose labels resolve from the nav namespace — against a search
Input, and navigates on click. Shipped destinations: Dashboard (/), Calendar, Scrumboard,
Invoice, FAQ, and Site Settings. An empty result renders errors:noResults.
Per-page decorations
Deco404, Deco500, and Deco503 each take a single reduce: boolean (the page's
useReducedMotion() result) and skip their infinite loops when it's true. MaintenanceCard also
takes reduce — under reduced motion the progress bar renders at 68% without the fill animation.
Configuration & customization
Edit the popular-pages list
Change the POPULAR array at the top of src/pages/errors/ErrorLayout.tsx. Each entry needs a
route, a lucide icon, and a nav namespace key — so a destination you add to the sidebar can be
listed with zero new copy:
const POPULAR = [
{to: '/', icon: LayoutDashboard, navKey: 'dashboard'},
{to: '/apps/calendar', icon: Calendar, navKey: 'calendar'},
// add your own:
{to: '/apps/chat', icon: MessageSquare, navKey: 'chat'},
]Change the copy
Titles, descriptions, button labels, the maintenance-card strings (including the ~30 minutes
ETA value), and the notify-me toast all live in src/locales/<lng>/errors.json
(title404/desc404, title500/desc500, title503/desc503, backHome, goBack, eta,
etaValue, notifyTitle, notifiedToast, …). Edit copy there — never in the components.
Add another error page (e.g. 403)
Create a page that renders ErrorLayout with a code, a tone (the unused info tone is ready), and
optionally a decoration; then add an appRoutes entry (fullBleed: true), a menu leaf under the
Error Pages group, and the nav:/errors: keys:
export function Error403Page() {
const {t} = useTranslation('errors')
return <ErrorLayout docTitle="403" tone="info" code="403" title={t('title403')} description={t('desc403')} />
}Tune the animation
The entrance choreography uses the shared motion tokens (EASE_OUT from src/lib/motion.ts,
Stagger/StaggerItem for the copy cascade). The ambient loop timings — the 6s shimmer sweep,
the 40s orbit rotation, the 22s/15s gear rotations, star/spark twinkle cycles — are local constants
in ShimmerNumber and the Deco* components; tune them there. Whatever you change, keep every loop
behind the reduce guard.
The 503 status card is presentational
The 68% progress, the ETA, and the notify-me capture are demo content — the form validates nothing
beyond a non-empty value and fires a success toast. Wire the submit handler to your API
(src/lib/api.ts) to make it real.
Examples
Render an error state inline (outside the routes)
ErrorLayout is a plain component — you can render it from an error boundary or a data-error
branch:
import {useTranslation} from '@/platform/i18n'
import {ErrorLayout} from '@/pages/errors/ErrorLayout'
function ReportCrashed() {
const {t} = useTranslation('errors')
return <ErrorLayout docTitle="500" tone="danger" code="500" title={t('title500')} description={t('desc500')} />
}Swap the app's catch-all to the animated 404
// src/App.tsx
<Route path="*" element={<Error404Page />} />Best practices
- Tokens only. The tone system, dot grid (
var(--border)dots), andbg-surface/70glass panels are all token-driven — extend via theTONEmap, never with hex or raw palette classes. - Copy through
errors.json. Both shipped languages (en + ja) carry the namespace; adding a string means adding the key to the locale files, not inlining text. - Gate every loop on
reduce. Infiniteanimateloops must render a static frame under reduced motion — follow the existinganimate={reduce ? undefined : …}pattern. - Keep decorations
aria-hiddenand pointer-transparent. The watermark layer is decorative;ErrorLayoutalready wrapsdecorationin anaria-hiddenabsolute container. - Reuse
navkeys in the side panel so popular-page labels stay in sync with the sidebar in every language.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| The number renders in the UI font, not monospace | The numeral uses the .font-numeral utility from src/styles/index.css — keep that class (it's deliberately skin-independent JetBrains Mono). |
| The shimmer doesn't loop seamlessly | The gradient repeats its first stop (from-X via-Y to-X) so -200% lands on an identical frame. Keep that shape if you add tones. |
| Animations don't play | OS "reduce motion" is on — every loop is useReducedMotion-gated by design. This is correct behavior, not a bug. |
| The page shows the app sidebar/header | Expected: the routes render inside AppLayout. The pages are full-bleed but still shell-wrapped — that's the demo presentation. |
| Side-panel search finds nothing for a page you know exists | The panel filters only the curated POPULAR list (against translated nav labels), not all routes. Add entries to POPULAR, or use the ⌘K palette. |
| Notify-me does nothing visible | It's presentational: it requires a non-empty email, then toasts errors:notifiedToast and clears the field. Wire it to your API for real capture. |
FAQ
Are these wired up as the app's real error handling? Partially — the * catch-all currently
renders a simple titled Placeholder; the animated pages are showcase routes. One-line swap in
src/App.tsx makes Error404Page the real not-found screen (see Usage).
Why is the error code monospace when my skin uses a different font? .font-numeral is
intentionally fixed (JetBrains Mono) for the techy oversized-numeral look, independent of the
skin's --font-display/--font-mono.
Do the pages work in dark mode and every skin? Yes — the tone gradients, canvas glows, dot grid, and glass panels are all semantic-token based, so they follow theme and skin automatically.
Where does "Contact support" go? /apps/chat (the full Chat app). Point it at your own support
channel by editing the navigate('/apps/chat') call in ErrorLayout.
Is the 503 progress real? No — 68% and the ETA are demo values from the component and
errors.json. Feed them from your status endpoint to make the page live.
Notes for designers & content editors
- All visible copy is in
src/locales/<lng>/errors.json; the popular-page labels come fromnav.json. Edit JSON, never the.tsx. - Colors are tone-token driven — a skin switch recolors the glow, shimmer, watermark, and link together. Don't introduce hex values.
- The decorations are pure CSS/SVG-free markup (borders, blurs, lucide gear icons, dot spans) — no image assets to swap.
- Reduced motion shows a fully static composition; check both states when adjusting the scene.
- The maintenance ETA (
errors:etaValue, "~30 minutes") is copy, not a computed value — keep it plausible or wire it to real data.
Related
FAQ Page
The sibling help-center page, linked from the 404 side panel.
Layout & Shell
Full-bleed routes, header-height sizing, and useDocumentTitle.
Animation & Effects
HeroCanvas, Stagger, and the reduced-motion policy.
Design Tokens & Dark Mode
The semantic tones the TONE map builds on.
Chat
The support destination behind Contact support.
Was this page helpful?
