All posts
CSS2026-08-29 · 7 min

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.


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.

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.

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.