Notes

Rebuilding this site around one background

3 min read#next.js#performance#design

The previous version of this site had twenty-one dependencies, three webfont families, two full copies of the home page layout in the DOM, and a particle canvas that was torn down and re-seeded on every navigation. It looked fine. It did not feel fine.

This is what changed, and why.

One background, mounted once

The flow field was the best thing about the old site and the most expensive. It lived inside the page component, which meant React unmounted it on every route change and a fresh canvas started from an empty buffer — so the trails you had just been watching vanished, and the whole field re-seeded.

It lives in the root layout now:

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Backdrop />
        {children}
        <Mascot />
        <Dock />
      </body>
    </html>
  )
}

The layout survives client-side navigation, so the field is seeded exactly once per session. Nothing about the background changes when you move between pages — which is precisely why moving between pages now feels like moving inside one site instead of loading four.

A few other things came out of that rewrite:

  • It does not start until the browser is idle after first paint, so it never competes with LCP.
  • Device pixel ratio is capped at 1.25. It is an out-of-focus haze; a 3x retina buffer is 5.7x the fill cost for nothing you can see.
  • Trails are erased with destination-out rather than repainted with the page colour, so the canvas stays transparent and the CSS glow underneath is not slowly painted over.
  • It parks itself after a few seconds of nobody touching anything, and wakes on pointer, scroll or resize.

Page transitions are a CSS file

App Router has a template.tsx that, unlike layout.tsx, remounts on every navigation. That is the entire transition:

export default function Template({ children }) {
  return <div className="page-in">{children}</div>
}
@keyframes page-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

No animation library, no route-change listener, no JavaScript. It is enter-only on purpose: an exit animation has to block the navigation in order to play, which makes a site feel slower even as it looks smoother.

The contribution graph ships no JavaScript

The old calendar used a client-side library. That means a bundle, a request on every single visit, a spinner, and a layout shift when the data lands.

The replacement fetches on the server and lets Next cache it for an hour:

const res = await fetch(`${CALENDAR_API}/${login}?y=last`, {
  next: { revalidate: 3600 },
})

The grid is then 371 <i> elements with two custom properties each, coloured by color-mix and staggered by week:

.calendar > i {
  background: color-mix(
    in srgb,
    var(--color-accent) calc(var(--l) * 24%),
    var(--color-surface-2)
  );
  animation: tile-in 420ms var(--ease-out) both;
  animation-delay: calc(var(--w) * 8ms);
}

It is in the HTML of the first paint. There is nothing to hydrate.

Deleting the design system that wasn't one

The real problem was never any single component — it was that there were eleven ways to draw a card. The fix was a @theme block: four surfaces, four inks, one accent pair, one type scale, two easings. Every border on the site is --color-rule. Every card is .panel. Every technology icon resolves through one registry, so "Go" is the same glyph on the stack card, the timeline and a project tile.

That is not a visual change so much as a structural one, and it is the reason the whole thing now fits in about a third of the source it used to need.