Two Color Schemes, Four Modes: Native CSS Theme Switching.
Source: Dev.to
The Idea
Frontend is finally moving toward vanilla‑ization, and that is the best thing that has happened to the front‑end since Wes Bos published his first courses. Native browser support for themes works absolutely fine and can be enough for many projects, but I just wanted to have fun. I didn’t want just one colour scheme with light and dark variants. I wanted two schemes, each supporting light and dark – four variants in total – and I wanted to achieve this using only native browser features. And it worked.
The Requirements
- Light and dark mode, handled by the browser natively
- A second “spring” (or whatever) colour scheme with a completely different palette
- A toggle button to switch between default and spring
- No flash of the 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.
Note: styled‑components is outdated; my future desire is to get rid of it, but it isn’t the main topic of this post.
: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 needed. You still need color-scheme declared for this to work:
<!-- Example: declare color-scheme in HTML -->
<html color-scheme="light dark">
…
</html>
Important: For browsers that don’t support light-dark() yet, keep a @media (prefers-color-scheme: …) fallback block below. The cascade handles it gracefully.
The Spring Theme – One CSS File, a Class on <html>
For the second theme I wanted to avoid dynamic CSS imports (they’re async, unreliable in production builds with Vite, and add complexity). Instead, both themes live in a single CSS file.
The spring theme overrides the default variables using a .spring class on the <html> element. The key insight is specificity: .spring:root:root:root beats :root:root:root because of the extra class.
/* default theme */
: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)
);
}
/* spring theme – 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, 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
Run this synchronously before the first paint to avoid any flash, layout shift, or 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(
<App />
);
What I Tried That Didn’t Work
Before landing on this solution 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: …;
}
}
It worked in Chrome, but not in Firefox Developer Edition, and Safari didn’t support it at all. So I dropped it, but it’s 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 colour values – all colours live in CSS
- No React context or state for theming
- No page reload on toggle
- No flash of the wrong theme on load
- Native browser handling of light/dark preferences