Skip to content

Theming

An nf-metro SVG has no separate light and dark version, and nf-metro never re-renders it when the viewer’s theme changes. Every themeable color is baked into the file as a pair, and native CSS picks the right half at display time:

Diagram: light-dark(#fff, #1a1a1a) resolves to #fff under a light color-scheme and to #1a1a1a under a dark one

light-dark() takes two color values and resolves to one of them. It works on any color property, which lets it drop straight into SVG presentation attributes and <style> rules:

fill: light-dark(#ffffff, #1a1a1a);

It resolves against the element’s used color-scheme. If nothing declares one anywhere, it still resolves, but against the browser’s own default, which is generally light rather than the viewer’s OS preference. Declare one to let that preference in:

:root {
color-scheme: light dark;
}

light dark means “both are supported here, so pick whichever matches the current preference”. Pinning one side instead (color-scheme: dark) forces every light-dark() under it to that value regardless of preference. That is the deterministic escape hatch for a case such as a PNG export.

fill: var(--my-bg, light-dark(#ffffff, #1a1a1a));

A page embedding the SVG (the host page) can set --my-bg on a wrapper to override the color outright. If it doesn’t, the light/dark pair still runs. This layer is optional, because light-dark() plus color-scheme is the whole mechanism. nf-metro calls the properties it exposes this way chrome: background, title, labels, section boxes, and legend. Line and route colors carry meaning, and they stay fixed.

color-scheme is inherited. An element with no color-scheme of its own takes its ancestor’s computed value, the same as font-family or color normally would. Redeclaring it resets what “current preference” means for everything below. An element that sets its own color-scheme therefore stops inheriting and starts from scratch. That matters once the SVG is embedded, because the host page and the SVG can disagree about who owns the decision. nf-metro has two modes, controlled by self_color_scheme on render_svg (--no-self-color-scheme on the CLI):

Standalone (the default). The root <svg> declares its own color-scheme (src/nf_metro/render/svg.py:2595-2597):

<svg style="color-scheme: light dark"></svg>

light-dark() then resolves against the viewer’s OS or browser preference. That is the right behavior for a file opened on its own, or dropped into a page that has no opinion about theme.

Embedded in a page that already manages theme. Passing --no-self-color-scheme omits that declaration, and the <svg> inherits color-scheme from its host instead. Every <Metro> embed on this docs site uses it (website/src/lib/render-metro.mjs:277). This site’s theme picker sets color-scheme on <html>:

/* reset.css, from Starlight - the framework this docs site is built with */
html {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
}

This map has no color-scheme of its own, and it follows the picker. Switch the theme in the nav to see it change, independently of your OS setting:

Simple Pipeline Input Trimming Alignment FastQC Quantification MultiQC Main Quality Control created with nf-metro v2.0.0+dev

The two modes are not interchangeable. An embedded SVG that declares its own color-scheme: light dark ignores a host’s forced single value and falls back to the OS preference whatever the host’s toggle says. Redeclaring light dark reopens both options, which reverts the “current preference” it resolves against to the browser or OS signal. --no-self-color-scheme exists for that reason. Leaving the property off the SVG root is what lets a host’s manual toggle reach it at all.

The flag only decides whose preference wins. Something still has to supply a light-dark() pair to resolve in the first place. That is the separate job of the chrome CSS from the var() ingredient described earlier. Both have to be right for an embedded map to stay theme-adaptive.

A transparent-background theme has no --nfm-bg pair to lean on. Text drawn straight onto the canvas, such as titles and section labels, can therefore turn illegible against the host page’s background. For that one case nf-metro adds a second, coarser path built on prefers-color-scheme. That media query branches a whole stylesheet rule, rather than a property like color-scheme that light-dark() resolves per value (src/nf_metro/render/svg.py, _inject_dark_mode_style):

@media (prefers-color-scheme: dark) {
.nf-metro-section-label {
fill: #d0d0d0;
}
.nf-metro-title {
fill: #ffffff;
}
}

--no-dark-mode-css turns it off, for a host whose own theming it would otherwise fight.

Consumers that cannot parse light-dark() or var()

Section titled “Consumers that cannot parse light-dark() or var()”

Some SVG consumers, cairosvg among them, parse a restricted CSS subset and abort outright on light-dark() or var(). The nf-metro export flags map directly onto the ingredients described earlier:

Terminal window
# bake one concrete palette; no var()/light-dark() left to choke on
nf-metro render pipeline.mmd -o pipeline.svg --no-chrome-css --mode light
# pin color-scheme for a consumer that DOES understand light-dark(),
# so the result is deterministic rather than following its own environment
nf-metro render pipeline.mmd -o pipeline.svg --mode dark

Full flag reference: Embedding.

Inline means the host page contains the SVG markup directly, rather than referencing it by URL:

<!-- referenced: a separate document -->
<img src="map.svg" />
<!-- inline: part of this document -->
<svg>
<rect fill="light-dark(#fff, #1a1a1a)" ... />
</svg>

<img> loads the SVG into its own document, and ordinary CSS stops at that boundary. Custom properties, cascade, and inheritance do not cross it, and var() overrides never arrive. color-scheme is the one deliberate exception. Per Media Queries Level 5, an <img>-loaded SVG resolves prefers-color-scheme and light-dark() against the used color-scheme of the <img> element rather than the OS preference. That overrides even the SVG’s own color-scheme: light dark. Chromium and Firefox honor this deterministically, independently of the OS preference.

Safari does not. It ignores the declared color-scheme on the <img> entirely and follows the real system preference instead, as if no override were present.

<object> and <embed> do not get this either, because they open a full nested browsing context, which the spec excludes. Do not rely on either.

For var() overrides you have to inline the SVG either way, because nothing in the <img> exception carries them. That is why the nf-metro docs site inlines its maps.

None of this is specific to nf-metro. A standalone example:

<svg viewBox="0 0 200 100" style="color-scheme: light dark">
<style>
rect {
fill: light-dark(#ffffff, #1a1a1a);
}
text {
fill: light-dark(#111111, #eeeeee);
}
</style>
<rect width="200" height="100" />
<text x="10" y="50">Adapts to the viewer's theme</text>
</svg>

Inline it in an HTML page and it follows the OS preference. To make it follow a manual toggle on your own site instead, drop the SVG’s own style="color-scheme: light dark" and drive color-scheme from your toggle at the page level, the same way the Starlight reset.css does:

:root {
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
// wherever your toggle's click handler lives
document.documentElement.dataset.theme = "light";

Any inlined SVG with no color-scheme of its own now inherits from :root and follows the toggle rather than the OS setting.