Skip to content
EquivalentLogo
One file answering four different conditions A single file at the left connected to four screens: a light one, a dark one, a narrow one and a high-contrast one. Each screen shows the same mark rendered for its own condition. logo.svg one file light dark narrow contrast ONE SOURCE Four conditions. The file decides, not the page.

One file for light mode, dark mode, small screens and high contrast

September 8, 2026 · 9 min read

Most teams ship a separate export for every condition, then wire the page to choose between them. A SmartSVG™ carries the conditions itself.

A designer hands over a logo. It looks right. Three weeks later somebody turns on dark mode and the logo disappears into the background, because it was drawn in near-black on the assumption that the page behind it would be white.

The fix takes ten minutes. Export a second file with the marks inverted, add a line of markup that picks between them, ship it. Nobody would call that a bad decision. It is the correct decision, and it is also the first of four.

The problem is not dark mode, it is the second file

One extra export is not a burden. What makes it a burden is that the reasoning generalizes. If the page can pick a file for color scheme, it can pick one for screen width, and it can pick one for readers who have asked their operating system for high contrast. Each of those is the same ten-minute decision, made months apart by different people, and each one lands as another file in the same folder.

By the time the set is complete you are maintaining four drawings of one mark. They were exported from the same source on four different days. They are not identical, because somebody nudged the source between exports and only re-exported two of them. And there is no test that will tell you which two.

This is the pain this post is about. Not file size, not performance. The cost of keeping several drawings of one thing in agreement with each other, forever, by hand.

What the page ends up carrying

Here is the markup a team writes once they have the second file. It is a reasonable, well-known pattern, and there is nothing wrong with it in isolation:

<picture> <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" /> <img src="/logo-light.svg" alt="Equivalent" width="180" height="40" /> </picture>

Now add the narrow-screen lockup, because the full horizontal mark does not fit on a phone. And the high-contrast version. The markup grows a row per condition, and every row is a place where a path can be wrong:

<picture> <source srcset="/logo-contrast.svg" media="(forced-colors: active)" /> <source srcset="/logo-dark-small.svg" media="(prefers-color-scheme: dark) and (max-width: 480px)" /> <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" /> <source srcset="/logo-small.svg" media="(max-width: 480px)" /> <img src="/logo-light.svg" alt="Equivalent" width="180" height="40" /> </picture>

Look at what this block actually is. It is a copy of your design rules, written in markup, living in a template, maintained by whoever last touched that template. The rules themselves are a design decision. They are now stored in a place where designers cannot see them and where nothing checks them.

And this is one usage. The same five lines have to be repeated in the email template, the partner kit and the slide master, or the logo is wrong in those places instead.

The same rules, written inside the file

An SVG is not a picture in the way a PNG is a picture. It is a document. It can contain a stylesheet, and that stylesheet can contain media queries, which the browser evaluates while it renders. That is not a trick and it is not new: it is how SVG has worked for a long time. Most exports simply never use it.

Written that way, the conditions live with the drawing. The palette becomes a set of custom properties at the top of the file, and each condition changes their values rather than replacing the whole drawing:

