All posts
CSS2026-Sep-20 · 10 mins

Blinking Terminal

This site's favicon is a tiny terminal prompt with a blinking cursor, which is my personal favourite. Here's how to animate a favicon reliably with a canvas, two frames, and a timer.


This site's favicon blinks for only 30 seconds. If you're here for longer, you may not see the favicon blink, and you might need to reload the page. See Q3 in the Production-Grade section to understand why.

This blog has long code examples. For the best experience, view on desktop or landscape mode.

I spent almost a week animating a 16-pixel favicon. Why? Because it's the only UI element visible when your page isn't active, or buried under 20 other tabs.

The result: a dark retro terminal prompt that blinks steadily in the browser tab. Here's the complete blueprint—and why it's harder than it should be.


The Swapping File Approach

The intuitive approach is putting two static PNG files in /public (favicon-on.png and favicon-off.png) and running a setInterval timer to swap the href attribute of <link rel="icon">:

Code
1
my-terminal-app/
2
├── public/
3
│ ├── favicon-on.png # Frame 1: Lit cursor
4
│ └── favicon-off.png # Frame 2: Unlit cursor
5
└── ...

In real browsers, this fails because:

  • Aggressive Browser Caching: Browsers cache favicon URLs aggressively in memory threads and often ignore HTTP cache headers.
  • Competing Link Tags: Multiple fallback tags (like apple-touch-icon.png or favicon.ico) cause browsers to pin one static candidate and ignore dynamic updates.

Why? A Working Demo.

To reproduce and verify why static file swapping fails, you can test this in your locale environment, by creating a BrokenFaviconTest() component.

tsx
1
// src/components/BrokenFaviconTest.tsx
2
import { useEffect } from "react";
3
4
export function BrokenFaviconTest() {
5
useEffect(() => {
6
// Place favicon-on.png and favicon-off.png in /public
7
let lit = true;
8
9
const interval = setInterval(() => {
10
lit = !lit;
11
const link = document.querySelector('link[rel="icon"]');
12
if (link) {
13
link.href = lit ? "/favicon-on.png" : "/favicon-off.png";
14
}
15
}, 600);
16
17
return () => clearInterval(interval);
18
}, []);
19
20
return (
21
<div className="p-4 bg-yellow-100 border border-yellow-400 rounded">
22
<p>Static File Swap Test Active</p>
23
<p className="text-sm text-gray-600">
24
Watch the browser tab favicon. It should blink but won't.
25
</p>
26
</div>
27
);
28
}
How to Verify

Open DevTools → Network tab → check "Disable cache". Watch the browser tab favicon for 10 seconds:

  • Static file swaps: Produce 0 requests after initial load (favicon stays frozen).
  • Data URLs: Update instantly with no additional network requests.

The Solution: Canvas Data URLs

To bypass browser file-caching and file-swapping limitations, we generate a unique in-memory base64 Data URL PNG on the fly using HTML5 Canvas 2D and dynamically swap the <link rel="icon"> tag in document.head.

*What is a Data URL?* A Data URL (e.g. data:image/png;base64,iVBORw0KG...) embeds raw pixel data directly inside a text string. Because the browser receives the raw image payload inline rather than requesting a file from a server over HTTP, frame swaps execute instantly without browser caching locks or network requests.


Project Setup & Fallback Strategy

You can set up your project in two ways:

  • Option 1: Clone the pre-configured Playground Starter Repository for a ready-to-go Vite + React + Tailwind CSS environment.
  • Option 2: Initialize a fresh Vite + React + TypeScript app using npm create vite@latest my-terminal-app -- --template react-ts. or bootstrap it with your favourite flavor.
Why Static Fallbacks Matter

Always keep a static favicon.ico in /public as a fallback, while our JS engine dynamically takes over the browser tab at runtime. Web crawlers (Googlebot), RSS readers, and mobile home screen shortcuts cannot execute JavaScript animation loops.


Canvas 2D Frame Generator Engine

Create src/utils/blinkingFavicon.ts. This file contains the vector rendering logic, presets, and error handling:

