Tuesday, September 29, 2026
Animating a favicon browsers refuse to animate
Browsers will not animate an SVG favicon, so this one is 120 pre-rendered PNGs swapped through a link tag at 24fps. Here is how the drink stays level, why the ice is always 150ms late, and every trade-off that made it cheap enough to ship.
On this page
The favicon on this site is a bourbon tumbler. As you scroll, it either twirls — the drink sloshing against each change of direction — or you take a sip — tipping toward the title, holding a mouthful, and coming back a little emptier before you top it back up.
None of that is supposed to be possible. Browsers do not animate SVG favicons. Whether you try an <animate> element in favicon.svg, or a CSS animation, or a SMIL timeline, the tab will render the first frame and ignore the rest. The favicon is chrome, not content, and the chrome is not a renderer.
...So how does this work?
It cheats. Every frame is drawn as an SVG string, rasterized through a canvas to a PNG data URL at build-of-the-page time, and then the href of every link[rel="icon"] is swapped through those frames on a timer. The browser thinks the icon is changing, because it is. It just has no idea it is watching an animation.
The whole thing is about 370 lines in one module, with no dependencies, and it is skipped entirely under `prefers-reduced-motion`. This post is the interesting half: the physics shortcuts, the timing decisions, and the optimizations that keep it from being a silly thing to ship (though let's be honest, it is a little silly).
# The trick is lag, not simulation
The obvious way to animate liquid in a glass is to simulate liquid in a glass. Don't. At the size this renders — 16 pixels, maybe 20 — a fluid solver would be several hundred lines producing a result indistinguishable from what can ultimately be done with only four lines of arithmetic.
Here is the entire physics model: every part of the drink is handed the glass's angle from a moment ago. Not its own velocity, not a spring, not a force. Just the same curve, read late.
In code, that is unglamorous (which is the point):
const twirlFrame = (t) => frame(twirlAngle(t), ice(twirlAngle(t), twirlAngle(t - ICE_LAG)) + liquid(twirlAngle(t - LIQUID_LAG)));The cube also gets a second, subtler cue: it leans by whatever it still owes the glass — the difference between where the glass is and where the cube thinks it is. When the glass snaps back through center, the cube is still tilted the old way for a beat. Nobody consciously notices that. Everybody notices its absence, because without it the cube reads as painted on.
# The drink has to stay level, and it has to (preferably) not spill
Rotating a glass is trivial. Rotating a glass full of something is where it gets really interesting, because the liquid's surface is the one thing in the drawing that must ignore the rotation — it lies horizontal no matter what the glass does.
The first attempt rotated the resting liquid shape backwards by the same angle the glass turned. That works for about eight degrees and then falls apart: the resting shape is a 45-pixel-tall "rectangle" (with a little curve for effect), and once it counter-rotates far enough one of its corners lifts off the bottom of the glass and you can see the floor through your bourbon.
What works is to stop thinking of it as a shape at all. The drink is an oversized rectangle, counter-rotated out of the glass's frame and clipped to it. There is always more rectangle than glass, so there is nothing to run out of.
That leaves one real problem, and it was the part I enjoyed most.
Enter cosine
If you hold the surface at a fixed height and tilt the glass, the glass gains drink. The wedge that appears on the low side is bigger than the wedge that disappears on the high side, and the level visibly climbs through the sip. It looks like the glass is filling itself while you drink from it.
The area of liquid in a tilted container, while the surface still meets both walls, is:
area = 2a · d / cos(θ)where a is the half-width and d is the surface's perpendicular distance from the pivot. Hold d constant and the area grows with the tilt. Scale d by the cosine instead — d = d₀ · cos(θ) — and the cosines cancel. The area is 2a · d₀ at every angle. (Yes, I absolutely had to look these up!)
That formula is also what sets the deepest the glass is allowed to tip. It only holds while the surface still touches both walls — past roughly 27° the raised corner runs dry, the trapezoid becomes a triangle, and the maths silently stops describing the picture. The sip tips 24°, with the margin deliberate rather than lucky.
# Timing, part one: realism
Two numbers took the longest to settle on, and neither of them was related to the physics.
The hold. The first sip tipped, touched, and came straight back — 580ms at full tilt. On replay, it read to me as flinching, not drinking. I slowed it to 1280ms, which is more than half the sequence, and that gave us a sip instead of a sniff.
The drift. Adding the longer hold introduced a new problem: the liquid and the cube settle within about 150ms of arriving, and then nothing moves. A frozen frame for over a second does not read as a pause, it reads as a stall — your eye assumes the animation broke. So the glass drifts 1.5° across the hold. It is far too small to consciously see and it is the entire difference between a held breath and a crash.
In truth, stillness in an animation has to be active. If the thing has stopped moving entirely, nobody can tell the difference between a pause and a bug.
# Timing, part two: readability at 16 pixels
Everything described above is invisible if the drawing does not survive the size it is displayed at, and a favicon is displayed at a size where basically nothing survives.
The original mark had the drink filling about a fifth of the glass interior. At 16 pixels, that is a band about two and a half pixels tall — enough to see if you already know it is there, not enough to read as liquid. I raised the resting level to about a third of the interior, which roughly doubled it, and that was the single largest improvement to the whole effect.
It also cost more than it looked like it should, because of this constraint:
The last frame of the fill animation has to be pixel-identical to the static favicon, or the icon visibly jumps the moment the animation ends and the real file is restored.
The animation's frames come from a script. The resting icon comes from a file. Raising the level meant moving both — the script constant, the SVG's path data, and both PNG fallbacks regenerated from the SVG so all three stay the same drawing. Four files that now move together or not at all.
And the tail on that: browsers keep favicons in their own cache, which a normal reload does not clear. Change the artwork without versioning the URL and returning visitors get the animation at the new level and the resting icon at the old one — which looks exactly like an animation bug, and is not one. The icon hrefs carry a ?v= for that reason.
# The trade-offs
This is an ornament. An ornament is allowed to cost approximately nothing, so most of the engineering went into making it cheap.
Every frame is retained, so the frame rate is a memory setting
The frames are PNG data URLs held in an array for the life of the page. Three sequences come to about 120 frames. At 30fps it was 147, and the difference is pure retained string. I dropped it to 24fps, which took roughly a fifth off both the rasterizing at load and the memory held afterwards, and at 16 pixels I cannot tell the two apart. I chose that frame rate for bytes, not smoothness.
One canvas, not one per frame
The naive rasterizer allocates a canvas per frame. There is no reason for that — one shared canvas does fine, as long as you clear it:
const rasterise = (svg) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { ctx.clearRect(0, 0, SIZE, SIZE); ctx.drawImage(image, 0, 0, SIZE, SIZE); resolve(canvas.toDataURL("image/png")); }; image.onerror = reject; image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; });That clearRect is not defensive tidiness, it is load-bearing. The mark never covers the full square, so without it every frame composites over the one before and the icon turns into a smear. I checked: remove the clear and 116 of 117 frames come out different.
The fill sequence is a lookup table
The best optimization here was noticing something already existed. The page-load fill animates the drink from empty to full — which means it contains a frame for every level in between.
So nothing else ever renders a partly-filled glass. It asks the fill which frame matches the level it wants:
- Draining while the tab is hidden is the fill sequence read backwards.
- Refilling when you come back is the fill played forward from wherever you left off.
- The sip's top-up hands straight to the fill at the frame matching what the mouthful took.
Three behaviors, no new frames, and the ice sinking as the level drops came free — the fill already animated the cube settling as the drink rose, so running it backwards drops the cube accurately without adding an extra line.
You cannot animate a hidden tab, so don't
The most interesting constraint. A favicon is the one part of a page still visible after you switch away, which is the argument for doing something there. The argument against doing it with animation is that hidden tabs have no frame rate to spend: timers clamp to roughly a second, and Chrome cuts them to once a minute after five. A two-second sequence would take nearly a minute of lurching and then stall.
So the background behavior is not an animation at all. The level drops one swallow every 30 seconds, four swallows to empty, and then the timer stops — one timer per swallow, not a polling loop, and all the background work is over inside two minutes. The five-minute throttle never even applies.
Designing for the constraint got a better result than fighting it would have. A glass that is emptier when you come back is a nicer idea than a stuttering 1fps rock, and it costs a rounding error.
A rotation needs more room than you think
This one cost me an afternoon: the glass nearly fills its own viewBox, so rotating it at all pushes a corner outside the frame. The original 8° tilt had been quietly clipping its own corners for months. Everything tilted now goes through a fit pass that scales down only as far as the overflow demands and nudges what is left back inside — and is the identity at 0°, so the resting frame still matches the static file exactly.
Its side effect turned out to be a feature. The nudge slides the glass against the tilt, which converts a pivot at the base into an effective pivot around the middle — which is what tipping a glass toward your mouth actually looks like.
# What it degrades to
- Reduced motion: the whole module is behind
prefers-reduced-motion: no-preference. Nothing renders, nothing rasterizes. - Safari: does not repaint favicons on
hrefchanges, so none of this is visible there. It fails silently and costs one wasted rasterizing pass. - No canvas: guarded, skipped.
- Sizeless SVG: worth knowing if you build something similar — an SVG with only a
viewBoxand nowidth/heightis a long-standing rasterizing hazard outside Chromium. The frames carry explicit dimensions now.
The static favicon is always the real file, and the animation is always additive. If every part of this fails, you have an icon of a glass of bourbon, which was the requirement in the first place.
# Was it worth it
Absolutely not, and I would do it again. It is a tab icon that nobody asked for, that Safari cannot see, that is invisible to anyone with reduced motion on, and that occupies about 250 square pixels of anyone's screen.
But the constraints were genuinely interesting — a rendering target that refuses to render, a frame budget measured in kilobytes of string, a background state with no frames at all — and every one of them turned out to have a cleaner answer than the brute-force version. That is the kind of problem that is fun precisely because the stakes are zero.
Scroll, and mine is having a drink.