Last week, I wrote about the definition of CSS complexity and how CSS features have inherent complexity – that is, complexity outside of a web designer's control.
This week, I want to take a look at some tried and true techniques for unwinding your spaghetti CSS. The techniques are covered in their general form – with links to framework-level implementation examples.
This is not a 'list of CSS methodologies' post – there are enough of those. Nor will I try to convince you to use 'The One Framework / Process' – every team is different and every project's needs are unique.
My goal for this post is to provide a few metrics to analyze CSS technologies, which will hopefully help you (and myself) choose the right one for a new project.
Here's a quick summary of CSS complexity:
CSS complexity is anything related to the CSS of a project that makes it hard to understand and modify.
CSS complexity shows up in 3 forms:
- Change Amplification: making a style change require multiple edits.
- High Cognitive load: a large amount of context is needed to make a change.
- Unknown unknowns: when it is not clear where to make a change.
Ok, on to the techniques!
Limit Selector Combinations
One of the biggest sources of CSS complexity is selector explosion and the resulting specificity escalation – where a
nth level element with a single class on the page has >
2^n possible combination of potential class-based selectors. CSS's machine-friendly specificity score calculations only add to this problem. (See example from last post)
This problem affects all 3 symptoms of complexity: In order to make a style change, you now have to remember all selectors targeting the element (high cognitive load) track down the right ones (unknown unknowns), and potentially change multiple files (change amplification),
Because this is a footgun, all CSS frameworks and best practices impose limits on the selector space. The advice 'don't use elements in your selectors' as an example, removes a substantial subset of potential selectors.
- Tailwind sticks to a single level of class-based specificity – not including normalizing CSS.
- Block Element Modifier (BEM) takes a similar approach and recommends against using combined selector for variations – limiting selector space to class-based selectors with at most 2 levels.
- Scalable and Modular Architecture for CSS (SMACSS) does not restrict selector type, instead opting for limiting depth. It allows for layout ids and modules can potentially double up on class selectors.
- The example of never using elements in your selector came from Object-Oriented CSS (OOCSS).
The exact number of allowed selectors isn't as important as the need to have a principled way to limit selector depth.
The difficulty of limiting selectors is it needs to be both documented and enforced. It's easy to slip up when 'design needs this change done yesterday'.
Reduce Property Overlap
Source order is another major source (sorry) of CSS complexity. The same component on different pages might have different styles due to different import orders – especially if selector specificity is flattened.
This leads to both unknown unknowns (which file to change?) and high cognitive load (remember to always include
The best way to combat this is to ensure overlapping selectors do not overlap in CSS properties. And if they have to overlap, make sure their relative specificity is unambiguous.
- Atomic frameworks like Atomic CSS and Tailwind takes a single purpose approach to selectors, so CSS properties are naturally never overlapping – this is also why Tailwind's
@applyis an anti-pattern when overused.
- BEM is against global modifiers for this reason also, and modifier styles should always be declared after the block.
- When global utilities are encouraged, as is the case with ITCSS, utility classes are encouraged to have
!importantapplied to all values. So the property value is always clear.
- CUBE CSS gets around to this problem by separating layer concerns from visual properties. Layout classes and blocks should not share properties.
When not using a utility first approach, it can be difficult to keep track of all classes' overlapping properties in a project. I think this is an area with a need for better tooling.
Restrict Selector Scope
CSS is global in scope – at least pre cascade layers. As with code, global CSS leads to name conflicts in large projects. When creating a new component, you need to keep in mind every class name used in the project along with potential 3rd-party classes.
This imposes a cognitive load penalty that scales with the size of the project and the number of developers working on it.
The solution to this problem is the same as in programming, you reduce selector scope to eliminate the possibility of collision.
- Component-based CSS frameworks originating with OOCSS inherently limit selector scope to only elements inside the 'CSS object'.
- ITCSS / BEMIT introduces a Hungarian notation of class names to aid in reading – this reduces the cognitive load when reading CSS.
- Taking the idea to somewhat of an extreme, CSS modules explicitly makes all CSS locally scoped by default.
There's not much downside to limiting CSS scope, although when taken too far you'll end up with larger stylesheets than needed. You can prevent this by planning out sensible global default styles, I especially like CUBE CSS's approach to progressive enhancement.
The last problem I want to take a look at is the inherent complexity of using class name-based CSS hooks.
Imagine you have a large, existing e-commerce site. One day, your designer asks you to add a purple decorator to female-gendered product category headings and another for male ones. Assuming this was not an abstraction previously included in your site's markup, now you need to come up with a semantic and nonclashing class name for gendered heading variation. Then you need to update the code that generates the reusable heading template to add logic for
heading--female. Side Note: there are lots of other ways to do this, but for this example, I'm limiting it to a BEM modifier.
In my opinion, this is 'play-pretend separation of concerns', a style-only update now triggers a 'semantic' html rabbit hole. I'm not the only one to feel this way, I first came across a clear description of this problem in this Adam Wathan post.
This pattern leads to change amplification and unknown unknowns, you have to find the right HTML template and its corresponding CSS file, make the same modification in both places at the same time, and if you make a typo in either case, check two files to find it.
This problem is best mitigated by placing style information right next to HTML templates. This way, even if you have to invent a new 'semantic class name, you at least can do it in the same file.
- Some leave out style hook class names altogether, instead using aria attribute and semantic HTML elements to separate style from markup. See this Heydon Pickering Smashing Magazine Article and Enduring CSS chapter.
- Atomic approaches (ACSS, Tailwind) get around this by using only style-based hooks, reading the class is looking at its style – no need to cross-reference.
- CSS-in-JS category of tooling (JSS, Styled-Components, etc.) does 'inline++' styling, which also has the added benefit of placing style information right next to the template.
- Lastly, JS frameworks like Vue and Svelte provide an all-in-one experience, where template, style, and functionality all live in the same file.
The largest drawback of this approach is it violates the traditional concept of separate JS, CSS, and HTML files, each responsible for its own domain of the web experience.
Next time you read about 'the next big CSS paradigm', you can map its features to one of the general techniques above. And if you are looking to create your own CSS workflow/framework, I hope you will take these general patterns into your consideration.
I finished the initial draft of this post feeling like I've only made a dent in the topic – serves me right for trying to cover an area as broad as 'how to make CSS less complex'. So please let me know if I've missed one – or a dozen – of your favorites. You can ping me on Twitter @ItsTrueInTheory or email me at zhang@ this blog's domain.