ts
1
export type CursorStyle = "underline" | "block" | "bar";
2
3
export interface FrameConfig {
4
bg?: string; // Background color (e.g. #14181f)
5
fg?: string; // Prompt & cursor color (e.g. #64ffda mint green)
6
style?: CursorStyle;
7
}
8
9
10
//Pre-configured color palette presets for easy discoverability.
11
export const presets = {
12
mintEmerald: { bg: "#14181f", fg: "#64ffda", style: "underline" as CursorStyle },
13
retroCrimson: { bg: "#2a0f0f", fg: "#ff4444", style: "block" as CursorStyle },
14
};
15
16
//Renders a 64x64 canvas frame in memory and returns a PNG Data URL string.
17
export function drawFrame(
18
cursorOn: boolean,
19
config: FrameConfig = {}
20
): string {
21
const { bg = "#14181f", fg = "#64ffda", style = "underline" } = config;
22
23
const size = 64; // High-DPI 64x64 resolution for Retina displays
24
const canvas = document.createElement("canvas");
25
canvas.width = size;
26
canvas.height = size;
27
const ctx = canvas.getContext("2d");
28
29
if (!ctx) {
30
throw new Error("Canvas 2D context unavailable in current environment");
31
}
32
33
// 1. Draw rounded dark background container (four smooth rounded corners)
34
const r = 14;
35
ctx.fillStyle = bg;
36
ctx.beginPath();
37
ctx.moveTo(r, 0);
38
ctx.arcTo(size, 0, size, size, r); // Top-right corner
39
ctx.arcTo(size, size, 0, size, r); // Bottom-right corner
40
ctx.arcTo(0, size, 0, 0, r); // Bottom-left corner
41
ctx.arcTo(0, 0, size, 0, r); // Top-left corner
42
ctx.closePath();
43
ctx.fill();
44
45
// 2. Draw prompt chevron vector ( > )
46
ctx.strokeStyle = fg;
47
ctx.lineWidth = 7;
48
ctx.lineCap = "round";
49
ctx.lineJoin = "round";
50
ctx.beginPath();
51
ctx.moveTo(15, 20);
52
ctx.lineTo(28, 32);
53
ctx.lineTo(15, 44);
54
ctx.stroke();
55
56
// 3. Draw active cursor shape when lit
57
if (cursorOn) {
58
ctx.fillStyle = fg;
59
if (style === "underline") {
60
if (typeof ctx.roundRect === "function") {
61
ctx.beginPath();
62
ctx.roundRect(34, 40, 18, 7, 3.5);
63
ctx.fill();
64
} else {
65
ctx.fillRect(34, 40, 18, 7);
66
}
67
} else if (style === "block") {
68
ctx.fillRect(34, 18, 16, 28);
69
} else if (style === "bar") {
70
ctx.fillRect(34, 18, 6, 28);
71
}
72
}
73
74
// Convert buffer pixel data to base64 Data URL string
75
return canvas.toDataURL("image/png");
76
}

In the same file src/utils/blinkingFavicon.ts, implement the DOM updater function:

ts
1
// Clears competing favicon link tags and injects a fresh <link rel="icon">
2
// The <link rel="icon"> will always point to the Data URL.
3
// Prevents stale favicons by removing old links before appending new ones,
4
export function applyIcon(dataUrl: string): void {
5
// Clear any existing static or dynamic icon links
6
document
7
.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')
8
.forEach((el) => el.remove());
9
10
// Inject fresh link element pointing to our base64 Data URL
11
const link = document.createElement("link");
12
link.rel = "icon";
13
link.type = "image/png";
14
link.href = dataUrl;
15
document.head.appendChild(link);
16
}

React Lifecycle Custom Hook

Create src/hooks/useBlinkingFavicon.ts to connect the engine to your React application lifecycle:

ts
1
import { useEffect } from "react";
2
import { drawFrame, applyIcon, CursorStyle, FrameConfig } from "../utils/blinkingFavicon";
3
4
interface Options extends FrameConfig {
5
speedMs?: number;
6
enabled?: boolean;
7
}
8
9
/**
10
* Manages the animation loop: generates frames once up-front, respects prefers-reduced-motion,
11
* and cleans up intervals on unmount to prevent memory leaks.
12
*/
13
export function useBlinkingFavicon({
14
speedMs = 600,
15
bg = "#14181f",
16
fg = "#64ffda",
17
style = "underline",
18
enabled = true,
19
}: Options = {}) {
20
useEffect(() => {
21
if (!enabled) return;
22
23
// Generate both ON and OFF frames once up-front for maximum CPU efficiency
24
const frameOn = drawFrame(true, { bg, fg, style });
25
const frameOff = drawFrame(false, { bg, fg, style });
26
27
// Accessibility check: lock cursor lit if user prefers reduced motion
28
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
29
applyIcon(frameOn);
30
return;
31
}
32
33
let lit = true;
34
applyIcon(frameOn);
35
36
// Animation interval loop
37
const intervalId = window.setInterval(() => {
38
lit = !lit;
39
applyIcon(lit ? frameOn : frameOff);
40
}, speedMs);
41
42
// Cleanup timer on unmount to prevent memory leaks across page navigations
43
return () => {
44
window.clearInterval(intervalId);
45
};
46
}, [speedMs, bg, fg, style, enabled]);
47
}

Calling & Rendering in Your Application

Now simply call your hook in src/App.tsx using presets or custom options:

tsx
1
import React from "react";
2
import { useBlinkingFavicon } from "./hooks/useBlinkingFavicon";
3
import { presets } from "./utils/blinkingFavicon";
4
5
export default function App() {
6
// Activate blinking terminal favicon loop using discoverable preset
7
useBlinkingFavicon({
8
...presets.mintEmerald,
9
speedMs: 600,
10
});
11
12
return (
13
<main className="min-h-screen bg-[#14181f] text-[#64ffda] flex items-center justify-center">
14
<h1 className="text-2xl font-mono">
15
> Blinking Terminal Engine Active in Browser Tab!
16
</h1>
17
</main>
18
);
19
}

