
For a long time, we believed that CSS-in-JS was the "modern" way to style applications. We loved the scoping, the dynamic props, and the feeling that everything was JavaScript. We treated CSS like a compilation target rather than a language.
But eventually, the cracks appeared. The runtime performance cost was real. The server-side rendering complexity was annoying. And honestly, writing CSS as JSON objects just felt wrong. We moved back to standard CSS (with Modules and Variables), and our site got faster and our developers got happier.
The Premise: Why We Bought In
In 2018, CSS-in-JS libraries like Styled Components and Emotion felt like a superpower. We were building complex React applications, and the global nature of CSS was biting us. We had BEM (Block Element Modifier) class names that were 40 characters long just to avoid collisions. We had !important wars in our stylesheets. We had "dead code" fear—nobody deleted CSS because nobody knew if it was still being used by that one modal from three years ago.
CSS-in-JS promised to solve all of this:
- Automatic Scoping: Components generated unique class names like
sc-AxjAm. No more collisions. - Dynamic Styling: We could pass React props directly to the style.
color: ${props => props.primary ? 'blue' : 'gray'}. It felt magical. - Deletion Confidence: If you deleted the component file, you deleted its styles. 1:1 mapping.
- Theming: The `ThemeProvider` context made switching light/dark modes a breeze (or so we thought).
We went all in. We rewrote our entire design system in Emotion. We banned `.css` files. We were "modern."
The Reality: The Runtime Tax
The first sign of trouble was performance on low-end devices. We noticed our "Scripting" time in the Chrome Performance tab was unusually high, even when we weren't doing complex logic.
It turned out, the library was doing a lot of work. Every time a component rendered, the CSS-in-JS library had to:
- Serialize the style object or string.
- Calculate a hash of the content to generate a class name.
- Check the StyleSheet manager to see if this rule already existed.
- If not, create a new style rule and inject it into the DOM.
This doesn't sound like much, but do it for 2,000 DOM elements in a complex dashboard, and it adds up to hundreds of milliseconds of main-thread blocking time. We were effectively shipping a compiler to the user's browser.
The Hydration Mismatch Nightmare
Then came Next.js and the push for Server-Side Rendering (SSR). CSS-in-JS libraries, by design, rely on knowing the component state to generate styles. But the server doesn't have a DOM. So we had to set up complex `_document.js` logic to extract critical CSS, inject it into a style tag, and rehydrate it on the client.
We constantly battled "Prop className did not match" errors. We had flashes of unstyled content (FOUC). The promise of "Zero Config" in Next.js was broken the moment we `npm install @emotion/styled`.
The Vibe Shift: CSS Got Good
While we were fighting with our JS-based styling libraries, CSS itself quietly got really, really good.
1. CSS Custom Properties (Variables)
The killer feature of CSS-in-JS was theming. But native CSS variables (--primary-color: #007bff;) solved this natively. They are dynamic. You can change them in JavaScript with `style.setProperty`. You can scope them to specific DOM subtrees. You don't need a React Context Provider to pass them down; they cascade naturally (it's in the name!).
2. CSS Modules
The scoping problem? CSS Modules solved this at build time, not runtime. You write `.button { ... }` and the bundler transforms it into `Button_button__xF8d2`. Zero runtime cost. Zero collisions. And you get to write actual CSS syntax, not JSON objects with camelCase keys.
3. :has(), :is(), and Nesting
We used to need libraries to do simple nesting. Now browsers support it. We can write clean, hierarchical CSS without a preprocessor.
The Migration: Back to the Future
We decided to migrate our Design System back to CSS Modules and Variables. It was a massive undertaking, but the pattern we landed on is superior in every way.
The New Pattern
Here is what our components look like now:
// Button.tsx
import styles from './Button.module.css';
export const Button = ({ variant = 'primary', children }) => {
return (
);
};
/* Button.module.css */
.button {
background: var(--bg-color);
color: var(--text-color);
padding: 1rem 2rem;
border-radius: 4px;
}
.button[data-variant="primary"] {
--bg-color: blue;
--text-color: white;
}
.button[data-variant="secondary"] {
--bg-color: gray;
--text-color: black;
}
Notice the use of Data Attributes. Instead of passing the prop into the CSS-in-JS function, we pass it to the DOM as a data attribute. CSS selects that attribute and redefines the locally scoped variables. It's declarative, readable, and incredibly performant.
The Benefits We Measured
1. Bundle Size Reduction
We dropped 35KB of gzipped JS from our main bundle. That's the weight of the Emotion library plus the specific runtime overhead code for all our components.
2. Parsing Performance
CSS parsing is highly optimized in browsers. It happens on a separate thread in some engines. Parsing JS-injected styles blocks the main thread. Our "Time to Interactive" on mobile improved by 15-20%.
3. Debugging Experience
This is underestimated. In DevTools, we now see `Button_button__xF8d2` defined in `Button.module.css`. We can click the file link and go straight to the source. With styled-components, we saw `` blocks with no file reference, just a soup of generated styles.
4. Developer Velocity
New hires know standard CSS. They don't need to learn our specific flavor of `styled-system` or `xstyled`. They can copy-paste from CodePen. They don't have to map `padding-top` to `paddingTop`. The cognitive load is lower.
The Counter-Arguments (And Rebuttals)
"But I like colocation!"
We co-locate our CSS files right next to the component. `Button.tsx` sits next to `Button.module.css`. It's the same folder. It's just a different file extension. Modern IDEs handle split-panes perfectly.
"But I need truly dynamic styles, like a coordinate from a mouse drag!"
For highly dynamic values (like a draggable position), use the `style` prop. That's what it's for. Don't generate a new class for every pixel of movement; that trashes the CSSOM performance anyway. Use `style={{ --x-pos: x }}` and use `var(--x-pos)` in your CSS.
The Ecosystem is Moving
We aren't alone. The creators of React are actively working on Server Components, which discourage runtime CSS-in-JS. Library maintainers of Emotion and Styled Components have admitted that the runtime cost is hard to eliminate. Newer libraries like Vanilla Extract or Panda CSS are trying to bring type-safety to build-time CSS, proving that "Runtime CSS-in-JS" is likely a transitional technology.
We are entering the "Post-CSS-in-JS" era. It was a useful experiment. It taught us about the importance of component-scoping. But the browser learned those lessons too, and now offers better ways to achieve the same goals.
Conclusion
We stopped playing compiler in the browser. We embraced the platform.
If you are starting a new project today, I implore you: default to CSS Modules or a utility framework like Tailwind (which is also build-time). Treat CSS-in-JS as a legacy constraint, not a modern feature. Your users' batteries will thank you.
Written by XQA Team
Our team of experts delivers insights on technology, business, and design. We are dedicated to helping you build better products and scale your business.