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-outrather 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.