Most favicons are static afterthoughts. A .ico file dropped into the public folder during the first week of a project, never touched again. This site’s favicon now changes color when you toggle the theme and waves at you when you leave the tab. Around 30 lines of JavaScript.
The starting point: CSS inside SVG
The original favicon was an SVG with embedded CSS media queries for light and dark modes:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<style>
:root { --bg: #eff1f5; --fg: #1e66f5; --accent: #8839ef; --border: #ccd0da; }
@media (prefers-color-scheme: dark) {
:root { --bg: #272822; --fg: #a6e22e; --accent: #f92672; --border: #75715e; }
}
</style>
<rect width="32" height="32" rx="6" fill="var(--bg)"
stroke="var(--border)" stroke-width="1.5"/>
<polygon points="4,9 11,16 4,23 6.5,23 13.5,16 6.5,9" fill="var(--accent)"/>
<text x="15" y="23" font-family="'Georgia', serif"
font-size="21" font-weight="700" fill="var(--fg)">b</text>
</svg>
Browsers evaluate prefers-color-scheme inside SVGs used as favicons, so this works out of the box. Dark OS, dark favicon. No JavaScript needed.11SVG favicons are supported in all modern browsers. The only holdout was IE, which is no longer a concern for most sites.
Where it breaks
prefers-color-scheme follows the operating system preference, not the site’s theme. This site has a manual toggle that sets a data-* attribute on the <html> element. A visitor on a dark OS who switches the site to light mode sees a light page with a dark favicon. They’re out of sync.
CSS inside an SVG file can’t read a data-theme attribute from the parent document. The SVG is loaded as a separate resource. It’s isolated. JavaScript is the only way to bridge the gap.
Generating the SVG in JS
Instead of maintaining separate SVG files for each theme, I build the SVG string in JavaScript with the correct colors injected via encodeURIComponent:
const faviconColors = {
light: { bg: '#eff1f5', fg: '#1e66f5', accent: '#8839ef', border: '#ccd0da' },
dark: { bg: '#272822', fg: '#a6e22e', accent: '#f92672', border: '#75715e' },
};
window.buildFavicon = function(theme, sleeping) {
const c = faviconColors[theme] || faviconColors.light;
const indicator = sleeping
? '<text x="2" y="24" font-size="20">đź‘‹</text>'
: '<polygon points="4,9 11,16 4,23 6.5,23 13.5,16 6.5,9" .../>' +
'<text x="15" y="23" ...>b</text>';
return 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" ' +
'width="32" height="32">' +
'<rect width="32" height="32" rx="6" .../>' +
indicator +
'</svg>'
);
};
Both buildFavicon and setFavicon are exposed on window so other scripts can call them. The function returns a data: URI that gets set as the href of a <link rel="icon"> element in the document head. The browser treats it identically to a regular SVG file reference.22Data URIs over blob URLs here. Blob URLs require cleanup via URL.revokeObjectURL and don’t survive serialization. Data URIs are simpler and the payload is tiny, around 400 bytes.
The color values are drawn from the same palettes as the rest of the site (Catppuccin Latte for light, Monokai for dark), though the favicon maps them to its own token names. One object is the single source of truth for the favicon’s palette.
The sleeping favicon
The Page Visibility API tells you when a user switches tabs. document.hidden returns true when the tab is in the background, and the visibilitychange event fires on transitions:
window.setFavicon = function() {
const currentTheme = document.documentElement
.getAttribute('data-theme') || 'light';
const sleeping = document.hidden;
const el = document.getElementById('dynamic-favicon');
if (el) el.setAttribute('href', window.buildFavicon(currentTheme, sleeping));
};
document.addEventListener('visibilitychange', window.setFavicon);
When the tab is hidden, the entire monogram gets replaced with a 👋 emoji. When you come back, the caret and “b” return. Subtle enough to notice if you’re scanning your tabs, not jarring enough to be annoying.33Other sites use tab visibility creatively too. Slack swaps its icon to show a notification badge, and some sites change the page title to “Come back!” when you leave. I prefer the quieter approach.
Hooking into the theme toggle
The favicon logic lives in an inline <head> script (it needs to run before paint to avoid a flash of the wrong favicon). The theme toggle lives in a separate Astro component. They need to communicate.
Since both functions are on window, the theme switcher can call setFavicon after updating data-theme:
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
if (typeof window.setFavicon === 'function') {
window.setFavicon();
}
}
Global functions are the right call here. The head script runs first and defines them. The theme switcher component runs later and calls setFavicon. No event system, no framework, no ceremony.44Why not ES modules? Astro’s regular <script> tags get bundled into deferred type="module" scripts. Deferred scripts don’t execute until after the HTML is fully parsed, which means the favicon would flash the wrong theme before the module runs. The is:inline directive keeps the script untouched in the HTML, so the browser executes it synchronously during parsing, before the first paint. The tradeoff is no import/export syntax, hence window globals.
View transitions
Astro’s ViewTransitions swaps the DOM on page navigation. The <link> element survives the swap (it’s in the <head>), but the data-theme attribute on the new document might need the favicon re-synced. One listener handles it:
document.addEventListener('astro:after-swap', window.setFavicon);
Forgetting this line means the favicon resets to the static fallback after every navigation. I caught this during testing when the favicon flickered on page changes.
The fallback
The <link> tag starts with the static SVG reference before JavaScript takes over:
<link id="dynamic-favicon" rel="icon" type="image/svg+xml" href="/favicon.svg" />
If JavaScript is disabled, the browser uses that SVG file, which still has its CSS media queries for OS-level dark mode. The dynamic behavior is a progressive enhancement. There’s also a favicon.ico in the public directory for legacy browsers that don’t support SVG favicons at all.
Closing thoughts
The entire implementation is about 30 lines of inline JavaScript. No build step, no dependencies, no framework integration. The favicon is part of the site’s design system now rather than a forgotten static asset. Toggle the theme, the tab icon follows. Leave the tab, it waves goodbye. Small details like this are what make a site feel considered.