Skip to content

Two birds, one slow hero

  • SvelteKit
  • Animation
  • Performance

The Hero on the homepage is two birds, three words, a standfirst, and a button. The words are "Developer. Designer. Diviner?" (because it was my placeholder text during design... and it just kind of stuck) and each letter shows up on its own schedule, a little late and a little crooked, like it got the invite but not the dress code. If you have a mouse you can grab a letter and throw it (for now). Scroll down and the whole headline explodes apart, half the letters flying at your face and half shrinking into the distance, while the rest of the page slides up over the mess like nothing happened.

I wanted most of this from the start, and the rest I iterated on for fun and to avoid writing content for the site. None of it was fast at first. On a throttled phone the biggest thing on the page took six and a half seconds to show up. Today Google's lab tool. It loads a page on a simulated slow phone or a desktop and scores it 0 to 100 on Performance, Accessibility, Best Practices and SEO. gives the homepage a 95 on a phone and a row of 100s on desktop. Here's how, including the parts where I was confidently wrong.

The first version

I built the hero piece by piece as I converted it from my Figma design, and I built it the way I'd built every hero before it. GreenSock Animation Platform, the JavaScript animation library most hero sections on the web are built with. for everything, about 370 KB of it, shipped first thing to every visitor, including the ones who'd asked for reduced motion and were never going to drag anything. In the words of my guy, Antonio, "no good!" A GSAP plugin that breaks a block of text into one element per line, word or letter so each can animate on its own. chopped the headline into letters, Draggable let you fling them, a timeline staggered them in, and the two birds were keyed video clips drifting toward your cursor. It was 825 lines long and looked fantastic on my MacBook Pro connected to Google Fiber, which is the most dangerous sentence in frontend dev. I had no measurements (mostly on purpose, it was my own personal site so I was yoloing). I had a thing that moved, and I liked it.

The video birds went first. On a large monitor they were upscaled 1.3x and looked like a bird through a screen door, so I swapped them for static cut-outs and 8.8 MB of video left the repo (git remembers even though I tried to forget). Then I ran the homepage through a Lighthouse's phone simulation: a 1.6 Mbps connection with 150 ms round trips and a CPU slowed down four times. A mid-range Android on a bad day., which is the browser's way of asking "but what about a phone in a parking garage?" Largest Contentful Paint: how long until the biggest thing in the first screen is on screen. A quarter of the performance score. Good is under 2.5 s. came back at 6455 ms. You could read the whole headline out loud, twice, before it showed up.

Every trace pointed at the left bird (the Blackburnian Warbler). It was a 255 KB file stuck in a queue behind sixty other requests, and 1x screens were downloading the retina version because my The markup that offers an image in several sizes so a phone can pick a small file instead of the desktop one. only listed densities. This is the type of thing AI does left to its own devices, and why I constantly audit its output. AI can do great things to improve site performance, but you really have to motivate it. I even have "performance concise" wording in my Claude files. The fixes were not clever. fetchpriority="high" on the bird, a width-based srcset, a year of caching on fonts and images. Same trace afterward: 811 ms. I'd love to tell you which change bought which chunk of that. I didn't write it down, and past me is still hearing about it.

The headline was waiting on JavaScript

The birds were fast now (although not as fast as a Peregrine Falcon). The headline was not, and this one was self-inflicted. To stop it flashing before the animation kicked in, I'd hidden it until JavaScript was ready. So the three words you came to read waited on The step where the JavaScript takes over a page the server already rendered, so buttons and animations start working. The page is readable before it., then the GSAP download, then the fonts, then an 850 ms pause I'd added because the birds "needed a head start." On a cold phone that was past three seconds. The birds were showing up to the party before the host.

My fix was to stop asking JavaScript to run the entrance at all. The entrance is now data. A small function decides, for every letter, when it starts and how crooked it starts, and Svelte writes those numbers into the HTML on the server:

<span class="headline-char" style="--d: 1550ms; --r: -14deg; --y: 100%">D</span>

One An animation written as a start state and an end state in CSS. The browser plays it on its own, no JavaScript required. reads them. It starts the moment the page paints, needs no bundle and no font, and still plays with JavaScript turned off. The "random" tilt is a hash of the letter's position, because the server and the browser have to agree on every attribute, so every visit plays the exact same pattern. It used to be different on every load, which was cool, but absolutely not worth the JavaScript I had to ship to every user before the site was even usable.

GSAP is still around (for now...). It loads later, in its own chunk, and does one job, the dragging. One trade I took with my eyes open: on a slow cold load the headline can start in the fallback font and swap to Greycliff mid-entrance. I built the version that waited for the font. It pushed the headline out to 4.5 s and put it behind the birds again. For my own personal site, I'm fine with the swap. I'm not selling anything.

Then I made it come apart on scroll

With that first pass behind me, I built the part I'd wanted since the beginning. I started with a "lab route" that layered seven different exits onto the real hero so I could flip between them. Scatter won over stagger, fan, rise, tumble, split and curtain.

