Skip to content
serdjukow.eu
All stories

How this site is built

A statically prerendered, bilingual Next.js 16 site with a seeded Canvas simulation in the hero, a hand-written WebGL background and tests that fail on any console warning.

  • Next.js
  • React
  • WebGL
  • Canvas
  • Playwright

Goals

A personal site usually lists skills. I wanted this one to prove them instead: the stories explain real production problems, the architecture diagrams can be broken on purpose, and the hero is a small working model of the kind of system I build. The page itself should be evidence of how I work on the frontend.

That set three constraints from the start:

  • Fast. Every page is prerendered; animation must never cost more than it adds.
  • Accessible. Keyboard operable, readable in both themes, calm with reduced motion, and never dependent on JavaScript to show its content.
  • Bilingual. English and German, with neither one allowed to fall behind.

Architecture

The site runs on Next.js 16 with the App Router, React 19 and TypeScript, styled with Tailwind CSS v4. The runtime dependency list is short on purpose: Next.js, React, MDX, Motion for UI transitions and Vercel Analytics, which works without cookies. There is no UI kit, no canvas engine and no WebGL library; those parts are written by hand.

Everything is prerendered. cacheComponents is enabled, and story pages export ensureStatic = "navigation", so the build fails if anything request-time sneaks into them.

Locales live in the URL. Every page exists under /en and /de. A small proxy (src/proxy.ts, what used to be middleware) redirects unprefixed paths using the Accept-Language header: it honors q-values, matches on the primary subtag (de-AT → de), falls back to English and sets Vary: Accept-Language so shared caches don't reuse the wrong redirect. For two locales, a few dozen lines of my own code were simpler than a dependency.

Translations are typed. The English dictionary defines the Dictionary type, and the German one has to satisfy it. A missing or extra key in either language is a compile error, not something a visitor finds later.

Stories are MDX with static imports. Each story is a registry entry plus one MDX file per locale. The bodies are imported statically rather than with a dynamic import(), because a dynamically imported MDX file that renders a Client Component (like the interactive diagram) broke Next 16.4's dev-time validation. Static imports cost nothing here: everything is rendered at build time anyway. A unit test checks that every registered story has a body in both languages.

The living pipeline

The hero animation is a simulated event-driven system: producers send events through an API into a queue, a pool of six workers processes them, and failures are retried with backoff or end up in a dead-letter queue. You can push traffic into it by moving the pointer or scrolling, and clicking sends a burst of fifty events.

The important decision was to keep the simulation separate from the renderer. pipeline-sim.ts is pure TypeScript with no DOM: a seeded random generator (mulberry32) whose state lives in the simulation state, and a fixed timestep of 1/60 s. The Canvas renderer only reads that state. The loop in the component is the classic accumulator:

const frame = (now: number) => {
  const dt = Math.min(now - last, 100) / 1000;
  last = now;
  acc += dt;
  while (acc >= DT) {
    step(sim, DT, input); // pure, seeded, allocation-free
    acc -= DT;
  }
  drawPipeline(ctx, sim, layout, palette, rs, intro, dt);
  raf = requestAnimationFrame(frame);
};

Because the simulation is deterministic, it is testable like any other code: the unit tests check that the same seed gives the same run, that the worker cap is never exceeded, that every event is accounted for in exactly one state, and that the packet pool stays bounded.

No allocations in the hot path. Packets come from a preallocated pool (400 on desktop, 150 on small screens) with an Int32Array free list, and the queue is a ring buffer of indices. The HUD numbers are updated at most every 250 ms, and only when a value changed.

The traffic is tuned, not random noise. Arrivals are a Poisson process with recurring surges whose peaks exceed the workers' capacity of about 5.5 events per second. So the backlog visibly builds up and drains even when nobody touches anything, and a unit test makes sure it keeps doing so.

It only runs when someone can see it. The loop stops when the canvas leaves the viewport or the tab is hidden. With prefers-reduced-motion, there is no loop at all: the simulation runs a few seconds ahead once and draws a single composed frame. The seed for that frame was chosen so it shows every state, including dead letters. The canvas is decorative (aria-hidden), with a text description for screen readers.

Measured on an Apple M4 in Chrome, simulation step plus drawing takes about 0.7 ms per frame (median, about 1 ms at the 95th percentile).

The ambient background

Behind all content runs a WebGL background: one full-screen triangle and a single fragment shader in WebGL1 / GLSL ES 1.00. It draws a soft aurora from domain-warped fractal noise (fbm), a fine dot lattice every 24 CSS pixels that breathes with slow noise, a vignette and triangular dithering against banding. The hash function avoids sin(), which behaves inconsistently on some mobile GPUs.

