Writing
In December this year, CSS turns 30 🥳. But after 30 years, we still can’t agree on something as fundamental as how to add it to a website. Should you use CSS modules, Tailwind, CSS-in-JS, or just plain old CSS files?
Separation of Concerns or Locality of Behavior
In my experience, the most important decision is whether to use Separation of Concerns or Locality of Behavior.
The key difference between these two approaches is scope. With Locality of Behavior, the CSS is scoped to the source files; with Separation of Concerns, CSS is scoped to the output.
Here is an example of Locality of Behavior using Vue.js:
When using the scoped attribute, the Vue compiler generates a unique hash for each component. The resulting CSS actually looks like this:
This guarantees that the styles in this component only apply to HTML generated by this component. The styles are scoped to this file.
If we remove the scoped attribute, then we are going from Locality of Behavior to Separation of Concerns.
The CSS in the style tag now targets all paragraphs on the page, whether or not they were rendered by this component. The styles are global rather than scoped.
Once we understand how the different approaches relate to scope, we can make more informed decisions about which is right for our project.
A question of scope
The benefit of global styles is that you can easily make style changes across several pages at once. If you want all paragraphs to be red, just add color: red; to your paragraph selector. To create more targeted styles, we can use the cascade.
The cascade is incredibly powerful in letting you write very efficient CSS. This means that for smaller projects, you have CSS that is easy to write and easy to reason about.
As your project grows, this slowly starts to change. The bigger your project gets, the more cascading rules you have, and the greater the chance that you accidentally match elements you didn’t intend to. You end up with more different kinds of paragraphs, and you have to override some of the styles from the other cascading rules. At this point, your CSS starts to become brittle. Making any change to a rule like p { font-size: 1rem; } will likely break the styling of several paragraphs you didn’t intend to affect.
This is where scoped styles become interesting. They offer a tradeoff, trading efficiency for simplicity. You lose the ability to quickly change the font-size of all paragraphs, but in return, you get CSS that is easy to reason about, where everything you need to understand is right in front of you.
It’s a skill issue, bro!
A common critique of the different scoped style solutions, such as CSS-in-JS or Tailwind, is that they are for people who don’t understand CSS well enough. That people only struggle with scaling CSS because they don’t know it well enough. That CSS scales just fine if you write it correctly.
That is technically true. Both options can scale when done right. The difference is that with global styles, it is much easier to make mistakes, and since the styles are not scoped, those mistakes usually have much more severe consequences. To scale a codebase that uses global styles, you need much higher code quality than when the styles are scoped. With global styles, the quality of code has to grow linearly with the size of the codebase.
Which to choose?
If you are not using a framework, I would go for Separation of Concerns. It is efficient and allows you to reuse styles across your site when needed.
With components, you already have a way to reuse code, including styles, in your projects; adding a second mechanism brings more complexity than value. For very large projects, being able to ignore 95% of the codebase when implementing a feature or fixing a bug is one of the best features you can have. This is why scoping is so important, whether you are writing JavaScript or CSS.
If your project requires a framework, I would always go with Locality of Behavior. In my opinion, an architecture that relies on code quality improving as the project grows is a bad architecture.
Um, Actually!
There are a few caveats worth pointing out. On exactly two occasions in my career, I have worked on a project where one codebase needed to work for multiple sites (both cases were e-commerce). These sites needed completely different styling while using the same HTML. The difference went far beyond colors and border-radius, so each site needed its own separate stylesheet. In both cases, Separation of Concerns was the clear choice, and we just had to deal with the additional difficulties we faced as the sites grew.
I have also worked on sites where the designers wrote most of the CSS but weren’t comfortable using the full dev setup with React and JavaScript. In those cases, Separation of Concerns allowed them to contribute to the codebase, which they otherwise would not have been able to do.
There is still room for improvements
When CSS was introduced 30 years ago, the web looked very different. It was a platform for sharing documents, and CSS was designed to be decoupled from HTML because that made the most sense at the time. Just as frameworks changed the way we write HTML, it makes sense that the way we write CSS changes too.
I think both Vue and Svelte offer very good options with single-file components and scoped styles, but I think there is still room for improvement.
That is why, when deciding how styling should work in Nordcraft, we opted for a very strict version of scoped styles. In Nordcraft, styles are set on a per-element basis, and none of the CSS on one element affects any other elements. This is effectively inline styling, while still using the cascade to create style rules for pseudo-classes, media queries and so on. This is essentially the developer ergonomics of CSS-in-JS without having your CSS depend on your JavaScript files. Of all the things Nordcraft can do, this is the number one thing I miss whenever I go back to writing code.
Happy 30th birthday, CSS. Here’s to another 30 years of arguing about you.