Blinking Terminal
This site's favicon is a tiny terminal prompt with a blinking cursor. Here's how to animate a favicon reliably with a canvas, two frames, and a timer.
The idea
A favicon is the smallest piece of UI a site ships — 16 pixels in a tab strip — yet it's the one thing visible even when the page isn't. I wanted this portfolio's tab to feel like the rest of the site: a dark terminal with a mint-green prompt and a cursor that blinks.
The static version is trivial: an SVG with a chevron and an underscore. The blinking part is where browsers fight you.
Why swapping href doesn't work
The obvious approach is two SVG files and a timer that flips the <link rel="icon"> href between them. In practice this is unreliable:
- Browsers cache favicons aggressively and may never re-read a changed href.
- When several icon links exist (ICO, PNG sizes, apple-touch-icon), the browser picks one — often not the one you're animating.
- Chrome, Edge, and Firefox each behave slightly differently here.
Fighting three browser caches with a timer is a losing game. So instead of pointing the browser at a file, we hand it a fresh image every frame.
Draw each frame on a canvas
Each frame is a 64x64 canvas: rounded dark background, a > chevron, and an _ cursor that is either drawn or skipped:
function drawFrame(cursorOn: boolean): string | null {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
// rounded dark background, chevron stroke...
if (cursorOn) {
ctx.beginPath();
ctx.roundRect(34, 40, 18, 7, 3.5); // the "_" cursor
ctx.fill();
}
return canvas.toDataURL("image/png");
}The return value is a PNG data URL — a brand-new string every time, so there is nothing for the cache to be clever about.
Replace the link, don't update it
Applying a frame means removing every existing icon link and appending one fresh element:
function applyIcon(href: string) {
document
.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')
.forEach((el) => el.remove());
const link = document.createElement("link");
link.rel = "icon";
link.type = "image/png";
link.href = href;
document.head.appendChild(link);
}Removing all icon links matters: as long as a second candidate exists, the browser is free to keep showing it. One link, one source of truth.
The blink loop
Both frames are drawn once up front, then a 600ms interval alternates them:
const on = drawFrame(true);
const off = drawFrame(false);
let lit = true;
applyIcon(on);
const id = window.setInterval(() => {
lit = !lit;
applyIcon(lit ? on : off);
}, 600);The interval is cleared in the effect's cleanup, so nothing leaks across navigations.
Respect reduced motion
A blinking cursor in the tab strip is exactly the kind of motion prefers-reduced-motion exists for. When the user asks for reduced motion, we skip the interval entirely and pin the lit frame:
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
applyIcon(on);
return;
}Takeaways
- Animate favicons by generating fresh data URLs, not by swapping file hrefs.
- Delete competing icon links before appending yours.
- Keep static files (ICO, PNG, apple-touch-icon) for crawlers, pinned tabs, and the initial load — the canvas animation enhances them, it doesn't replace them.
- Reduced motion applies to the tab strip too.