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.
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;
}
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);
}
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);
}
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>
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)