Frontend is finally moving toward vanillaization and that is the best thing that has happened to frontend since Wes Bos published his first courses. Native browser support for themes works absolutely fine and that can be enough for many projects, but I just wanted to have fun. I didn't want just one color scheme with light and dark variants. I wanted two, each supporting light and dark. Four variants in total. And the key is using native browser features. And it worked.
The Requirements:
- Light and dark mode, handled by the browser natively
- A second "spring" (or whatever) color scheme with a completely different palette
- A toggle button to switch between default and spring
- No flash of wrong theme on reload
- No page reload needed to switch
The Specificity Hack:
:root:root:root
If you use styled-components, you've probably run into specificity wars where your global CSS variables get overridden by styled-components' injected styles. The fix is delightfully dumb, just repeat :root three times. Yes, I know, styled-components is outdated, so my future desire would be to get rid of it, but for now it is not the main topic of the conversation :)
:root:root:root {
--color-background: light-dark(oklch(0.99 0.0105 320.98), oklch(13.709% 0.02553 268.319));
--color-primary: light-dark(oklch(70.61% 0.085 271.69), oklch(0.79 0.1233 266.14));
/* ...etc */
}
The browser picks the right value automatically based on the user's system preference. No JavaScript involved. You do still need color-scheme declared for this to work:
// index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="color-scheme" content="light dark" />
<title>Title</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Important thing. For browsers that don't support light-dark() yet, keep @media (prefers-color-scheme) fallback blocks below. The cascade handles it gracefully.
But you probably notice that I just mentioned 2 themes of 1 color-scheme. And the whole approach above is something that was highly used before. But let's add another one. And it's gonna be tricky (not really).
The Spring Theme: One CSS File, A Class on HTML Tag
For the second theme, I wanted to avoid dynamic CSS imports, because they're async, unreliable in production builds with Vite, and add complexity. I tried it, didn't work well. Instead, decided that both themes should be in one CSS file.
The spring theme overrides default variables using a .spring class on the element. The key insight is specificity: .spring:root:root:root beats :root:root:root because of the extra class:
:root:root:root {
--color-background: light-dark(oklch(0.99 0.0105 320.98), oklch(0.21 0.037 271.06));
--color-primary: light-dark(oklch(70.61% 0.085 271.69), oklch(0.79 0.1233 266.14));
}
/* this one wins when .spring is on <html> */
.spring:root:root:root {
--color-background: light-dark(oklch(0.99 0.012 150), oklch(0.18 0.05 145));
--color-primary: light-dark(oklch(56.316% 0.10067 150.907), oklch(0.72 0.2 145));
}
Light and dark still work automatically inside both themes and light-dark() keeps doing its job regardless of which theme is active.
The Toggle
classList.toggle() flips the class and returns the new boolean state. That's the entire toggle logic. No React state (or whatever you use), no context, no re-renders. The CSS reacts instantly because the class change is reflected immediately in the DOM.
// UI component
function handleToggle() {
const isSpring = document.documentElement.classList.toggle("spring");
localStorage.setItem("theme", isSpring ? "spring" : "default");
}
Applying the Right Theme on Load
This runs synchronously before the first paint. No flash, no layout shift, no useEffect timing issues.
// main.tsx
import "./themes.css";
const savedTheme = localStorage.getItem("theme");
document.documentElement.classList.toggle("spring", savedTheme === "spring");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
What I Tried That Didn't Work
Before landing on this decision, I experimented with @container style() queries β a newer CSS feature that lets you apply styles based on the value of a custom property on a parent:
@container style(--theme: spring) {
body {
--color-background: ...;
}
}
And it worked! But in Chrome. Not even in Firefox Developer Edition. Don't even talk about Safari. So I dropped it. But it is worth keeping an eye on for the future. It would have been a much more elegant approach.
The Result
- One CSS file with all four theme variants (light default, dark default, light spring, dark spring)
- Zero JavaScript for color values, all colors live in CSS
- No React context or state for theming
- No page reload on toggle
- No flash of wrong theme on load
- Native browser dark mode support throughout
Top comments (0)