<svg role="img" viewBox="0 0 180 40" xmlns="http://www.w3.org/2000/svg"> <title>Equivalent</title> <style> :root, svg { --mark: #14171a; --accent: #0066cc; } @media (prefers-color-scheme: dark) { svg { --mark: #f2f5f8; --accent: #00ccff; } } @media (forced-colors: active) { svg { --mark: CanvasText; --accent: LinkText; } } .wordmark { fill: var(--mark); } .glyph { fill: var(--accent); } @media (max-width: 480px) { .wordmark { display: none; } } </style> <path class="glyph" d="..." /> <path class="wordmark" d="..." /> </svg>

Four conditions, one file, and the markup on the page goes back to what it was before any of this started:

<img src="/logo.svg" alt="Equivalent" width="180" height="40" />

That is the whole idea. The conditions did not disappear. They moved from the page, where each team re-implements them, into the file, where they are written once and travel with the artwork.

Why this is the place designers and developers finally agree

Look again at the top of that file. The palette is a list of named values. That is the same shape as a design token, because it is a design token: a name with a value, and a rule for when the value changes.

This matters more than it looks. In most handoffs the design system exists twice. Once in the design tool, where the designer maintains it, and once in the codebase, where a developer transcribed it. The two drift, and the drift is invisible until somebody notices the wrong blue in production.

When the graphic reads its colors from custom properties, the same file can either carry its own values or inherit the page tokens it is placed into. Inlined into a page that already defines its palette, the graphic simply uses it:

/* the page already owns these */ :root { --brand-ink: #14171a; --brand-accent: #0066cc; } /* the inlined graphic reads them, with its own value as the fallback */ svg { --mark: var(--brand-ink, #14171a); }

The designer changes a token. The developer changes nothing. The graphic follows, in every mode, because every mode was reading the token already. That is what people mean when they say handoff should not be a translation step, and it is unusually literal here: the artwork is speaking the same language as the stylesheet.

What the browser is actually doing

It is worth being precise about the mechanism, because "the file adapts" sounds like magic and it is not. There is no script and nothing is watching for changes.

A media query is a condition the browser evaluates continuously as part of rendering. When the reader switches their operating system to dark mode, the browser re-evaluates every prefers-color-scheme query on the page, including the ones inside an SVG, and repaints what changed. The same machinery that has restyled web pages for a decade restyles the artwork.

This is why the approach is durable rather than clever. It relies on the oldest, most thoroughly implemented part of the platform. There is no build step that can break, no dependency that can go unmaintained, and no runtime that has to load before the graphic is correct.

Two ways to place the file, and the difference between them

How you put the graphic on the page decides one specific thing: whether it can see your page CSS.

<!-- Referenced. The file is a separate document with its own CSS scope. --> <img src="/logo.svg" alt="Equivalent" /> <!-- Inlined. The file is part of this document and inherits from it. --> <svg role="img" viewBox="0 0 180 40"> ... </svg>

Referenced, the graphic still answers light, dark, narrow and high contrast, because those rules are inside it. What it cannot do is read a custom property you defined on the page, because it is a separate document with its own scope. Inlined, it can.

Neither is the right answer in general. A marketing page that wants its illustrations to follow its theme tokens should inline them. A logo used in fifty places, half of which are outside your codebase, should be a referenced file that carries its own values. Most teams end up doing both, and the useful thing is knowing which trade-off you picked rather than discovering it later.

A worked example: an icon that has to survive four conditions

Take a status icon, the kind that appears next to a row in a table. It has a colored dot, a label mark and a plate behind it. Here is the whole thing, and it is short enough to read in one go:

<svg role="img" viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg" aria-labelledby="okTitle"> <title id="okTitle">Operational</title> <style> svg { --plate: var(--surface-card, #ffffff); --ring: var(--border-divider, #c9d2dc); --dot: var(--status-ok, #0a7d3c); } @media (prefers-color-scheme: dark) { svg { --plate: var(--surface-card, #161b21); --ring: var(--border-divider, #2f3944); --dot: var(--status-ok, #35d07f); } } @media (forced-colors: active) { svg { --plate: Canvas; --ring: CanvasText; --dot: CanvasText; } } @media (prefers-reduced-motion: no-preference) { .dot { animation: pulse 2s ease-in-out infinite; } @keyframes pulse { 50% { opacity: .55 } } } </style> <circle cx="12" cy="12" r="11" fill="var(--plate)" stroke="var(--ring)" /> <circle class="dot" cx="12" cy="12" r="5" fill="var(--dot)" /> </svg>

Four things are happening there and each is worth naming. The palette defers to your tokens and falls back to its own values when there are none. Dark mode swaps the values rather than the drawing. Forced colors hands the palette to the reader entirely. And the motion is written so that it only exists for readers who have not asked for less of it.

That last one is the detail teams most often get backwards. Writing the animation and then disabling it under prefers-reduced-motion: reduce works, but it makes motion the default and the accommodation the exception. Writing it under no-preference inverts that: the still version is the baseline, and motion is added only where it is welcome.

What this replaces in your build

Teams solve this problem today with tooling, and it is worth being clear about what goes away.

  • A sprite build step that concatenates icons and rewrites their ids to avoid collisions.
  • A React or Vue wrapper component per icon, generated at build time, existing mainly to pass a color prop through.
  • A theme prop threaded down through the component tree so an icon deep in a table can know what mode the page is in.
  • A second set of exports, and the script that copies them into place.

None of that tooling is bad engineering. It exists because the artwork could not answer the question itself, so the application had to answer it on the artwork behalf. When the file answers it, the machinery around it has nothing left to do.

The component, if you still want one, collapses to the thing it should have been:

// Before: the component has to know about theme. function StatusIcon({ theme }) { return <img src={theme === 'dark' ? okDark : okLight} alt="Operational" /> } // After: it does not. function StatusIcon() { return <img src={ok} alt="Operational" /> }

The handoff, concretely

The phrase "designers design with tokens and developers build with code" gets said a lot and usually means nothing. Here it means something specific and checkable.

A designer names a color in the design tool. That name is exported as a custom property. The stylesheet declares it. The artwork reads it. There is one definition and three consumers, and none of the three is a person retyping a hex value from a screenshot.

/* design tokens, exported from the design tool */ :root { --color-brand-default: #0066cc; --color-supporting-default: #00ccff; } /* the semantic layer the product actually uses */ :root { --surface-button-primary: var(--color-brand-default); } @media (prefers-color-scheme: dark) { :root { --surface-button-primary: var(--color-supporting-default); } } /* the artwork, reading the same name as the buttons */ svg .mark { fill: var(--surface-button-primary, #0066cc); }

The graphic and the primary button are now the same color for the same reason, in both modes, and they will still be after the next brand change. That is a small thing that is very hard to achieve any other way, because normally the button reads a token and the graphic reads a baked-in fill that somebody matched by eye.

What this does not solve

Being honest about the edges is more useful than a longer list of benefits, so here are the real ones.

  • An SVG referenced with an img src cannot read the CSS custom properties of the page around it. Its own media queries still work, so light, dark, narrow and high contrast all behave, but inheriting your page tokens requires inlining the file.
  • This applies to vector artwork. Icons, logos, diagrams, illustrations, interface graphics and previously vector-based art that can be reconstructed. Not photographs, and not every image.
  • A browser that does not understand one of the media queries ignores that block and renders the base state, which is the light drawing. That is a graceful floor, not a failure, but it does mean the oldest browsers see the artwork you would have shipped anyway.
  • It does not make a page accessible on its own. It removes one specific and common failure, which is a graphic that arrives with no name and no high-contrast behavior.

None of that is a reason to keep four files. It is a reason to know which problem you are actually solving before you change how you ship graphics.

Try it on something real

The interesting test is not a simple logo. It is the file you are slightly embarrassed by: the intricate illustration, the icon set nobody has touched in three years, the diagram with more layers than it needs. Those are the ones that tell you whether this holds up.

Start on the free account, put one of those through, and tell us what happened. Every report goes on our public board, and the awkward files are the ones we learn the most from.

Post Details

Published

September 8, 2026 · 9 min read

The conditions move inside the file.

A SmartSVG™ is a standard SVG with the decisions written into it. The palette is declared as custom properties, and a prefers-color-scheme block swaps their values. A max-width block changes the layout on a narrow screen. A forced-colors block steps out of the way when the reader has their own palette.

None of that is a runtime or a library. It is CSS inside the file, which every browser that renders SVG already supports. If our service disappeared tomorrow, the file keeps working.

This is what the beta is for. Join us and bring the artwork you think will break it.

Four exported files against one adaptive file Two columns. On the left, the usual approach: four separate exports, one for light, dark, narrow and high contrast, with the page choosing between them. On the right, one file that carries all four conditions and decides for itself. THE USUAL WAY logo-light.svg logo-dark.svg logo-mobile.svg logo-contrast.svg The page picks. Miss one and it shows. ONE FILE logo.svg prefers-color-scheme: dark max-width: 480px forced-colors: active role="img" + title The file decides. Nothing to pick. The conditions move from the page into the file. Same standard SVG either way. Any browser already renders it.

Questions about adaptive files

No. The adaptation is CSS inside the SVG, which the browser applies while it renders. There is no script, no runtime and nothing to load.

It renders the base state, which is the light-mode drawing. An unsupported block is ignored rather than treated as an error, so the graphic degrades to what you would have shipped anyway.

The color and layout blocks do, because they are inside the file. What an external file cannot read is the CSS custom properties of the page around it, so a graphic that should follow the host page tokens needs to be inlined. Both are supported, and the trade-off is worth knowing before you choose.

Yes. It opens in any editor, renders in any browser, and can be edited by hand. Nothing about it depends on us.