CSS debt
No other language is quite like CSS. Unlike Ruby or JavaScript, you can't test it. There aren't any warnings when it breaks or when you use it improperly. When you add a rule, it's easy to affect unrelated elements. And its purpose — design — changes with business requirements, so it's constantly being edited.
This is why stylesheets become bloated; why we create new rules instead of using existing ones; why we opt for !important
s and inline styles over simple class names.
Over the last several years, I've learned how to write styles that can scale. They break less often, and they're more maintainable — and honestly, they're more fun to write. In this post, I'll go over some of the things that have led to "CSS debt" in the past, and make recommendations on how to avoid it.
Testing
Your computer can't tell if your styles are wrong. It doesn't know if using a certain z-index
will break the modal on the sales page, or that applying overflow: hidden
will prevent the dropdown nav from appearing on iOS Safari. In other languages, it's easy to tell if a record is created with [these] properties when you run [this] function. But in CSS, it's hard to tell if the UI on every affected element across your site is "still usable" and "looks right" when you change a box-sizing
to border-box
.
This means that when we change a style, we need to manually test it. This means visiting every instance of an element that was impacted by a style change. ...but this is a huge pain, so most of us just write a new class and copy over the properties. Add a few !important
s, and we're done. After all, it's easier to duplicate styles than refactor them.
Continue this for a few years, and stylesheets become a mess of spaghetti code, bigger and buggier than they need to be.
Nuances
It doesn't help that CSS has a lot of nuances that can be pretty confusing to new developers. For instance, take this HTML:
<div class="example">
<p>Given this HTML...</p>
</div>
And this CSS file:
/* Text is blue. */
.example {
color: blue;
}
/* Text is red. Rules defined later take precedence. */
.example {
color: red;
}
/* Text is white. IDs take precedence over classes. */
#example {
color: white;
}
/* Text is STILL white. IDs take precedence. */
.example {
color: pink;
}
/* Text is orange. !important takes precedence over IDs and classes. */
.example {
color: orange !important;
}
/* Text is green. Specific rules take precedence. */
.example p {
color: green;
}
/* Text is STILL green. Specific rules take precedence. */
.example,
#example {
color: yellow !important;
}
/* Text is purple. !important and specific overrides everything. */
.example p {
color: purple !important;
}
A few years ago, I was refactoring the CSS for a web app where I found a 1.5k line CSS file with this right in the middle:
.alert--error p {
color: white;
}
.alert--error p {
color: white !important;
}
.alert--error {
color: white !important;
}
/* WHY WON'T THIS WORK */
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
.alert--error p { color: white !important; }
It just goes to show that CSS has a bunch of nuances, and unlike JavaScript, there isn't a console to see the warnings when you mess up. When you're working on a large enough team, someone is bound to use unnecessary !important
s, too much nesting, or set styles via JavaScript. And once this starts, it spirals out of control, because it just creates the need to keep repeating this behavior. In other words, stylesheet quality degrades exponentially.
Global Namespacing
When we're developing a UI, we tend to think of each element as separate. The navbar is separate from the main content, which is separate from the footer – but CSS targets the global scope. That means if we have a layout like this:
<!-- Top navbar -->
<nav>
<ul>
<li><a href="/" class="active">Home</a></li>
<li><a href="/">About</a></li>
</ul>
</nav>
<!-- Navbar in footer -->
<footer>
<nav>
<ul>
<li><a href="/">Terms</a></li>
<li><a href="/">Privacy</a></li>
</ul>
</nav>
</footer>
...we can't write CSS without adding some unique class names or deeply nesting our selectors.
a {}
nav ul {}
nav li {}
nav a {}
nav .active {}
nav a:hover {}
footer nav a {}
footer nav a:hover {}
footer nav .active {}
/* ... */
We can add unique classes to style each of these elements, but each class will be in the global scope, so we have to be careful with our naming conventions.
Because styles are global, one nested selector leads to another, which leads to an !important
, which eventually creates the need for more nested selectors and more !important
s, and the problem continues to grow.
In the past, we've tried to solve these naming issues with methodologies like SMACSS. This is better than nothing, but it still has a learning curve, and you need to get your team to use it too.
Preprocessors
Preprocessors add features to CSS. They make it easier to lighten and darken colors and do math inside our stylesheets; they give us access to loops, and also higher-level features like mixins, which are helpful for grouped styles, like a certain font family/font size that are often used together, or horizontal menus that all use the same type of list styling.
They also make it very easy to bloat and overcomplicate your code. Sometimes, they lead to unnecessary nesting and larger compiled file sizes:
nav {
// ...
&.open {
// ...
}
ul {
// ...
}
li {
// ...
&.active {
// ...
}
}
svg {
// ...
}
a {
// ...
&:hover,
&.active {
// ...
svg {
// ...
}
}
}
}
footer {
// ...
nav {
// ...
a {
// ...
&:hover {
// ...
}
}
ul {
// ...
}
li {
// ...
}
}
}
When we're in the middle of a big project and we come across six different 200-line .scss files shaped like this, the last thing we want to do is take the time to understand if we can use one of these classes. So we create new class names (or even new files altogether) instead of reusing the existing ones. And by creating new classes, we might feel the need to throw in a few !important
s or do some further nesting to override the specificity on some existing elements. And our CSS continues to become harder to maintain.
Luckily, vanilla CSS is starting to get some of these features, like variables. They have their own quirks. (For instance, you can't use a CSS variable as the width in a media query, so it's hard to use them to set consistent breakpoints. Also, the syntax is much more verbose than the alternatives: calc(--var(width) * 2)
doesn't have the same ring to it as $width * 2
.) but the language is much nicer than it used to be.
Debugging
Along the lines of what I said earlier: vanilla CSS is a black box. Some styles won't work unless your element has a certain display
value, or position
, or so on. There are no warnings for this. If you define a rule that isn't specific enough, nothing happens. (After all, this is a feature! Some people solve this by rejecting cascading altogether with the argument that it never works to our advantage.) And we catch ourselves using actual styles to debug our other styles:
.example {
outline: 2px solid red;
}
Combine this with hundreds of properties to customize, and you wind up with a language that's hard to debug. Try solving a z-index
or overflow
issue in a complex app, and you'll spend most of your time in your developer tools trying to find out why properties aren't being applied to an element – or why they are, but nothing is happening. Sometimes it's because of a parent's style. Sometimes it's because of your browser. Sometimes it's because you forgot to add another property on the same element.
JavaScript gives you a debugger and console logs and error messages to trace issues, but CSS remains completely silent.
Stranded
As pages change over time, some rules will be stranded. In other words, we'll delete <div class="foo">
but forget to remove .foo
. Then the rules just sit there, and the next time we visit the file, we have to guess if they're useful or not.
This isn't a huge problem, except it's mental overhead for anyone looking at the file. Many older websites have thousands of declarations that aren't being used, but developers are too scared to remove or change them because it might break some element they'd never even think about checking. I've especially seen this with deeply-nested SCSS — it makes sense when you're writing it, but once you lose context, it's hard to get it back.
I know there are people who'll say, "but we can add tools to strip them out during the build process". But that adds more complexity, requires another build step, and leaves a bunch of unreferenced files sitting in your repo. Or you can run tooling to find unused declarations, but most people don't do that.
Grain of the web
A good web designer understands the medium they're working with and creates designs that work with the web's grain. In other words, the quality of the designs you're given correlates with the quality of the implementation you can produce.
Take one look at the Dribbble homepage, and you'll see some designs that would work well, and others that would be a nightmare to maintain. (A design might look beautiful, but it would take days and thousands of lines of CSS to implement it properly, to make it work on different screen sizes, and so on. Because stylesheet quality degrades exponentially, you'll need to be cautious when developing – and inevitably updating – this kind of UI. It'd be like building a house from a blueprint that looked great but wasn't structurally sound.) At some point, every developer will need to build this kind of UI. Stakeholders will bikeshed the design, and you'll have to deal with it. When that happens, try to isolate it as best as possible from the rest of your codebase. But if at all possible, try to find a compromise.
How CSS can be fixed
I think that we, as an industry, are closer than ever to writing CSS that's actually good. In my opinion, the two best tools we have are utility classes and modular styles.
Utility classes are minimalistic CSS classes that are created for the purpose of adding one type of style to an element. For instance, if your UI has rounded borders on some of its elements, you could create a utility class:
.rounded {
border-radius: 8px;
}
You can string utility classes together to write your elements:
<div class="rounded bordered padded">...</div>
I hated the idea of utility classes until I used them in a big, real-world application. Here are some of my old arguments against them, with my new thoughts:
-
"But they make my HTML ugly!"
If anything, seeing
div class="absolute top right"
takes me closer to the truth: that the div is in the top right of its relative ancestor, so I don't even need to check the CSS. -
"Their names are too confusing!"
This is what I'd originally thought about Tailwind – until I dug into it. The learning curve took me about 15 minutes. Most class names are easy to remember; to set
display: flex
, you useclass="flex"
. To setposition: relative
, you useclass="relative"
. Commonly-used properties are shortened, likepadding-bottom
topb
. If you don't know a property, you can easily search the docs to find it. (Just go to tailwindcss.com and hit/
, and you can search for any property you need.) -
"They're just another version of inline styles!"
Inline styles aren't bad because they're inline; they're bad because they aren't DRY. Utility classes are a single source of truth for any style, so you can edit and change them and your whole UI will respond appropriately. There's no duplication here, no need to edit a value in multiple places. (Example: If your designer asks you to change the shade of red you're using across an entire app, you only need to change one line, and it'll be updated everywhere. That's powerful.)
A good utility framework is a huge boon for a multi-developer project. Its benefits far outweigh the slight change to HTML and the learning curve, and they make your stylesheet size grow at a much slower rate. In addition, I've never seen well-crafted utility classes become debt.
More thoughts on utility classes
If you're working on a commercial project, I recommend using a prebuilt utility framework. However, on personal sites, it can be fun to write your own utility classes.
Creating your own utility classes
If you don't want to use a pre-built utility framework, my recommendation would be to prevent any one class from doing too much. For instance, a class like this isn't a great idea:
.entry {
display: block;
padding: 1rem;
margin-bottom: 1rem;
}
Instead, split it into multiple classes like this:
.block {
display: block;
}
.margin-bottom {
margin-bottom: 1rem;
}
.padding {
padding: 1rem;
}
This might seem counterintuitive at first, but now you can use any combination of the three across your application. This means you won't have to worry about styles overwriting each other or use !important
.
Modular styles work in the opposite direction of utility classes: instead of assigning global, generic styles, it assigns localized, highly specific styles to each component. There are many packages for this, two of my favorites being Styled Components and CSS Modules. Let's take a look at modules:
Primer on CSS Modules
A CSS Module is a CSS file in which all class names and animation names are scoped locally by default.
CSS Modules lets you create a small CSS file for each one of your components, keeping your styles separated and organized. Take this example:
import React from 'react'
import styles from './Example.module.css'
const Example = () => <div className={styles.foobar} />
export default Example
.foobar {
width: 10px;
height: 10px;
background: red;
}
className={styles.foobar}
will become something like .foobar_2x86z
in the browser. Even if there was another file with the foobar
class elsewhere in the application, its styles would stay separate, because it would get its own transformed class, like .foobar_1i3lo
.
When combined with CSS variables and componentization, this can handle mostly every use case you'll need. It allows you to use simple naming conventions, enforces organization, and prevents every issue you used to have with global namespacing. Just make sure you only use unnested classes for styling; tags and nested identifiers will just cause the same problems you're trying to escape from.
Next Steps
If you want to pay off the CSS debt in your application, I have a few recommendations.
First, take a look at ways that you could adopt utility classes and/or modular CSS. Utility classes are global, and modular CSS is local, and they work well together. For instance, you could combine a pre-built utility framework with CSS Modules. Use the utility classes for everything you can, and use CSS Modules when you need ultra-specific behavior for certain designs (like keyframe animations or complex hover states).
Second, look at condensing your current CSS. I did this at a previous company and decreased their CSS files by 80%, then we used modules to style the rest. (Refactoring CSS is a tedious process and works best during a redesign. If you're interested, let me know and I can write a post about this.)
Once you start condensing and refactoring your CSS, I also have a few golden rules for working on team projects:
!important
can usually be followed to find CSS debt.- Try to cut back on how often, and how much, you use nesting. If you can reject it altogether, that's even better – but it's probably unrealistic for most apps.
- Don't use inline styles unless you have a good reason to.
- Only style with classes. IDs take precedence over classes, and tags are easy to "accidentally style" in the future. Basically, if you don't point a gun at your foot, you can't shoot yourself in the foot.
CSS is an open-ended language, but you can use the suggestions from this post to give yourself some footing. They'll help you build UIs that aren't just more scalable, but also more reliable, allowing you to finish quicker and have more fun in the process.