Is CSS-in-JS actually bad?
Or "why I made a CSS-in-JS library in 2023"
I have a confession: I used to not like CSS very much in my first few years of being a developer. I would dread opening up CSS files. I would cuss at z-index for causing me so many headaches. I would wish for someone else to write the CSS for me. I would make heavy use of pre-styled component libraries without a second thought.
Fast forward to today, where just thinking about CSS makes me giddy. I obsess over tiny CSS details, like theming scrollbars. I see a well-designed UI out in the wild, and the very first thing I do is pop open dev tools to try and figure out how it was made. I'm unafraid. I enjoy CSS.
So what changed? Well, I took the time to learn CSS. Oh and also the fact that CSS is now Good™️ and only keeps getting better year after year. Even the smallest of things, like flexbox gap
, make CSS feel more ergonomic and approachable.
Still, most new CSS features don't help with its maintainability, at least until recently... In 2021, we got the much needed :where
pseudo-class, which lets us control the specificity of selectors. In 2022, we got cascade layers, which let us bypass selector specificity altogether (even for third-party styles!) and directly control the order of large chunks of styles. This is by far my favorite recent addition to CSS.
But it's only half of the equation.
Scoping
You see, CSS maintainability is a two-dimensional problem. Layers solve the vertical aspect of it ("order"), but we still need something to manage its horizontality ("reach"). Just like we don't want our global reset styles overriding our component styles, similarly we don't want the styles for one component interfering with other components. If we do this right, we would also know exactly what is being used and where, meaning we can confidently remove dead code (something that has been historically too difficult to do in CSS).
(Diagram based on Miriam Suzanne's guide to cascade layers)
There is a spec being developed for @scope
which will help with this across the board. Maybe we'll see it shipped in 2023? But until then, we're on our own.
Scoping is one of those problems that developers have attempted to solve the most. One of the simpler forms of scoping is achieved by using naming conventions like BEM. More formally, it is solved through tooling. CSS modules (not the native ones) is probably the most popular and robust solution for scoping — it has been implemented by countless tools and has spawned similar variations in many other tools. It worked well then and continues to work well now, despite so many years of innovation both in browsers and in the developer ecosystem.
Colocation
Having a solution only for scoping can often be enough, but we can do better. We often want better. We want our styles to live close to the markup that they are scoped to. This makes it easier to keep them in sync, change/delete them together, etc. (This is also the whole promise of a utility-first approach, right?)
This is where BEM, CSS modules, et al start to feel a bit unergonomic. The styles and markup live in different locations — the closest we can get is a CSS file in the same folder. And we still need to manually make the association. There is no Intellisense or "Go to definition" to travel back and forth between the two. It is easy for things to get out of sync. And the bigger the size of the team, the greater the chance of human error.
Admittedly, web components (specifically shadow DOM) have somewhat solved this problem. But, in my opinion, shadow DOM goes too far with its style encapsulation. I don't want to throw away the entire cascade, I simply want some (but not all) parts of it to be closely tied to specific elements.
That's where CSS-in-JS comes in. There are very clear DX benefits to libraries like styled-components and emotion*. They provide scoping and colocation on top of the full power of CSS. But remember: CSS was missing some key features back then, and developers really enjoy writing JavaScript. So why not use JavaScript as a pre-processor, post-processor, runtime-processor, everything-processor for theming, breakpoints, "variants", "style objects", "polymorphism", "critical CSS", "extending styles", even global styles.
...eh, what? I just wanted scoping and colocation. Why is JavaScript eating everything up? And isn't there a tangible cost to all of this?
*The idea of CSS-in-JS actually predates CSS modules (but not BEM). But it was a very different form of CSS-in-JS from what we see today with styled-components and emotion, so I've chosen to skip past that.
Performance
Calculating all styles in JavaScript at runtime kills performance (perhaps obviously). Even with SSR, it takes time for the page to become interactive. I have personally seen multi-second page loads on sites that use runtime CSS-in-JS.
It took a few years but the JavaScript community has started recognizing this issue. You might recall a recent blog post from Sam Magura, one of the maintainers of emotion: "Why we're breaking up with CSS-in-JS". In that post, Sam goes into a performance deep dive, illustrating the problem with practical measurements and an insider perspective.
I should (again) briefly mention that web components can kinda fix this, through constructable stylesheets. Even better, that API can be used outside web components to potentially improve the performance of runtime CSS-in-JS for light DOM (see github issue). But we may be past that point, considering that maintainers are "breaking up" with the whole idea, and a large portion of their userbase has moved on to utility classes.
Besides, even the React core team now recommends static solutions over runtime:
"You could however build a CSS-in-JS library that extracts static rules into external files using a compiler. That's what we use at Facebook."
Compile all the things!
It starts to become clear by now that if we want the DX benefits of CSS-in-JS and the UX benefits of static CSS files, then we might need to do things at compile/build time. Pretty much all of the downsides of CSS-in-JS we've seen so far can be traced back to doing things using JavaScript at runtime.
In the same article where Sam Magura discusses the runtime performance of CSS-in-JS, they also briefly attempt to speculate on the downsides of compile-time CSS-in-JS libraries (without actually having tried any of those libraries, mind you). In response to that, Mark Dalgleish (co-creator of CSS modules and Vanilla Extract) called out the falseness of these claims.
While we're talking about Vanilla Extract, I should mention that I think it is solving a different problem than most CSS-in-JS libraries. Its main focus is to help build type-safe design systems, not websites or applications. Now, one could argue that many applications could benefit from using a design system (one that could be built on top of Vanilla Extract perhaps), and I would generally agree, but that is a different conversation.
Back to the topic at hand: remember, we want scoping, colocation, and performance. If the goal remains focused on those three points, then this becomes a much narrower, simpler problem to solve. Let me show you my attempt at solving it.
Since scoping happens at the class level (or more broadly, selector level), we'll use it as the singular point in our API. This has the added benefit of being framework-agnostic. And it's all we really need.
From a DX perspective, the API is quite intuitive too, especially for those coming from other CSS-in-JS libraries (the idea for the css
function actually comes directly from emotion). We also get to use real CSS syntax here, so the authored code feels instantly recognizable to everyone. There is no learning curve, and the styles are copy-pasteable across different projects and codepens.
So now we just need to replace the whole CSS string with a hashed class name and use it to populate a CSS file. Sounds simple enough. And honestly? It is! Building the original proof of concept only took about 50 lines of code (and a few drinks).
I want to emphasize again that this is all at build time, which enables us to do all kinds of shenanigans with the extracted CSS, without affecting runtime performance. That's how we're able to get &
nesting for free (using PostCSS). We can use this idea to go one step further and support Sass too, because Sass is awesome!
Sweet! So we've basically taken verbatim what would have been in a .css or .scss file, and instead put it inside our .jsx/.tsx files with scoped class names that have all the same benefits of JavaScript variables - being importable, tree-shakeable, "Go to definition"-able, etc. It's like inline (S)CSS modules!
Closing thoughts
Scoped + colocated + performant styling totally should have been an already-solved problem. There were definitely attempts made: Linaria is a static CSS-in-JS library designed to address exactly this; except I have had trouble getting it to work with cascade layers or with Vite SSR (a la Astro). In fact, I have had trouble with most CSS-in-JS libraries in Astro.
I am notably more disappointed in React for not taking this problem seriously. To illustrate this, you need only to look at the competition. Most frameworks that use a non-JSX language for templating — Svelte, Vue, Astro, Angular, WebC — have a mechanism for scoping CSS (if you use one of these non-JSX frameworks, you were probably waiting for me to mention this). There is no reason React, and by extension JSX, can't support scoping when others have done so for ages. React, in its quest to be unprescriptive, has led to years of arguably suboptimal attempts by the community, to solve a problem that the framework is best equipped to solve. The same goes for other JSX frameworks like Preact and Solid, but there is no doubt that once React figures it out (and there are signs they might), others will soon follow.
My goal for ecsstatic is to make it the closest thing possible to CSS so that it supports all CSS features (including ones that don't exist yet) but more importantly, so that when the time comes, migrating off of it feels painless.
If you've made it all the way to the end, have a balloon: 🎈. And maybe check out ecsstatic.dev. Thanks for reading.