· Zerdalu
Go Back

Adaptive SVG Favicons with prefers-color-scheme

When building polished web applications, the favicon is often the last thing we optimize. We export a 32×32 PNG, drop it into the public folder, and forget about it. But if your users browse in dark mode—and today, most do—that tiny icon can vanish against a dark browser tab or bookmark bar.

I ran into this recently with Lafyeri (lafyeri.zerdalu.com). Our brand mark is a clean, dark geometric shape. In a light browser tab, it looks crisp. In a dark one, it becomes nearly invisible. I didn’t want to maintain multiple icon files, and I didn’t want to add JavaScript logic just to swap a favicon based on some theme state.

Modern browsers already give us a better way: a single SVG favicon that adapts on its own.


The Problem: Static Favicons Don’t Adapt

Traditional .ico and .png favicons are static bitmaps. Once the browser caches the file, its pixels are locked in place. If your logo is designed with a dark palette to match a sleek interface, it becomes a ghost on dark-themed browser chrome.

The usual workarounds all have downsides:

  1. Adding a Glow or Outline: This protects contrast, but it often looks tacky and dilutes a minimal mark.
  2. Using a Background Shape: Forcing a circular or square container adds weight and can clash with the native tab design.
  3. Multiple Favicon Files: Serving different files for light and dark modes requires JavaScript to detect the theme and rewrite the <link> tag’s href. That’s extra state, extra requests, and extra complexity.

What I wanted was a favicon that responded directly to the browser’s own environment, without any help from React.


The Solution: A Self-Adapting SVG

SVG is an XML-based format that can contain a <style> block. Modern browsers support SVG favicons, and the embedded CSS respects the same prefers-color-scheme media query used in your stylesheets. The browser evaluates this when rendering the icon in its chrome—tabs, bookmarks, and history—entirely independently of whatever theme your website is currently displaying.

The SVG Markup

Here is the favicon we use on Lafyeri. It uses a dark zinc tone for light-mode browser chrome and automatically flips to an off-white tone for dark mode.

<!-- public/favicon.svg -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
    <style>
        path { fill: #18181b; }
        @media (prefers-color-scheme: dark) {
            path { fill: #f4f4f5; }
        }
    </style>
    <g>
        <path d="M20.716 11.636H41.091L14.716 52.364H7Z"/>
        <path d="M43.284 52.364H22.909L49.284 11.636H57Z"/>
    </g>
</svg>

A few details worth noting:

  • The @media (prefers-color-scheme: dark) query is doing the heavy lifting. The browser evaluates this natively when it renders the favicon.
  • The colors #18181b and #f4f4f5 map to Tailwind’s zinc-900 and zinc-100. Reusing your design system’s palette keeps the icon visually connected to your brand.
  • There is no fill="currentColor" attribute on the tag. In a standalone favicon, there is no parent document from which to inherit a color value, so explicit CSS rules on the path elements are the only source of truth.

Wiring It Up in TanStack Start

In your __root.tsx, you only need a standard inside the route’s head configuration. No runtime logic is required.

// src/routes/__root.tsx
export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: "utf-8" },
      { name: "viewport", content: "width=device-width, initial-scale=1" },
      { title: "Lafyeri" },
    ],
    links: [
      { rel: "stylesheet", href: appCss },
      { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
    ],
  }),
  // ...
});

The type="image/svg+xml" is important. It tells the browser to parse the file as SVG rather than sniffing the format. If you place favicon.svg in your public/ directory, TanStack Start (via Vinxi/Vite) will serve it at the root path automatically.

Why Not PNG?

You might wonder if a couple of PNG exports would be simpler. For most modern projects, SVG wins on three counts:

Resolution Independence: One SVG is sharp on a 1× monitor, a 2× Retina display, and at any arbitrary size the browser might request for a shortcut icon or PWA manifest. PNGs either soften or require multiple fixed-size assets.

File Size: For simple geometric icons, a single optimized SVG is often smaller than a bundle of ICO or PNG files.

Zero JavaScript: By keeping the color logic inside the image format itself, your React code doesn’t need to know the favicon exists. There are no useEffect hooks watching theme changes and mutating tags.


Conclusion: Lessons Learn

Switching to an SVG favicon with embedded prefers-color-scheme logic solves a surprisingly annoying problem without adding weight to your bundle.

The key takeaways:

One File, Infinite Contexts: A single favicon.svg replaces an entire folder of PNGs and ICO

Browser-Native Behavior: The adaptation is handled entirely by the browser’s rendering engine and the OS color preference.

Keep It Standalone: Since the SVG lives outside your document, style it with explicit hex values rather than relying on inherited properties like currentColor

Design System Alignment: You can reference the exact tokens from your Tailwind config, so the icon never feels visually disconnected from your application.

The best solutions often don’t involve adding more logic to your application. They involve leveraging what the platform already provides. In this case, a few lines of CSS inside an SVG saved me from maintaining a parallel set of favicon assets and our dark-mode users can finally see the tab they are looking for.