DEV Community

Olga Urentseva
Olga Urentseva

Posted on

Two Color Schemes, Four Modes: Native CSS Theme Switching.

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
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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: ...;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)