Let your site color the SVG: tokens, variables and inline graphics
· 10 min read
An illustration that ignores your dark mode has its colors baked in. Move them into your CSS, put the graphic inline, and it follows your theme like everything else.
Your site has a dark mode. The buttons follow it, the cards follow it, the headings follow it. Then there is the illustration in the middle of the page, still drawn in the light palette, sitting on a dark background like a sticker somebody forgot to peel off.
Nothing is broken, exactly. The graphic is doing what it was told. Its colors were written into the file when it was exported, and nothing your site does can reach them. This post is about moving those colors out of the file and into the place the rest of your colors already live: your CSS.
Tokens and variables, briefly
A design token is a named design decision. "Brand blue" is a token. "The color of body text" is a token. The point of the name is that the decision can change without every place that uses it changing too.
A CSS custom property, usually called a CSS variable, is how a browser carries a token. You define it once, usually on the root of the page, and read it anywhere with var():
:root {
--brand: #0066cc;
--accent: #00ccff;
--ink: #171717;
}
.button { background: var(--brand); }
.heading { color: var(--ink); }
Dark mode then becomes a second set of values for the same names. The button does not change. The token does, and everything that reads it follows. That is the whole trick, and an SVG can take part in it like any other element on the page, as long as two things are true: the colors are not fixed inside the file, and the file is part of the page.
Where an SVG keeps its colors
A typical export sets its colors in one or more of three places. Each one is a fixed value, and each one behaves differently when you try to override it:
<svg viewBox="0 0 64 64">
<style>.a { fill: #0066cc; }</style>
<path class="a" d="..." />
<path style="fill: #00ccff" d="..." />
<path fill="#171717" d="..." />
</svg>
- A style block inside the file. Its rules apply wherever the file goes. Inline in a page, they also join the page's stylesheet, so a class name like .a can collide with yours.
- A style attribute on an element. Inline styles beat every ordinary rule in your stylesheet, so your tokens lose to it unless you reach for !important.
- A fill or stroke attribute. These are presentation attributes, and they have the lowest priority of all. Any CSS rule that targets the element wins, which makes them the easiest to override and the easiest to forget are there.
Design tools tend to use all three in one file, sometimes on the same shape. Before a graphic can take your colors, every one of them has to go.
Taking the colors out
The goal is a file that says what each shape is and nothing about what color it should be. Give each shape a class that names its role, and remove the fixed values:
<svg viewBox="0 0 64 64">
<path class="brand" d="..." />
<path class="accent" d="..." />
<path class="ink" d="..." />
</svg>
Then the page decides, using the tokens it already has:
.brand { fill: var(--brand, #0066cc); }
.accent { fill: var(--accent, #00ccff); }
.ink { fill: var(--ink, #171717); }
The second value in each var() is a fallback. It is used only when the token is not defined, and it matters more than it looks. A fill that reads an undefined variable with no fallback does not quietly keep its old color. It behaves as if no fill were set, so the shape inherits whatever is above it, and with nothing set above it that is black. One misspelled token name and a shape turns black with no error anywhere.
Use the names your site already uses
The variable names in the graphic have to match the names in your stylesheet exactly, so name them after your tokens rather than after the color they happen to be today. --brand survives a rebrand. --blue does not.
Watch for names that carry a mode in them, like --brand-light and --brand-dark. They look tidy, but they move the light and dark decision back into the graphic: the file now has to know which mode it is in to pick a name, and your site's toggle cannot help it. One name per role, with the page supplying a different value per mode, keeps that decision where it belongs.
Smaller files are a side effect
Every color you remove is markup the file no longer carries, and a stylesheet that colors twenty graphics is written once instead of twenty times. The saving per file is usually modest. It adds up across an icon set, and the larger win is that the palette now has one home.
Following the toggle and the operating system
Most sites that offer a theme switch want two behaviors at once. With no choice made, follow the operating system. Once the reader uses the switch, follow the switch, even if it disagrees with the operating system. That fits in three blocks of CSS, and the token names never change between them:
/* 1. Light, the default */
:root {
--brand: #0066cc;
--ink: #171717;
}
/* 2. Dark operating system, unless the switch says light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--brand: #00ccff;
--ink: #f7f7f7;
}
}
/* 3. The switch says dark */
:root[data-theme="dark"] {
--brand: #00ccff;
--ink: #f7f7f7;
}
The switch sets data-theme on the root element, and removes it to go back to following the operating system. The attribute name is yours to choose; data-theme, data-color-mode and a class on the html element are all common. What matters is that the graphic never sees any of this. It reads var(--brand), and the cascade has already worked out which value that is.
Two details are worth getting right. Keep a value for every token in every block, or a token defined only for dark mode will be undefined in light mode and fall back to black. And set the attribute before the page first paints, with a small script in the head, so a reader with a dark preference does not see a flash of the light palette first.
Why it has to be inline, not an <img>
This is the step that catches people out. The file is clean, the tokens are defined, and the graphic still renders in its fallback colors. Usually the reason is one line:
<img src="/illustrations/hero.svg" alt="..." />
An SVG loaded through an <img> is a separate document. The browser renders it in isolation, as a picture, and the page's styles stop at its edge. Your custom properties do not cross into it, your classes do not match inside it, and the attribute your switch sets on the page is not visible to it. The same is true of an SVG used as a CSS background-image. Only the file's own internal styles apply.
Inline SVG is different. When the markup sits in the page itself, every shape is an ordinary element of the page, and your stylesheet reaches it exactly as it reaches a button:
<div class="hero-art">
<svg viewBox="0 0 64 64" role="img" aria-labelledby="hero-title">
<title id="hero-title">A mountain at sunrise</title>
<path class="brand" d="..." />
<path class="accent" d="..." />
</svg>
</div>
You rarely have to paste markup by hand. Most frameworks can import an SVG file as a component at build time, and a server-side template can include the file's contents directly. Either way, what reaches the browser is inline markup, which is the part that matters.
When an <img> is the right call
An <img> is not wrong. It caches separately, it keeps large or complex artwork out of your HTML, and it is the only option in places that do not run your CSS at all, like email. A file used that way can still adapt, with its own prefers-color-scheme rules inside it, but it can only respond to what the browser reports to it. It will never follow your switch. Choose inline when the graphic should look like part of the interface, and an image when it should look the same everywhere.
Making sure it renders correctly
Inlining moves the SVG into your page, so a few things that never mattered for an image start to matter:
- Duplicate IDs. Gradients, clip paths and title elements are referenced by id. Inline the same graphic twice, or two graphics exported by the same tool, and the ids collide. The second reference silently points at the first definition. Give each inline copy unique ids, which many build tools can do for you.
- Untrusted files. An inline SVG can carry script and event handlers that an <img> never runs. Only inline files you produced or have sanitized.
- An accessible name. Give a meaningful graphic role="img" and a title referenced by aria-labelledby, so it is announced with a name. Give a decorative one aria-hidden="true" so it is skipped.
- Forced colors. In Windows high contrast and similar modes the browser replaces your palette with the reader's own. Check that shapes still read, and use system colors such as CanvasText in a forced-colors block where they do not.
Then test the combinations, not just the defaults:
- Light operating system, switch untouched: light palette.
- Dark operating system, switch untouched: dark palette.
- Dark operating system, switch set to light: light palette. This is the one that most often breaks.
- Light operating system, switch set to dark: dark palette.
- Inspect a shape in your browser's developer tools. Its fill should come from your stylesheet, not from an attribute or a style on the element.
- Open the file on its own. It should still render, in its fallback colors.
Where Smart SVG™ files fit
A Smart SVG™ keeps its palette as custom properties near the top of the file rather than as fixed values scattered through it, which is the starting point everything in this post builds on. On its own, as an image, the file uses its built-in values and its own media queries.
Whatever produces your graphics, the rule is the same. Colors belong to the page, the file names its shapes, and the graphic goes inline wherever it should follow your theme.
Related posts
Post Details
· 10 min read