Each section has a mood. Sections carry a data-bg attribute, and an IntersectionObserver with a root margin of −50% fires only when a section crosses the middle of the viewport. The colors then ease toward the new palette instead of switching. Story pages get their own hue, derived from the slug.

It is cheap by construction. The canvas renders at half the device resolution (at most 1.5 megapixels), at most 60 frames per second, and pauses when the tab is hidden. On the main thread that is about 0.1 ms per frame, measured on the same M4. With reduced motion it draws one static frame per mood change; without WebGL it falls back to a CSS gradient; a lost WebGL context is handled and restored.

Contrast is guaranteed, not hoped for. The shader only mixes from the page background toward the aurora colors, so each palette color is the darkest (light theme) or brightest (dark theme) the background can ever get. Light peaks stay at a relative luminance of at least 0.89 and dark peaks at most 0.04. A unit test reads the color tokens from the CSS and checks body text, muted text and the accent color against every aurora color of every mood, including every story hue:

for (const theme of themes) {
  for (const id of allMoods) {
    const p = resolveMood(id, theme, seed);
    for (const o of [U.c1, U.c2, U.c3]) {
      for (const text of ["fg", "muted", "accent"]) {
        expect(contrast(tokens[text], color(p, o))).toBeGreaterThanOrEqual(4.5);
      }
    }
  }
}

Interaction details

Command palette. ⌘K (or Ctrl+K, or /) opens a palette for navigation, language and theme. It is a native <dialog> opened with showModal(), so the rest of the page is inert without a focus-trap library. The input is a combobox that points at the active option of a listbox with aria-activedescendant; arrow keys move the selection, focus returns to where it came from on close, and a live region announces confirmations and empty results.

Scroll reveals that never hide content. The server always renders the final, visible content: no opacity: 0 in the HTML, nothing that needs JavaScript to become readable. After mount, an element is hidden for its entrance animation only if it is still below the viewport, so the switch is never visible. The state is written as a data attribute directly to the DOM node, so revealing never re-renders React. With reduced motion, nothing is ever hidden.

Theme without a flash. A small inline script sets the saved or system theme before the first paint. React warns about script tags it renders on the client, which happens on client-rendered pages like the 404 shell. There the tag becomes an inert text/plain block, and a layout effect applies the theme instead.

Quality

  • Unit tests (Vitest) cover the simulation, the background palettes and their contrast, the dictionaries, the story registry, the diagram definitions and the command palette filter. More than a hundred tests run in well under a second.
  • End-to-end tests (Playwright) run every flow on a 1280 px desktop and a 375 px mobile viewport, by default against a production build, and against the dev server when I want to catch dev-only problems like hydration warnings.
  • Any console error or warning fails a test. A shared fixture listens to the browser console and page errors. The only exceptions are two documented false positives; everything else gets fixed at the cause.
  • Types everywhere, including the dictionaries, and ESLint with the Next.js Core Web Vitals rules.
  • Sharing and search. Every story gets its own generated preview image (next/og, set in the site's own fonts), and JSON-LD describes the person, the website and each story as a TechArticle.

Trade-offs and what I'd do differently

  • WebGL has a cost on weak GPUs. Half resolution, a 60 fps cap and a low-power context keep it small, but a full-screen fragment shader is never free. On a very low-end device, a static gradient would be the more honest default, and I would like to detect that case better.
  • No CMS. Stories are MDX files in the repository. That keeps them typed, reviewed and tested together with the code, but every text change is a deploy. For a personal site with a handful of stories that's the right trade; for a team of editors it wouldn't be.
  • The light theme is deliberately subtle. The contrast budget leaves the light aurora very little room: its colors have to stay almost white. I chose readable text over a more dramatic background.
  • Hand-written code is code I own. The proxy, the palette and the canvas renderers could have been libraries. I wrote them because each one is small and specific, but that also means I maintain them.

Result

  • Every page is prerendered in two languages, and missing translations fail the build.
  • The hero simulation takes about 0.7 ms per frame and the background about 0.1 ms on the main thread, both paused when nobody can see them.
  • Readable contrast on the animated background is checked by a test, not by eye.
  • More than a hundred unit tests and a Playwright suite on two viewports, which fails on any console warning.

Built with Next.js 16, Tailwind CSS and CanvasHosted on VercelGitHub (opens in a new tab)Built