Theming SVG and Favicon Assets for Dark Mode
Part of prefers-color-scheme Integration. Tokens handle the interface; assets do not follow automatically. This guide covers the four asset classes that need separate treatment and the technique that fits each.
Prerequisites Link to this section
- A theme applied via a root attribute plus the OS media query, as covered in the parent area.
- Icons available as inline SVG or as a sprite you can modify; icons delivered as
<img>cannot usecurrentColor. - Access to the original artwork for any illustration that needs a second variant.
- A build step able to emit more than one favicon file.
Step-by-Step Implementation Link to this section
Step 1 — Make single-colour icons inherit Link to this section
Intent: an icon that takes its colour from the surrounding text needs no theme awareness at all.
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M12 2 2 22h20L12 2Z"/>
</svg>
.icon { width: 1em; height: 1em; color: inherit; }
Why this works: currentColor resolves to the computed color of the element, which the theme already sets through its text tokens. The icon inherits both the theme and the local context — muted in secondary text, inverted inside a filled button — with no additional rules. This one change handles the large majority of icons in a typical product.
Step 2 — Remap the palette in multi-colour inline SVG Link to this section
Intent: a diagram with several baked colours needs each of them replaced, not overridden wholesale.
/* Attribute selectors key on the exact hex authored in the SVG source. */
[data-theme="dark"] .diagram svg [fill="#ffffff"] { fill: #1e293b; }
[data-theme="dark"] .diagram svg [fill="#f1f5f9"] { fill: #334155; }
[data-theme="dark"] .diagram svg [fill="#0f172a"] { fill: #f1f5f9; }
[data-theme="dark"] .diagram svg [fill="#475569"] { fill: #cbd5e1; }
[data-theme="dark"] .diagram svg [stroke="#2563eb"] { stroke: #60a5fa; }
Why this works: CSS properties override presentation attributes, so a stylesheet rule wins over the fill written in the markup. Keying on the exact value means the mapping is explicit and auditable — you can list every colour the diagrams use and confirm each one has a dark counterpart. The critical discipline is that authors must use only palette values: a single off-palette hex has no rule and silently stays light on a dark background.
Step 3 — Give every inline SVG a background rect Link to this section
Intent: guarantee label contrast regardless of what surface the figure sits on.
<svg viewBox="0 0 720 300" role="img" aria-labelledby="t d">
<title id="t">…</title>
<desc id="d">…</desc>
<rect x="0" y="0" width="720" height="300" fill="#ffffff"/>
<!-- shapes follow -->
</svg>
Why this works: without a full-canvas rect the SVG is transparent, and its labels sit on whatever the page background happens to be. In light mode that is usually fine; after remapping, a dark label can end up on a dark page. A background rect that participates in the remap means the figure always defines its own surface, and contrast is a property of the figure rather than of where it was placed.
Step 4 — Ship two files for raster assets Link to this section
Intent: screenshots and photographs cannot be recoloured, so they need a second capture.
<picture>
<source srcset="/img/dashboard-dark.png" media="(prefers-color-scheme: dark)">
<img src="/img/dashboard-light.png" alt="The dashboard showing three active projects"
width="1200" height="750" loading="lazy">
</picture>
Why this works: <picture> selects a source before the image is fetched, so only one file is downloaded. The media attribute reads the OS preference directly — which is also its limitation, covered in the troubleshooting table: it does not know about an in-page theme toggle.
Step 5 — Handle the favicon separately Link to this section
Intent: the favicon renders in browser chrome, outside your document, where neither your attribute nor your stylesheet applies.
<link rel="icon" href="/favicon-light.svg" media="(prefers-color-scheme: light)">
<link rel="icon" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)">
<link rel="icon" href="/favicon.ico" sizes="48x48">
An alternative that needs only one file, using a media query inside the SVG itself:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
path { fill: #0f172a; }
@media (prefers-color-scheme: dark) { path { fill: #f1f5f9; } }
</style>
<path d="M4 4h24v24H4z"/>
</svg>
Why this works: both approaches key on the OS preference, which is the only signal available in that context. The single-file version is preferable when it works, because it keeps the two variants in one artefact that cannot drift apart; the two-link version is more widely supported and degrades to the .ico fallback in older browsers.
Step 6 — Close the toggle gap where it matters Link to this section
Intent: for assets where the mismatch is visible and meaningful, drive the swap from the theme attribute rather than the media query.
// Swap themed images when the resolved theme changes, not the OS preference.
function syncThemedImages() {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
for (const img of document.querySelectorAll('img[data-src-dark]')) {
const next = dark ? img.dataset.srcDark : img.dataset.srcLight;
if (img.getAttribute('src') !== next) img.setAttribute('src', next);
}
}
<img src="/img/chart-light.png" alt="Weekly active users, rising through Q3"
data-src-light="/img/chart-light.png" data-src-dark="/img/chart-dark.png"
width="800" height="400">
Why this works: the attribute is the resolved theme, so this follows both the OS preference and an explicit choice. The cost is a flash of the previous image on the first swap and a dependency on script, which is why it is worth reserving for assets where the mismatch is genuinely jarring — a full-width hero screenshot — rather than applying it to every image on the page.
Verification Link to this section
Check each class separately, because they fail independently and for different reasons.
For icons: toggle the theme and confirm every icon changes colour with its surrounding text. Any icon that stays put is either an <img> or has a hard-coded fill. For diagrams: switch to dark and look for shapes that stayed light — those are off-palette colours with no remap rule. A quick audit script that extracts every fill and stroke value from the content and compares it against the remap table finds them without looking at a single page.
For raster assets: emulate the dark OS preference in DevTools, reload, and confirm the dark file is the one fetched in the network panel. For favicons: the reliable check is a real browser with the OS preference switched, because DevTools emulation does not always propagate to browser chrome.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| An icon does not change colour | It is loaded as <img> or has a hard-coded fill |
Inline it, or use a CSS mask with background: currentColor |
| One shape in a diagram stays light | Its colour is not in the remap table | Run the palette audit; replace the off-palette value at the source |
| The screenshot is light while the interface is dark | <picture> reads the OS preference, not your toggle |
Use the script swap for that asset, or accept the mismatch |
| The favicon never changes | The media attribute is unsupported, or the .ico is winning |
Test in a real browser; order the links so the SVG variants precede the fallback |
| Diagram labels are unreadable after remapping | The SVG has no background rect, so the page surface shows through | Add a full-canvas rect that participates in the remap |
Deciding Which Assets Need a Dark Variant at All Link to this section
Not every image benefits from a second version, and producing variants indiscriminately is how an asset pipeline doubles in size for no perceptible gain. Three questions settle it.
Does the asset have a background? A photograph or an illustration that fills its own frame edge to edge sits on its own surface and needs nothing — it reads the same in both themes because the page behind it is never visible. A logo on a transparent background is the opposite case and almost always needs work.
Does it contain text or fine lines? A screenshot of an interface is mostly text on a light surface. Placed in a dark page it becomes a bright rectangle, which is uncomfortable rather than broken. Whether that justifies a second capture depends on how large it is and how central: a full-width hero screenshot, yes; a small inline figure inside a light card, no.
Does the colour carry meaning? A chart whose series colours were chosen for contrast against white will lose some of that contrast against a dark surface. If the chart is decorative, leave it. If a reader has to distinguish the series, it needs a dark palette — which is a data visualisation problem rather than an asset problem, and is better solved by rendering the chart from data at runtime than by shipping two images.
Applying those three questions to an existing library typically reduces the list of assets needing work by three quarters, and concentrates the effort on the handful where the difference is actually visible to a reader.
Migration Note Link to this section
Auditing an existing site’s assets is a one-off job with a predictable shape. Icons are usually the largest count and the easiest fix — converting a sprite to currentColor is mechanical and often removes half the problem in a single change.
Diagrams are the slowest, because each needs its colours normalised onto the palette before the remap can apply. Doing that normalisation is worth it regardless of dark mode: it is what makes a set of figures look like one system rather than a collection of individually drawn pictures.
Raster assets are worth triaging rather than converting wholesale. A screenshot embedded in documentation, on a white card, in a dark page reads perfectly well as long as the card stays light — deliberately keeping certain surfaces light in dark mode is a legitimate design decision and much cheaper than maintaining two captures of every screen.
Related Link to this section
- prefers-color-scheme Integration — the parent area covering the signal these assets respond to
- Advanced Theming & Dark Mode Implementation — the token architecture that icons inherit from
- Supporting Windows High Contrast with forced-colors — the third rendering mode these assets also have to survive