The evolution of scalable CSS is a multi-part chronicle intended to document the progress of tools, practices and techniques that enable us to write maintainable CSS at scale.
- Introduction
- Part 1: CSS scalability issues
- Part 2: Good practices
- Part 3: CSS Processors
- Part 4: Methodologies and Semantics
- Part 5: Styles Encapsulation
- Part 6: Atomic CSS
- Part 7: CSS-in-JS
- Part 8: Type-safe CSS
- Epilogue
In the previous chapter, Part 1: CSS scalability issues, we analyzed the most concerning problems with writing and maintaining CSS code. Now it's time to turn our attention to solving these issues.
Let's begin our journey by exploring the first iteration of solutions to avoid complex CSS selectors and specificity wars, focusing on essential good practices that equally apply nowadays, as they did many years ago:
- Keep specificity low
- Avoid mimicking HTML structure
- Avoid ID selectors
- Avoid type selectors
- Avoid using
!important
CSS tips and tricks from 2005 by Roger Johansson (aka. 456 Berea St.) is one of the first published articles on CSS good practices. Part of the content is still applicable today, but the majority is mostly outdated.
As more and more developers worked extensively and intensively with CSS, their approaches also diversified according to their projects. Next, we'll cover in detail several fundamental practices that reached consensus within the development community when talking about scalable and maintainable CSS.
Keep specificity low
The specificity wars are amongst the most dreadful problems we'll encounter as UI developers. To avoid them, we must keep specificity as low as possible.
Sometimes we tend to be overzealous when writing CSS selectors instead of keeping them simple. For example, defining overly specific CSS selectors by abusing descendant combinators will always result in high specificity.
/* ❌ AVOID: overspecific selector (1.4.3 specificity) */
#header .main_menu ul li.item a.link {}
Let's analyze this code for a second:
- If there is a single
.main_menu
element on the page, then the#header
selector is unnecessary. - The descendant selector
ul li.item
should not be necessary if alla.link
elements are placed inside theul
. Thus we can skip this selector altogether. - Last but not least, we shouldn't care that the
.link
is also ana
element, so we can safely remove thea
selector.
Thus, the above selector could be easily simplified, reducing its specificity:
/* 👍 BETTER: less specific selector (0.2.0 specificity) */
.main_menu .link {}
/* ✅ IDEAL: simple selector (0.1.0 specificity) */
.main_menu_link {}
Excessive nesting
CSS preprocessors make it even easier to create high specificity thanks to their nesting feature. Unfortunately, we often write excessive nesting, which is too overused and creates significant scalability problems.
// ❌ Avoid excessive nesting
.main_menu {
ul {
li {
.link {
}
}
}
}
// ❌ Prevent overspecific CSS output
.main_menu {
}
.main_menu ul {
}
.main_menu ul li {
}
.main_menu ul li .link {
}
We could easily reduce the specificity of the output CSS selectors by limiting the number of nesting levels:
// 👍 Prefer less nesting
.main_menu {
ul {
}
li {
}
.link {
}
}
// 👍 Prefer lower specificity output
.main_menu {
}
.main_menu ul {
}
.main_menu li {
}
.main_menu .link {
}
How low shall we go?
Keeping specificity low is definitely going to be helpful in the long run. But what exactly does "low" mean? How "low" should we keep it, and when does it become "too high"?
A rule of thumb used to say that CSS selectors should not have more than 2 descendants or levels of nesting. Thus, it's common sense to say that we should:
- use a single CSS class for the vast majority of our code:
.main_menu_link
; - use 1 descendant when overriding is necessary:
.dark-theme .main_menu_link
; - use 2 descendants only in extreme scenarios when the previous rules don't apply.
Avoid mimicking HTML structure
Another problem of high specificity is that it typically correlates with tight coupling between CSS selectors and the HTML structure. Mimicking the HTML structure in our stylesheets will usually produce rigid CSS code, requiring us to adjust the corresponding CSS selectors whenever we change the HTML structure.
// ❌ AVOID: unneeded "nav" ancestor
.main_menu nav .link {}
// 👍 BETTER: less structure dependent
.main_menu .link {}
// ✅ IDEAL: using a single class
.main_menu_link {}
Whenever we have more than 2 selectors in a style definition, we should be safe to remove the ones in the middle, ideally reducing the whole selector to a single class.
Overqualified selectorsWhenever we specify the element's type along with its CSS class, we deal with overqualified selectors, which should be avoided:
/* ❌ AVOID: unneeded and overqualified selector */
li.item {}
/* ✅ IDEAL: class name only */
.item {}
The problem with overqualified selectors is two-fold:
- Styles have less reusability, as they only apply for a single element type.
- The selector is tightly coupled with the HTML code. Changing the markup might require style definition updates as well.
Tight coupling is also related to strict combinator selectors such as child combinator or adjacent sibling combinator, as they reflect a specific HTML structure.
/* ❌ What if we need to wrap the .logo in a <div />? */
.header > .logo {}
/* ❌ What if we need to display the label first? */
.checkbox + .label {}
Scenarios that absolutely require using strict combinator selectors are very scarce. Therefore, it's advisable to use classes instead, as they scale better.
Avoid ID selectors
Sometimes we might think that a particular HTML block will only be displayed a single time on the page, so we could be inclined to use ID selectors for styling. Such examples might include the page's header, the contact form, or the product details page. However, two significant problems could occur when applying styles on ID selectors.
1. Unexpected overridden stylesLet's consider an HTML block called .profile
, which contains an .image
element. This block of content is used only on a specific page, for instance, post-listing.html
:
<div class="profile">
<img src="..." class="image" />
</div>
.profile .image {
/* 😌 these styles work as expected */
border: 1px black solid;
}
At some point, we want to reuse the .profile
content block on a different page, namely post-details.html
. However, that page already contains an .image
element, which is used inside an ID selector in CSS:
<div id="post-details">
<img src="..." class="image" />
<!-- re-use the .profile block on another page -->
<div class="profile">
<img src="..." class="image" />
</div>
</div>
#post-details .image {
/* 😈 IDs have higher specificity than classes */
border: 10px grey solid;
}
.profile .image {
/* 😡 these style will get overridden */
border: 1px black solid;
}
In these circumstances, the .profile .image
styles will get overridden by the more specific #post-details .image
selector. We could change the class names to avoid collisions, but the fundamental problem still remains: reusing HTML blocks could render different styles, depending on where we include them.
Any non-deterministic behavior is potentially a critical problem when thinking about long-term projects.
2. Lack of reusabilityThe last time I checked, foreseeing future needs is very debatable. In addition, my own past experiences strongly support that we are generally bad when making predictions.
It happened to me so many times to think that "we'll never have more than one Contact Form", only to find out one week later that we needed to add a Subscribe Form, which looked mostly as the existing Contact Form.
/* ❌ code smell which proves that we cannot foresee the usage of code blocks */
#contact-form,
#subscribe-form {}
Therefore, to prevent any of the problems mentioned above, we should avoid applying styles on ID selectors altogether and use classes instead:
/* ❌ Avoid using ID selectors in CSS */
#contact-form {}
/* ✅ Prefer classes instead */
.contact-form {}
Avoid type selectors
CSS type selectors match HTML elements by their tag name. It's tempting to use type selectors as it allows us to focus on CSS code, without polluting the HTML markup with class
attributes. Unfortunately, this practice ultimately leads to unexpected problems.
Using type selectors creates a coupling between the markup and the styles. As a result, changing the markup will unexpectedly break the styles.
Let's look at a common example:
<div class="card">
<h2>Title</h2>
</div>
.card h2 {
font-size: 2rem;
}
- we have a
.card
element that contains a title placed inside anh2
element; - we apply the styles using the
.card h2
selector.
At some point, somebody needs to replace the h2
with an h3
, which is a trivial HTML change and we shouldn't be concerned of breaking anything, right?
<div class="card">
- <h2>Title</h2>
+ <h3>Title</h3>
</div>
If we wrote this code ourselves one week ago, we might already know that we have to update the CSS code, as well. Similarly, if we deal with a small codebase, we should be able to fix it easily.
However, on large projects, these problems grow exponentially:
- Without knowing that there are styles applied to the
h2
element, which also require updates, there's a high chance of introducing regressions. - Debugging such a problem could be cumbersome because the styles are applied implicitly, not explicitly.
Thus, the styles will be coupled with the markup. We cannot change the markup without updating the styles as well. Whenever we have a tight coupling between parts of code that could be modified independently, without affecting one other, we have a scalability problem.
2. Lack of isolationNevertheless, adding new markup could also be problematic when using type selectors. For example, existing CSS rules could match newly added markup, rendering unexpected results.
Let's consider the following scenario:
<div class="card">
<h2>Title</h2>
<strong>Keywords</strong>
</div>
.card strong {
font-size: 0.75em;
color: grey;
}
- We have a
<strong>
element in our markup, which contains specific content for "keywords". - The styles for the "keywords" content are set using the
.card strong
tag selector, as it doesn't require us to add a new CSS class in the markup.
Now, it's not uncommon to add other HTML markup, at some point, that might also include a <strong>
element:
<div class="card">
<h2>Title</h2>
<strong>Keywords</strong>
<!-- 😡 the "highlighted" text will have unexpected styles -->
<p>Some <strong>highlighted</strong> description</p>
</div>
The problem is that both <strong>
elements will share the same styles, which might not be the expected result:
- We would expect the newly added
<strong>highlighted</strong>
content to render as genericbold
text, maintaining thecolor
andfont-size
of the parent<p>
element. - We don't want it to inherit the specific styles of
<strong>Keywords</strong>
content defined in CSS.
The solution for preventing the aforementioned problems is quite simple: avoid targetting HTML tags in CSS and use explicit classes instead.
/* ❌ AVOID tag selector */
.card strong {}
/* 👍 BETTER: classes are more flexible & isolated */
.card .keyword {}
/* ✅ IDEAL: single classes are ideal */
.card_keyword {}
As a result, changing the markup will not affect the styles. In addition, classes provide better styles isolation, preventing unexpected results when updating the markup.
ExceptionsAs problematic as type selectors are, there are a few exceptions where they are helpful or even required:
- Resetting or normalizing CSS implicit user-agent styles, using various CSS reset techniques.
- Styling dynamic content from a headless CMS which usually serve plain HTML content, without any class names. Thus, we must target elements by their type, for instance,
.blog_post h2
.
Avoid using !important
At some point, we might be tempted to use !important
as an attempt to fix the specificity wars. But, unfortunately, it's a losing battle. Many of us tried to make it work, but none of us succeeded. Even MDN documents it as a bad practice.
If we use !important
to fix the specificity wars, it won't take long until the !important wars start. At some point, we'll need to override already !important
rules, which is possible but disastrous.
ExceptionsWhen we end up overriding an
!important
CSS rule, we hit rock bottom regarding CSS maintainability!
Imagine the irony: even such an important advice, as avoiding !important
, has a few important exceptions worth mentioning:
Overriding 3rd party styles, especially those with high specificity which are cumbersome to override using conventional methods.
Defining utility classes such as
.bold
or.align-center
, because they could be easily overridden by accident.
Practices don't scale
Once we learn and understand all these practices, it should be fairly easy to apply them, right? Unfortunately, it's not a trivial task at all.
- There is no official comprehensive guide on how to write maintainable CSS. I've only covered a few of the essential practices, but there are so many more to consider.
- Some of the practices don't apply equally to every project, with every team. Also, developers tend to be very opinionated, so we rarely reach a consensus regarding a specific set of rules.
- Since there are so many practices, and some of them quite debatable, they are cumbersome to learn and teach.
- As we've seen, there are plenty of exceptions that apply. It's often challenging to draw a line when it's acceptable to break the rule.
- Some practices are typically impossible to enforce. While there are linters that can be used to enforce certain rules, we'll still have to rely on constant code review, training, and mentoring.
- Once we have an existing large application written without these practices in mind, they become tough to introduce into an existing codebase. Changing CSS is typically a fragile task, so most developers avoid big refactorings.
- Last but not least, good practices don't scale! The larger the code base, the more burdensome the maintenance will become.
However, we call them "good" or even "best" practices because they indeed help us. They were the first community effort to address the innate maintainability issues of CSS.
But they're not perfect. They don't fix all the problems. And you know how engineering works:
As long as there's a problem, there's also a solution waiting to be discovered.
Therefore, let's pack our bags and move on. We still have many other problems to solve, and our journey has just begun.
In the next chapter, Part 3: CSS Processors, we'll cover CSS preprocessors which remove most of the source code duplication among other features, while CSS postprocessors optimize CSS output and pave the way for styles encapsulation with CSS Modules, which we'll cover in one of the following chapters.
References and further reading
- Our (CSS) best practices are killing us by Nicole Sullivan
- Code smells in CSS by Harry Roberts
- CSS for Software Engineers for CSS Developers by Harry Roberts