PVR Tech Studio
Error pages

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.

9 min read
Updated July 15, 2026

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

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.
src/routes.tsxThe three route entries — /error/404, /error/500, /error/503, all fullBleed: true.
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). 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 PageHeaderErrorLayout 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)

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 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), 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? 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 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?