On this page
There’s a butterfly on my homepage. It’s made of light, it wanders through the sky behind my name without ever crossing a word, and if you click it, it bursts into dust and gathers itself back together somewhere else.
It’s about 7 KB of TypeScript and a plain 2D canvas, with no libraries and no WebGL. This is how it works, and the handful of decisions that made it feel alive rather than like a screensaver.
Why a 2D canvas, not WebGL
I seriously considered Three.js. A real 3D butterfly with bloom lighting sounded like the obvious way to make something glow. It would also have cost around 150 to 200 KB of JavaScript, plus GPU and battery time on every phone that opened the page, all for a decoration.
So I gave it a budget instead: under 15 KB, nothing that slows the page down, and nothing that keeps running when nobody’s watching. A 2D canvas fits that easily, and as it turns out, a little perspective maths is enough to make flat wings read as 3D.
Load it last
The butterfly is decoration, so it waits its turn. The page ships a tiny custom element wrapped around an empty canvas. The engine itself is a separate file, and it only downloads once three things are true: the page has finished loading, the browser has gone idle, and the hero is actually on screen.
const loaded = new Promise<void>((resolve) => {
if (document.readyState === 'complete') resolve();
else window.addEventListener('load', () => resolve(), { once: true });
});
const idle = () =>
new Promise<void>((resolve) => {
if ('requestIdleCallback' in window) requestIdleCallback(() => resolve(), { timeout: 3000 });
else setTimeout(resolve, 1200);
});
const visible = new Promise<void>((resolve) => {
const io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
resolve();
}
});
io.observe(this);
});
Promise.all([loaded.then(idle), visible])
.then(() => import('./butterfly'))
.then(({ start }) => start(this, canvas))
.catch(() => this.remove()); // it's decoration: fail quietly
The site is built with Astro, which has client:idle and client:visible for exactly this, but they only work with framework components. A custom element does the same job in about a kilobyte, with nothing to install. If anything goes wrong, the element removes itself, and the page simply has no butterfly.
Drawing the wings
Each wing starts life as a chain of Bézier curves, nudged until the silhouette felt right: a long leading edge sweeping out to a rounded tip on the forewing, and a softer lobe with a short tail on the hindwing. When the engine starts, it flattens those curves into polygons once, because points are far cheaper to move every frame than curves.
// Forewing: a long leading edge sweeping to a rounded apex, a scalloped outer margin.
const foreOutline = flatten([
[0.04, 0.1],
[0.3, 0.36], [0.68, 0.66], [0.9, 0.64], // leading edge to apex
[1.02, 0.6], [0.95, 0.36], [0.84, 0.2], // outer margin
[0.76, 0.08], [0.66, 0.02], [0.56, 0.0], // to the tornus
[0.36, -0.02], [0.14, -0.04], [0.05, -0.02], // inner margin back to the root
]);
The veins are gently bowed curves running from the root to points along the edge, and the glitter is a scatter of random points that land inside the wing. The randomness comes from a seeded generator, so everyone sees the same butterfly, every time.
Faking 3D
This is the trick that makes it work. The wings are flat shapes, but they live in a small 3D world. Each wing rotates around the body by the flap angle, the body turns to face the way it’s flying, and an imaginary camera looks down on it from slightly above. Then every point is projected onto the screen with simple perspective, so the parts further away come out a little smaller.
export function projectWing(fr: Frame, uv: Float32Array, side: 1 | -1, theta: number, out: Float32Array): number {
const c = Math.cos(theta), s = Math.sin(theta);
let depth = 0;
const n = uv.length / 2;
for (let i = 0; i < n; i++) {
const u = uv[i * 2], v = uv[i * 2 + 1];
projectBody(fr, v, side * u * c, u * s, out, i * 3);
depth += out[i * 3 + 2];
}
return depth / n;
}
The function also returns each wing’s average depth, so the four wings and the body can be drawn back to front, like layers of paint. When the butterfly turns around, the far wing slips behind the body and the near one comes forward. That’s most of what sells the illusion.
Two small details do a lot of the rest. The hindwings beat a fraction of a second behind the forewings, and the body rises a few pixels on every downstroke. Without them, it flaps like a machine. With them, it flutters. It also beats its wings just 1.4 times a second, much slower than a real butterfly, because slow reads as dreamy.
Making it glow
The wings are filled with a translucent gradient from root to tip, outlined with a soft glow (the canvas’s shadowBlur), and laced with faint veins. Every few seconds a band of light sweeps across them, and the glitter twinkles on its own rhythm.
The colours come from the site’s CSS variables, so the butterfly always belongs to the theme you’re in. The engine reads them with getComputedStyle, and a MutationObserver on the theme attribute tells it to read them again when you switch.
At night it’s drawn with additive blending (lighter), where overlapping light adds up and glows. In the day theme, that same trick made it vanish, because adding light to an almost-white sky just gives you white. So the daytime butterfly uses normal blending and fuller colours instead: pearl at the root, blushing pink at the tips. Getting there took a few attempts. Gold came first, then a pearl-and-blue line drawing, and neither sat right against the morning sky.
Fairy dust
The trail is a few hundred specks of light shed from the back edges of the wings. They drift away from the direction of flight, sink slowly, twinkle, and fade within a couple of seconds.
Two things keep that cheap. Every particle lives in a fixed pool of typed arrays, so nothing is created while it flies and the garbage collector never has a reason to stutter. And instead of drawing a gradient for every speck, the engine paints one soft dot onto a tiny off-screen canvas and stamps copies of it. Simplified slightly, it looks like this:
function makeSprite(core: [number, number, number], glow: [number, number, number]) {
const size = 64;
const c = document.createElement('canvas');
c.width = c.height = size;
const g = c.getContext('2d')!;
const grad = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
grad.addColorStop(0, rgba(core, 1));
grad.addColorStop(0.24, rgba(core, 0.9));
grad.addColorStop(0.65, rgba(glow, 0.35));
grad.addColorStop(1, rgba(glow, 0));
g.fillStyle = grad;
g.fillRect(0, 0, size, size);
return c;
}
Stamping a pre-drawn image with drawImage is one of the cheapest things a canvas can do, which is how a few hundred glowing specks cost almost nothing.
Never over the words
This was the rule that changed everything. The first version flew wherever it liked, including straight across the headline. It was pretty, and completely distracting. A site that’s meant to feel calm can’t have something fluttering across the words you’re trying to read.
So now the butterfly treats text as solid ground. It measures every heading, paragraph, link and button in the hero, plus the sticky header, and turns each of those boxes, padded by about half a wingspan, into a no-fly zone. To choose where to go next, it tries random points in the open sky and takes the first one it can reach in a straight line without crossing any of them:
function clearPath(x0: number, y0: number, x1: number, y1: number) {
for (let s = 1; s <= 12; s++) {
const k = s / 12;
if (overText(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k, pad())) return false;
}
return true;
}
On the way, it steers away from any text it gets close to, and turns harder the closer it gets, so it moves in lazy arcs instead of bouncing off invisible walls. The text moves too. The header follows you as you scroll, and lines rewrap when the fonts arrive, so it measures again twice a second, and once more when the fonts have loaded.
When there’s no open sky at all, like on a small phone where the words fill the hero, the butterfly simply doesn’t appear. If the space disappears while it’s flying, it dissolves into dust rather than land on a sentence. Before shipping it, I simulated three minutes of flight at five screen widths, from a 375-pixel phone to a 1920-pixel desktop, and checked that no wing ever touched a letter.
Click it
Click the butterfly and its wings break into nearly four hundred specks, taken from all over their surface and flung outward with a little spin. After two seconds of empty sky, dust spirals in around a new spot and gathers into a fresh butterfly, which flies off as if nothing happened.
It’s a small reward for anyone curious enough to try, and it never gets in the way. The canvas ignores the pointer entirely. Clicks are checked against the butterfly’s position on the hero itself, and any click on a link or a button is left alone.
Being a good guest
A decoration has to earn its place, so the butterfly keeps a few house rules:
- Screen readers never hear about it. The canvas is
aria-hidden. It’s there for the eyes, not the story. - It only runs when you can see it. An
IntersectionObserverand the Page Visibility API pause it when the hero scrolls away or the tab goes into the background. - It’s light on your device. The frame rate is capped near 60, and the canvas never renders at more than twice the screen’s pixel density.
- It respects reduced motion. If you’ve asked your device for less motion, you get one still butterfly, posed mid-flight, with no dust.
- It knows when to bow out. No canvas support means no butterfly, and the page doesn’t miss it.
All of that comes to about 7 KB gzipped: 6.2 KB for the engine and 1.1 KB for the loader.
If you’re making your own
Start with the rule that keeps it polite. Mine is “never over the words”. Yours might be “never near the buttons” or “only in the corners”. Everything after that is tuning.
Keep the tuning in one place. Every number that shapes this butterfly, from its wingspan and flap speed to how much dust it sheds, lives in a single config file, so I can change one, save, and watch the feel shift. Most of the magic came from nudging those numbers, not from writing more code.
And keep it to one. A single butterfly is a little moment. A swarm is a screensaver.