The home page has always had the nicest background on the site. In light mode, dark particles rise slowly; in dark mode, a star field drifts left, with the occasional twinkle and shooting star. Every other page got something different: a static dot grid in the side gutters, a scattering of small Lucide icons, and a hard 768px cut-out down the middle where the content sat on plain white or black.
Each part was fine on its own, but together they made the site feel like two designs. This update gives every page the same sky, makes the content column see-through, and keeps the cost close to zero.
Why not just reuse the home page script
The obvious move was to drop bg.js, the home page background script, onto every page. Before doing that I measured what it costs. I drove Chrome with a small Puppeteer script and recorded 6 seconds of idle animation per page: frame rate, main-thread time, paint count, raster time and the area of the composited layers.
The home page approach draws each particle as one entry in a giant box-shadow: 1,000 + 500 + 250 shadows per theme, spread over a 2560px square, on three layers that slide with a CSS transform. It looks great, but it has two problems:
- The layers are huge. The browser has to keep 20–30 megapixels of composited layers around for the home page. The old inner-page backdrop needed 8–12.
- The loop isn’t seamless. The layer slides 2000px and then snaps back to the start. Near the end of each cycle the bottom of the screen runs out of particles, and then everything jumps.
Canvas tiles slid on the compositor
The version I shipped keeps the home page’s look (same dot sizes, colours, directions and speeds) but draws it differently:
- For each of the three layers, draw a small tile of random dots to a
<canvas>once, and turn it into a blob URL. - Use that tile as a repeating
background-imageon a layer that is exactly one tile longer than the viewport in its direction of travel. - Animate the layer by exactly one tile with
transform. When the animation loops, the picture is identical to the first frame, so there is no seam and no jump.
.bd-particles {
width: 100%;
height: calc(100% + var(--th));
background-image: var(--tile-light);
background-size: var(--tw) var(--th);
will-change: transform;
animation: bd-rise var(--rise) linear infinite;
}
/* Dark mode drifts sideways, so it only needs to be wider */
.dark .bd-particles {
width: calc(100% + var(--tw));
height: 100%;
background-image: var(--tile-dark);
animation: bd-drift var(--drift) linear infinite;
}
@keyframes bd-rise {
to {
transform: translate3d(0, calc(-1 * var(--th)), 0);
}
}
Because only transform changes, the browser paints the tiles once and the GPU slides them from then on. In light mode the inner pages now do zero repaints while idle.
A few details mattered more than I expected:
- Odd, different tile sizes. The three tiles are 617×563, 739×677 and 863×811. Since the sizes don’t divide into each other, the repeats of the three layers never line up, and the pattern doesn’t read as a grid.
- Wrap dots at the edges. Each dot is drawn at its position and offset by ±1 tile in both directions. A dot that hangs off the right edge reappears on the left, so the tile joins up with no visible seam.
- Whole-pixel squares for tiny dots. My first attempt drew 1px circles, and they came out gray. Anti-aliasing smears a 1px circle across several pixels at partial opacity. The home page’s
box-shadowdots are effectively crisp single pixels, so the tiles now draw squares under 2px and circles only at 2px. - One axis per theme. Light mode only moves up and dark mode only moves left, so each layer is enlarged in one direction only. That brought the layer area back down to 15–17 megapixels, still below the home page.
Matching the density by measuring it
I copied the home page’s density numbers exactly, and dark mode still looked far too busy. To understand why, I took screenshots of both pages and counted the lit pixels.
Over the full screen the inner pages were about 5× brighter. In the strip where the home page’s stars are actually visible, the two densities matched within about 12%. The difference is the planet: on the home page it covers most of the screen, so you only ever see a band of sky. A full screen of the same star field reads as much denser. Dark mode now uses the same density as light mode, about half of the home page’s numbers, and it finally looks like the same sky.
A see-through content column
The old backdrop masked the content column out completely, which created the hard edges. Now a translucent panel sits behind the text: 80% white in light mode and 72% black in dark mode. Its edges fade out over 66px, so the particles keep moving behind the content, just dimmed, and there’s no visible boundary.
I deliberately didn’t use backdrop-filter: blur(). Blurring a moving background means the browser has to redo the blur on every frame for the whole column. A plain gradient is a static layer and costs nothing per frame. The footer also stopped painting its own background outside the home page, so the sky runs all the way down.
The persist that wasn’t persisting
Between inner pages the backdrop is identical, so it shouldn’t be rebuilt on every navigation. The layout already had this:
<PageBackdrop transition:persist="page-backdrop" />
My test script marked the particle elements, clicked through four pages, and checked whether the marks survived. They didn’t: every navigation rebuilt the backdrop from scratch. Looking at the HTML explained it. Astro only applies transition:persist to framework islands (the Solid search component, for example) and silently drops it on .astro components. The fix is to put the directive on the component’s root element instead:
<div class="backdrop" data-page-backdrop transition:persist="page-backdrop"></div>
That made the elements survive, but the particles still jumped back to the start on every page change. During the swap the persisted element is detached and re-attached, and that restarts its CSS animations. The fix is to set each animation’s start time to the timeline origin, so where the particles are depends only on the current time:
function syncParticles() {
for (const layer of document.querySelectorAll(".bd-particles")) {
for (const animation of layer.getAnimations()) animation.startTime = 0
}
}
document.addEventListener("astro:after-swap", syncParticles)
Now page switches between inner pages reuse the same elements and the same tiles, and the sky carries straight on.
Twinkles and shooting stars
Dark mode also gets the home page’s twinkling stars and meteors. Rather than rebuild them, the backdrop reuses the home page’s TwinklingStars and MeteorShower components. They already pause in hidden tabs and clear their timers on navigation, and I checked that the spawn rate after several page switches is the same as on the home page, with no doubled-up timers.
They now also skip spawning in light mode, where they were always hidden anyway. That saves a little work on the home page too. The randomized Lucide icons are gone; with the particles and shooting stars, they were just noise.
A bonus bug: a Response can only be read once
While testing, the dev server kept throwing Invalid state: ReadableStream is locked. It wasn’t the backdrop. The view counter API returned the same Response object every time KV wasn’t configured:
// Before: one shared Response for every request
const KV_NOT_CONFIGURED = new Response(JSON.stringify({ error: "..." }), { status: 503 })
// After: a fresh one per request
const kvNotConfigured = () => new Response(JSON.stringify({ error: "..." }), { status: 503 })
A response body is a stream, and a stream can only be read once. The first request worked and every request after it crashed. Production has KV configured and never reached this code, which is why it went unnoticed.
The numbers
Measured in Chrome at 1600×1000 over 6 seconds of idle animation per page:
| Inner pages | Before | After |
|---|---|---|
| Light mode repaints | 0–534 | 0 |
| Dark mode paint time | 0 ms | ≤ 66 ms |
| Frame rate | ~178 fps | ~178 fps |
| Composited layer area | 8–12 MP | 15–17 MP |
| Rebuilt on every navigation | Yes | No |
The dark-mode paint time comes from the meteors and twinkles, which glow with a drop-shadow filter. That’s a deliberate trade for matching the home page, and it is still less than the home page itself.
What’s next
The measurements also showed that the home page repaints about 360 times a second. The likely culprit is the animated gradient on my name in the hero, which animates background-position and forces a repaint on every frame. That’s the next thing on the list.