Andrei Pfeiffer logo
Back to Articles

The evolution of scalable CSSPart 2: Good practices

CSS
12 min read

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.

  1. Introduction
  2. Part 1: CSS scalability issues
  3. Part 2: Good practices
  4. Part 3: CSS Processors
  5. Part 4: Methodologies and Semantics
  6. Part 5: Styles Encapsulation
  7. Part 6: Atomic CSS
  8. Part 7: CSS-in-JS
  9. Part 8: Type-safe CSS
  10. 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:

ImportantIt should be noted that this is not at all a comprehensive list, as the topic is pretty vast. In addition, as their name suggests, these are practices, not dogmas. Even if we treat them as rules, there are always understandable exceptions.
Timeline of scalable CSS evolution, highlighting the main timeline or Semantic CSS (in blue) and pinpoiting the emergence of CSS good practices in 2005, which continue to evolve even today
CSS good practices started to be published around 2005 and continue to evolve even today

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 all a.link elements are placed inside the ul. Thus we can skip this selector altogether.
  • Last but not least, we shouldn't care that the .link is also an a element, so we can safely remove the a 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.

style.scss (source)SCSS
// ❌ Avoid excessive nesting
.main_menu {
  ul {
    li {
      .link {
      }
    }
  }
}
style.css (output)CSS
// ❌ 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:

style.scss (source)SCSS
// 👍 Prefer less nesting
.main_menu {
  ul {
  }
  li {
  }
  .link {
  }
}
style.css (output)CSS
// 👍 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 selectors

Whenever 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:

  1. Styles have less reusability, as they only apply for a single element type.
  2. The selector is tightly coupled with the HTML code. Changing the markup might require style definition updates as well.
Combinator selectors

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 styles

Let'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:

post-listing.htmlHTML
<div class="profile">
  <img src="..." class="image" />
</div>
style.cssCSS
.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:

post-details.htmlHTML
<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>
style.cssCSS
#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 reusability

The 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 {}
NoteIt's perfectly fine to use IDs in HTML for anchor links. But, we should avoid referencing them in CSS selectors.

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.

1. Tight coupling

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:

index.htmlHTML
<div class="card">
  <h2>Title</h2>
</div>
style.cssCSS
.card h2 {
  font-size: 2rem;
}
  • we have a .card element that contains a title placed inside an h2 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 isolation

Nevertheless, 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:

index.htmlHTML
<div class="card">
  <h2>Title</h2>
  <strong>Keywords</strong>
</div>
style.cssCSS
.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 generic bold text, maintaining the color and font-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.

Exceptions

As problematic as type selectors are, there are a few exceptions where they are helpful or even required:

  1. Resetting or normalizing CSS implicit user-agent styles, using various CSS reset techniques.
  2. 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.

When we end up overriding an !important CSS rule, we hit rock bottom regarding CSS maintainability!

Exceptions

Imagine the irony: even such an important advice, as avoiding !important, has a few important exceptions worth mentioning:

  1. Overriding 3rd party styles, especially those with high specificity which are cumbersome to override using conventional methods.

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

Scroll to top