We rolled out adaptive light-dark() support on our design system themes and it’s been a delightful upgrade. Creating light and dark variable sets isn’t difficult, but delivery has trade-offs. Most apps that do this probably ship both sets of token values in a single stylesheet. That’s fine until you have multiple kilobytes of duplicate definitions. To get around the performance problems we built two separate stylesheets –which is also not great– but my coworker Zacky found a good trick with <link disabled> to make it tolerable. Ultimately, we wanted to offer a single stylesheet for our human (and agent) friends to control theming.
Having light-dark() makes it trivial to support dual color modes in a single stylesheet and doesn’t add too much weight (0.5kb gzip for ~500 variables). It also gives you the ability to switch themes mid-page.
:root {
color-scheme: light dark;
--bg-color: light-dark(white, black);
--text-color: light-dark(black: white);
/* ...etc */
}
/* Add hard-coded theme overrides */
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
Adding [data-theme="dark"] to the body, or any element with color tokens defined, will force that section to be dark mode. The difference here between hanging variable definitions off a class is that you can respect the user preference without using JS to toggle, light-dark() does all the work.
Adding dark tier on a light theme is dramatic. Adding light tier on a dark theme sure would stand out. But when yielding light and dark modes over to the browser… something changes. In that situation hardcoding a theme mode into HTML loses a bit of meaning because I’m not controlling the theme anymore, the user is. What I actually want is the theme to be “opposite” the current theme. After a handful of attempts I think I came across a solution that’s easy to understand, maintain, and I even wrote a polyfill for you.
Automatic inversion with [data-theme=“inverted]
See the Pen [data-theme="inverted"] by Dave Rupert (@davatron5000) on CodePen.
The goal was to make it so that when the browser switched from light/dark modes, themed elements would switch to their inverse theme. The trick I uncovered was setting the current theme as a CSS variable:
:root {
color-scheme: light dark;
/* Initialize the theme variables */
--theme: light;
@media (prefers-color-scheme: dark) {
--theme: dark;
}
}
/* Have data-theme use the variable */
[data-theme] { color-scheme: var(--theme); }
/* Update hard-coded themes to use the variable */
[data-theme="light"] { --theme: light; }
[data-theme="dark"] { --theme: dark; }
The reason we use manage --theme variable instead of purely color-scheme is because we can pass that into a style() query. An that’s the magic that enables inverted themes:
/* Add style query magic ✨ */
@container style(--theme: light) {
[data-theme="inverted"] {
--theme: dark;
}
}
@container style(--theme: dark) {
[data-theme="inverted"] {
--theme: light;
}
}
Again, the nice thing here is that we’re not creating two different trees of variables or re-redefining variables inside [data-theme="inverted"], we’re relying on light-dark() and color-scheme to do the heavy lifting. We’re contextually updating the color-scheme and letting the browser negotiate the cascade.
I added a .funky card theme to the demo to give an idea of how far you might be able to push this tech. Roman Kamorov noted in his post on Querying the Color Scheme something Vadim Makeev said on a Russian podcast that while this inversion trick is neat, people who prefer dark-mode (e.g. for medical reasons) probably want dark mode and not to be flash-banged mid-page. That’s something to think about and I think I have some ideas about that but I’d love to see/hear yours.
Polyfilling browser support
At the time of writing this trick only works in Safari 18+ and Edge/Chrome 111+. Firefox is the outlier but the good news is container style queries is on the roadmap for Interop 2026 and behind a flag in nightly. That gives you two options:
- Roll out a bespoke polyfill for
[data-theme="inverted] - Do nothing. Wait it out.
Given that I know the experience will auto-upgrade for Firefox users at some point this year, I’m prone to wait it out per the rules of progressive enhancement. If it falls back to the current theme, I don’t think the world falls over.
However, you probably support browsers outside the latest version and what’s acceptable for a fallback depends on your company’s understanding of the eventual consistency of browsers. iOS devices version-locked at Safari 17.4 are of particular concern for me, so I wrote an inverted theme polyfill.
The CSS style-query-support detection is pretty simple, but quirky.
/* Style query support check */
body {
--syle-query-support: 0;
}
@container style(--theme) {
body { --syle-query-support: 1 }
}
There’s a new @supports at-rule() function that would be more idiomatic, but you can’t detect at-rule function support with at-rule() so we have to do this variable hack. Ideally we could do this purely in JS with CSS.supports(), but alas. Booleans in CSS, what could go wrong?
That brings us to the JavaScript part which is pretty simple as well but comes with one big potential tradeoff…
/**
* Polyfill for inverted themes using a `--theme` variable in a style query
* ⚠️ The use of `getComputedStyle` can trigger layout and style recalcs
*/
(()=>{
const isContainerStyleQueriesSupported = () => {
const bodyElStyle = getComputedStyle(document.body);
const hasStyleQueries = bodyElStyle.getPropertyValue("--syle-query-support");
if(hasStyleQueries === "1") {
return true;
}
return false;
}
if(!isContainerStyleQueriesSupported()) {
const invertedThemes = document.querySelectorAll('[data-theme="inverted"]');
invertedThemes.forEach((el) => {
const elTheme = getComputedStyle(el).getPropertyValue("--theme");
const invertedTheme = elTheme == "light" ? "dark" : "light";
el.dataset.theme = invertedTheme;
})
}
})();
Because we need to get the computed value of --style-query-support, getComputedStyle is known to trigger style recalcs and layout reflows, effectively penalizing all users not just browsers that don’t support container style queries. I tested this out by putting the polyfill inside a setTimeout, turning on paint flashing, and checking the Performance Monitor panel and I didn’t see any recalcs or layout reflows, but your mileage may vary depending on your setup.
Anyways, happy inverting! Let me know if you do something cool with it or if you already figured this out 10 months ago and I didn’t see your blog post.