Error Pages (404 / 500 / 503)
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.
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:
- 404 —
primarytone; a rotating dashed orbit ring with a moon dot, a faint inner ring, and seven twinkling stars scattered around the number. - 500 —
dangertone; a pulsing red glow, a glitch scanline sweeping vertically across the number, and warning-colored sparks. - 503 —
warningtone; two counter-rotating meshing gears behind the number, plus a maintenance status card (live recovery progress bar at 68%, an ETA line, and a presentational "notify me" email capture that fires a success toast).
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. |
| The route entries | /error/404, /error/500, /error/503 — all full-bleed. |
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). All three are registered as full-bleed routes:
src/app/[locale]/(app)/error/
├── 404/page.tsx
├── 500/page.tsx
└── 503/page.tsxBeing 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 not-found screen
Already done. These are not only showcase routes in this edition — the framework's own error conventions render them:
| File | Renders | When |
|---|---|---|
app/[locale]/not-found.tsx | Error404Page | An unknown URL, or any notFound() call. |
app/[locale]/error.tsx | Error500Page | An uncaught render error — it is a client error boundary. |
So an unknown URL gets the full animated 404 with the app chrome around it, and a thrown error gets the
500 screen instead of a blank page, with no wiring on your part. The /error/* routes remain as
browsable previews of all three designs.
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 register it as a full-bleed route, add 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')} />
}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? Yes. not-found.tsx renders Error404Page
for unknown URLs and error.tsx renders Error500Page for uncaught errors. The /error/* routes are
kept so you can preview all three designs deliberately.
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 System
full-bleed routes, --app-header-height sizing, useDocumentTitle
Animation & Effects
HeroCanvas, Stagger/StaggerItem, and the reduced-motion policy
Design Tokens & Dark Mode
the semantic tones the TONE map builds on
Chat
the support destination behind "Contact support"
Architecture & Routing
route registration, per-route presentation flags, and the not-found fallback
Was this page helpful?
