DEV Community

Kirill Chernenko
Kirill Chernenko

Posted on

Dynamic theming without CSS-in-JS

I remember well when CSS-in-JS showed up — styled-components in particular.
And I remember myself: I was probably its biggest cheerleader at work. I'd promote it for free, running purely on how much I loved it. Trendy, unusual, POWERFUL and flexible — after our cramped little native CSS, after all the preprocessor hassle (webpack plugins, post-CSS, pre-CSS, sub-CSS, super-CSS — joke), after Bootstrap's grids and utility classes. Tailwind was there, and still is — but it and I never clicked.

Theming systems

That was a while ago. At some point I hit a wall: one project needed to drop runtime. So I said "later" to styled-components and, kind of out of desperation, gave CSS Modules a shot — which I used to dislike. And I was surprised: moving themes, variables, container queries and layers into native CSS was night and day versus what I remembered.

I was styled-components' loudest fan. Then I deleted it from every project I own.

So that's what I did. styled-components, gone. Then Emotion. Then goober. Two old projects moved to CSS Modules, new ones native by default — and for my use cases I lost nothing I needed. The real win wasn't even perf; it was dropping the styling dependency at all. In one npm package where I was fighting for every byte, the bundle analyzer showed ~30% of the client chunk was the CSS-in-JS runtime and our wiring around it.

Why now, and not before

One thing really changed: plain CSS grew up. Custom properties, nesting, @layer, container queries, color-mix() cover most of the reasons I used to reach for SCSS or a JS styling layer. CSS Modules used to be bare CSS with local class names — that's it — so for theming and dynamics you went to styled-components. Today that same modular CSS plus native features does a lot.

Who this is for: on Next.js, or you care about bundle and client JS — read on. On a healthy CSS-in-JS codebase that's fine — I'm not telling you to rewrite working code.

The core: tokens as CSS variables

The whole idea is one file. Design tokens as custom properties on :root; a theme is just a set of overrides under a selector.

/* tokens.css */
:root {
  color-scheme: light;
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --color-accent: #3c7dc4;
  --color-on-accent: #ffffff;  
  --radius: 8px;
}

[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: #14161a;
  --color-text: #f5f5f0;
  --color-accent: #6aa6e8;
  --color-on-accent: #0d0f12;
}
Enter fullscreen mode Exit fullscreen mode

Components don't know which theme is active. They just read the variable

/* Button.module.css */
.button {
  background: var(--color-accent);
  color: var(--color-on-accent);
  border-radius: var(--radius);
}
Enter fullscreen mode Exit fullscreen mode

Tip: name tokens by role (--color-on-accent, --surface, --text), not by color — then a theme swaps the meanings of colors, and you dodge bugs like "the page background accidentally became the button's text color."

No ThemeProvider, no theme object through props, no global re-render. The cascade does the work: change a variable, and every var() that reads it updates on its own. And theming via data-theme doesn't depend on CSS Modules at all — I use Modules separately, just so class names don't collide.

Switching themes at runtime — without runtime CSS generation

The part I thought needed a JS library. It doesn't. Switching a theme is flipping one attribute:

function setTheme(theme) {
  document.documentElement.dataset.theme = theme;
  localStorage.setItem("theme", theme);
}
Enter fullscreen mode Exit fullscreen mode

And that's the whole "dynamic" part. No CSS rules are generated at runtime — the styles are already built into static CSS assets, not created during React render; the browser just recalculates the existing cascade. And since it's plain CSS plus an attribute tweak, almost the entire tree stays Server Components — only the small toggle itself (with its onClick) is a Client Component, not a ThemeProvider over the whole app.

Killing the flash on load

Stop here and you'll hit the classic dark-mode bug: the page paints in the default theme for a moment, then jumps to the saved one. The fix is to set the theme before the first paint — a tiny inline script in :

<script>
  const t =
    localStorage.getItem("theme") ||
    (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
  document.documentElement.dataset.theme = t;
</script>
Enter fullscreen mode Exit fullscreen mode

Bonus: system theme for free — no saved choice → fall back to prefers-color-scheme. For production, also wire up try/catch and validation for localStorage, suppressHydrationWarning on , a CSP nonce, and a matchMedia listener — active until the user pins a theme manually. But the heart of it — the early script — is right here.

"But don't I need SCSS?"

My real fear about leaving SCSS. The features I actually used have native answers (where it's not one-to-one, I'll flag it):

  • Nesting — native, no preprocessor.
  • darken() / lighten() → color-mix(). Not an exact swap (Sass tweaks lightness in HSL, color-mix() blends in a color space), but for "darken it a bit" it covers you from one token: :root { --color-accent: #3c7dc4; --color-accent-hover: color-mix(in srgb, var(--color-accent) 85%, black); }
  • Import order / cascade priority → @layer. Not "specificity cancelled" (inside a layer it works as usual), but far fewer wars out of nowhere:

@layer reset, tokens, components, utilities;

  • Component-level responsiveness → container queries. A complement to media queries, not a replacement: device settings still belong to media.

When not to do this

I'd be lying if I said you lose nothing. Build-time magic goes: @each loops, mixins with real conditions, compile-time math. Lean on those heavily and it's PostCSS, or a small build script that emits tokens. That's what I did for my datepicker: 28 themes generated by a build script; on the client, no theme generator and no runtime CSS generation.

And CSS-in-JS isn't evil: heavily dynamic per-instance styles really are nicer in JS (though even there, passing one value through an inline custom property and keeping the rules static is often enough).

This isn't "CSS-in-JS is dead" either

It's alive, even a v7 alpha in 2026. Tellingly, the official RSC path in styled-components v6.4 also leans on CSS custom properties: createTheme() turns tokens into var(), because inside Server Components a regular ThemeProvider is only a pass-through. Variables are the direction, not my exotic taste. Don't rewrite a big, working project for fashion: works — don't touch it.

But for theming specifically? Tokens plus data-theme cover it: less code, no runtime generation, no RSC headaches.

Want to try without diving in — ten minutes: one component, its colors on var(--token), light and dark in tokens.css, a toggle. You'll feel how little you needed that JS layer.

Top comments (0)