How to Inspect Tab Favicons

The easiest way to inspect such dynamic favions is:

  • Inspect Element: Open Chrome/Firefox DevTools, expand <head>, and select the <link rel="icon"> node. You can observe the href attribute toggling between two Data URLs in real-time.

Inspect Favicon

  • Network Tab Filtering: Filter DevTools Network panel by "data:" or "Img" to verify no external network roundtrips are occurring during frame swaps.

Performance & Optimization Highlights

Pre-Rendered Buffer Benchmark: We call drawFrame only once per state change up front. Each frame swap is a ~0.3ms DOM operation vs. ~8ms canvas render per cycle. With pre-rendering, you trade negligible memory (~500 bytes) for 26× faster frames without triggering canvas rasterization inside setInterval.

Accessibility: Respects prefers-reduced-motion: reduce system preferences automatically.

Clean Unmount: clearInterval ensures background timers don't persist across route transitions.

Platform Limitations

Before rolling out dynamic favicons, keep these platform-specific behaviors in mind:

  • iOS Safari: Completely ignores dynamic favicon Data URLs and only respects the static apple-touch-icon.png defined at initial page load.
  • Firefox Caching: Firefox caches Data URLs differently than Chromium engines; tearing down old <link> elements explicitly via el.remove() is required to prevent stale tab states.
  • OS Dark Mode Overrides: Operating system dark/light mode preference media queries (prefers-color-scheme) do not apply to base64 Data URLs inside <link rel="icon">. All themes must be styled directly inside the canvas renderer.

Production-Grade Disclaimer & Critical Engineering Q&A

Before deploying a dynamic blinking favicon to a high-traffic production application, consider these real-world engineering constraints:

Q1: Will this override the browser being idle or drain laptop battery?

Yes, if unoptimized. Standard setInterval timers run continuously. Modern browsers throttle background tab timers to 1Hz or 1 update/minute to save power, but active tab execution keeps CPU cores awake.

Production Fix: Use the Page Visibility API. Pause the timer when document.hidden === true and resume when the tab regains focus:

ts
1
document.addEventListener("visibilitychange", () => {
2
if (document.hidden) stopBlinking();
3
else startBlinking();
4
});

Q2: Will it eat RAM or cause memory leaks over time?

Not if pre-rendered.

  • Heap Allocation: Generating new Data URLs inside the interval loop allocates ~1.2KB base64 strings every cycle, causing unnecessary Garbage Collection (GC) pauses. Pre-rendering frameOn and frameOff into two static string references once up front completely eliminates heap allocation during animation cycles.
  • DOM Node Leaks: Always invoke el.remove() on old <link> elements before appending new ones. If omitted, thousands of orphaned DOM nodes will accumulate in <head>.

Q3: Is it really good practice to ship this in Production?

Not really. Use sparingly.

  • Visual Distraction: A constantly flashing icon in a user's tab strip creates visual noise and can be distracting when users have 20+ tabs open.
  • Production Recommendation:
  • Stop the blink loop after 10–15 seconds (or 30 seconds) and lock the cursor lit:
ts
1
// Stop blinking loop after 30 seconds and lock cursor lit
2
const timeoutId = window.setTimeout(() => {
3
window.clearInterval(intervalId);
4
applyIcon(frameOn);
5
}, 30000);
  • Or use dynamic favicons exclusively for state changes (e.g. flashing when an unread notification arrives or a background deployment completes).

Q4: Does this break SEO or Mobile PWA installation?

No, as long as static fallbacks exist. Web crawlers (Googlebot) and PWA manifest engines read static favicon.ico and apple-touch-icon.png directly from static HTTP requests without executing JavaScript.

Q5: Can I animate other browser UI with canvas data URLs?

Yes, but with caveats. Canvas Data URLs can also be applied to apple-touch-icon, PWA manifest icons, and <meta name="theme-color"> content preview elements. However, browser tab favicons (<link rel="icon">) are by far the most widely supported and reliable target for dynamic Data URL updates.


Try the Interactive Playground

Experiment below with real-time browser tab favicon synchronization, customize blink speeds, select retro CRT color palettes (Mint Emerald vs Retro Crimson), and inspect the 2D canvas frame buffer output live!

Blinking Favicon Engine Playground

Engine Configuration

Blink Interval Rate600 ms
150ms (Fast)600ms (Standard)1500ms (Slow)

2D Canvas Buffer Inspector

Frame A (Cursor ON)
64x64 PNG
Frame B (Cursor OFF)
64x64 PNG
Active Frame:Frame A (Cursor ON)
Preset Spec:Mint Emerald · underline · 600ms
Feedback & Improvements

Have feedback or edge-case browser findings?

If you spot an edge-case browser behavior or have suggestions to improve this blueprint, reach out directly using our interactive contact form.