PVR Tech Studio

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.

9 min read
Updated August 13, 2026

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 — primary tone; a rotating dashed orbit ring with a moon dot, a faint inner ring, and seven twinkling stars scattered around the number.
  • 500 — danger tone; a pulsing red glow, a glitch scanline sweeping vertically across the number, and warning-colored sparks.
  • 503 — warning tone; 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

FileResponsibility
src/pages/errors/ErrorLayout.tsxThe 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.tsxThe 404 page: primary tone + Deco404 (rotating dashed orbit ring with a moon dot, faint inner ring, seven twinkling stars).
src/pages/errors/Error500Page.tsxThe 500 page: danger tone + Deco500 (pulsing danger glow, vertical glitch scanline, five warning sparks).
src/pages/errors/Error503Page.tsxThe 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.tsThe sidebar entries: Pages → Error Pages group (error404 / error500 / error503).
src/locales/en/errors.jsonAll 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.tsx

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 not-found screen

Already done. These are not only showcase routes in this edition — the framework's own error conventions render them:

FileRendersWhen
app/[locale]/not-found.tsxError404PageAn unknown URL, or any notFound() call.
app/[locale]/error.tsxError500PageAn 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)

PropTypeDescription
docTitlestringPassed 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.
codestringThe oversized error code rendered by ShimmerNumber.
titlestringThe headline (pass an already-translated string).
descriptionstringThe supporting paragraph (already translated).
decoration?ReactNodeAnimated themed watermark rendered centred behind the number (absolutely positioned, aria-hidden).
extra?ReactNodeOptional 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

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), and bg-surface/70 glass panels are all token-driven — extend via the TONE map, 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. Infinite animate loops must render a static frame under reduced motion — follow the existing animate={reduce ? undefined : …} pattern.
  • Keep decorations aria-hidden and pointer-transparent. The watermark layer is decorative; ErrorLayout already wraps decoration in an aria-hidden absolute container.
  • Reuse nav keys in the side panel so popular-page labels stay in sync with the sidebar in every language.

Troubleshooting

SymptomCause / fix
The number renders in the UI font, not monospaceThe 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 seamlesslyThe 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 playOS "reduce motion" is on — every loop is useReducedMotion-gated by design. This is correct behavior, not a bug.
The page shows the app sidebar/headerExpected: 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 existsThe 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 visibleIt'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 from nav.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.

Was this page helpful?