Got Spaghetti Stylesheets? 4 Techniques for Managing CSS Complexity

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.  

Complexity Recap

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.

Framework examples:

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 foo.css before bar.css).

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.

Framework examples:

  • 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 @apply is 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 !important applied 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.

Framework examples:

  • 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.

Style Colocation

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--male and 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.

Framework examples:

  • 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.

Take-Aways

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.

If you found this post helpful, please share it with others. You may also be interested in the previous post in the series on CSS Complexity or What's the Big Deal with Tailwind CSS.