The first working version computed every letter's position in JavaScript, every frame. Fifty-five style writes per frame. On my machine it was fine, and I almost shipped it. At 4x CPU throttling only 26% of frames fit in the budget, against 95% without the effect, and The analytics and session-replay service the site uses. Its JavaScript is about 100 KB.'s session recorder was faithfully logging fifty-five style changes sixty times a second, like a court stenographer at a rave.

So I cut the per-frame work down to three numbers. On scroll, JavaScript writes progress, eased progress, and how far the button is holding back (to stay on screen and clickable as long as possible) onto the hero, and that's all it writes. Every letter's direction, distance, spin and size get decided once, when the hero is measured, and baked into static calc() strings the browser does the math on:

translate: calc(var(--e) * 312px) calc(var(--e) * -148px);
rotate: calc(var(--e) * 22deg);
scale: calc(1 + var(--e) * 0.9);

The scale was a late addition, as a depth cue. It felt right immediately, the frame counter said a steady 60 fps, and it was visibly choppy. All three were true at once. A trace showed scaling text was re-drawing every glyph every frame, 5260 The browser's work of turning text and shapes into actual pixels. Scaling text forces it to redo this every frame. per scroll against 1636 without. One line of will-change: transform told the browser to stretch a cached copy instead. The lesson I keep relearning is that the frame counter only sees half the work, and the expensive half is usually the painting it can't see.

Phones sit the whole thing out. A thumb flick covers the entire effect before you'd see any of it.

The bird is the LCP

Real visitors were seeing the birds in about half a second by now, and I figured I was done. Then I ran Google's hosted Lighthouse at pagespeed.web.dev, run from Google's machines instead of yours.. Desktop 94. Mobile 76. Traces under Lighthouse's phone conditions (a 1.6 Mbps connection, CPU slowed 4x) said it was all traffic. In the first second the browser asked for the stylesheet, three fonts, about 65 JavaScript files, and a 181 KB bird, all at once, and the bird lost. So, in order:

  • A newer image format, roughly half the size of WebP for the same picture. WebP stays as the fallback for older browsers. instead of WebP. The file a phone picks went from 181 KB to 62 KB, and a A hint in the page's head that says "fetch this file now, you'll need it," before the browser would otherwise discover it. in the head asks for it before the parser has even found the <img>.
  • No more fade-in. Chrome never counts an image that animates up from opacity 0 as the LCP, so on desktop it had been counting a line of text at 4.8 s. Fade off, LCP 0.67 s. I miss the fade a tad.
  • JavaScript out of the A tiny response Cloudflare sends before the real page, listing files the browser can start fetching early.. The site runs on Where the site runs. The built site deploys there on every push to main., and Cloudflare was sending a tiny early response that told the browser to start fetching all 65 JavaScript files before the page had even arrived. They shared the pipe with the CSS and the bird. Hinting only the stylesheet took mobile LCP from 5.0 s to 3.4 s. The cost is that the headline isn't draggable for an extra beat on a slow phone, which I can live with.
  • PostHog waits for a touch instead of loading "on idle," which fires about 100 ms in while the bird is still downloading. Total Blocking Time: how long the page's JavaScript keeps the browser too busy to respond to a tap. 30% of the performance score. went from 250 ms to about 10 ms.

Mobile LCP is now 2.0 to 2.8 s on every page, down from 5 to 10. PageSpeed Insights reads 99 to 100 on desktop and somewhere in the high 80s on mobile. I say somewhere because the same build scored anywhere from 76 to 90 across runs. Don't get attached to any single one.

Things that sounded good and weren't

Everything here got built or measured and then thrown out. The numbers did the throwing, not my gut.

  • Inlining the entire stylesheet into the HTML. No render-blocking request at all, in theory. In practice, it scored one point worse. Reverted.
  • Marking the JavaScript preloads low priority instead of removing them. Chrome honored it and it changed nothing, because a low-priority preload still starts immediately.
  • A smaller bird for phones. About 0.3 s off mobile LCP, and visibly soft on a 1.75x screen. Declined on quality.

The stone

Decide the animation ahead of time and let the browser play it. The entrance is numbers in the HTML. The scroll exit is numbers in inline styles. The only thing JavaScript does per frame is write three CSS variables, like --e. Other CSS reads them, and the browser recalculates whatever depends on them when they change., and the amount of JavaScript on the critical path is none.

What's left on phones is arithmetic. A simulated 1.6 Mbps connection costs about 2 s to first paint for any page with web fonts, full stop. The rest of the points are the bird slide and the headline stagger keeping the screen moving after paint. I could shorten the choreography and buy a few points. I'd rather keep the choreography.

And trace it. Every number in this post came from a profile, and my gut was wrong often enough that I stopped asking it. The 60 fps counter watched a stuttering exit and told me everything was fine.