Anvil Engineering
Posted on April 29, 2022
While CSS itself is ubiquitous, the way each project uses it is certainly not. Before preprocessors like Sass and Less, there were entire methodologies, like BEM, for writing and maintaining your CSS. Even with methodologies and preprocessors to help our style writing, there is still an abundance of CSS bloat in all projects.
Enter PurgeCSS: a tool used to eliminate unused CSS from your codebase. PurgeCSS helps projects with performance and UX by reducing overall bundle size. Preprocessors, methodologies, and all of the actions we take before building our projects are wonderful, but it's unrealistic to expect them to completely solve maintainability & performance issues. Despite our best efforts as developers to keep a lean codebase, unnecessary CSS will inevitably come up during development. PurgeCSS is our fail-safe to ensure we ship the least amount of CSS that's needed.
Here at Anvil, we use styled-components. PurgeCSS functionality is essentially baked into styled-components, with a few caveats. If PurgeCSS is already included in styled-components, can you still use both? And if you can, is there any benefit to doing so?
This blog post will answer these questions, as well as:
- How PurgeCSS works
- The benefits of using styled-components
- The caveats with styled-components' 'PurgeCSS' functionality
- Discuss some competitors to PurgeCSS & why PurgeCSS is the de facto solution
- Show you how to include PurgeCSS on a Gatsby site
What is PurgeCSS and why should I care?
PurgeCSS is a build/bundle time tool to transform your CSS into the bare minimum it should be. It aims to reduce your overall bundle size by sending only the critical CSS you need, instead of all the CSS you have written and imported. You can use it with any flavor of CSS you use: Sass, Less, Tailwind, Bootstrap, Material UI, Bulma, the list goes on.
The core reason to use PurgeCSS is to improve your site's performance. I also like using it to see a 'diff' of what CSS isn't being used. It's hard to track down what CSS is or isn't being used, so after building your site with PurgeCSS it's much easier to see what classes don't belong. After finding those classes, you can safely delete them from your codebase.
How PurgeCSS works
PurgeCSS analyzes your HTML and internally keeps track of which selectors are being used or not. PurgeCSS actually analyzes other types of files besides HTML for selectors, such as template files and JavaScript. This feature is what makes PurgeCSS different from a similar solution, UnCSS, and related to a 'predecessor' solution called PurifyCSS. More on both of those later on.
After finding which selectors are actually being used, PurgeCSS analyzes your CSS files and deletes the ones that aren't used. The CSS files with only the used selectors are included in the bundle. It's important to note that this only works by comparing selectors across your HTML and CSS; it's entirely possible that you have an unnecessary CSS property and value within a rule and that is entirely up to you to find and delete. Unfortunately, it's impossible (for now) for a program to determine if a CSS property is necessary or not.
There might be cases where you want to keep CSS selectors/rules in the bundle no matter what. PurgeCSS comes with a 'safelist' option so you can configure what selectors you want to keep, even if they aren't used. You can also toggle the safelist directly in your CSS with comments, similar to ESLint's configuration comments.
Styled-components
On the other side of our titular question is styled-components. While I'm talking about styled-components specifically, the topics and concepts here apply to any CSS-in-JS provider (e.g. emotion). There is a smaller CSS-in-JS library called astroturf that aims to give the developer the best of all worlds, so the limitations I'll discuss later on don't apply there. But be careful with smaller projects/ones that claim you can have it all! You are wading through uncharted territory :)
Writing styles with styled-components, simply put, is amazing. The primary motivation to use it is to improve DX (developer experience) and through that, increase development speed. React revolutionized web development by introducing component-based development to JS, and styled-components took that a step further by tying styles directly to components. It's a natural fit, as CSS classes are meant to be reused throughout the site, much like how a component is reused in many places in your application.
Another benefit of using CSS within JS: built-in processing. Using preprocessors, leveraging dynamic props, trimming whitespace from your CSS, and even purging your CSS of unneeded selectors all take time to configure; styled-components comes with all of that 'out of the box' since it's embedded into JavaScript.
While using styled-components is certainly more powerful than CSS by itself (or its preprocessor derivatives), it comes with its own caveats and considerations:
- Do you even need JavaScript? If your project is small, you should stick to HTML & CSS. Keep It Simple, Stupid!
- Are you introducing styled-components into a legacy codebase? You should probably clean up the cruft first before introducing more complexity.
- Does styled-components really include all the functionality of PurgeCSS? Let's find out!
What's the diff between styled-components and PurgeCSS?
From the documentation for styled-components, you get 'Automatic critical CSS' out of the box:
Automatic critical CSS: styled-components keeps track of which components are rendered on a page and injects their styles and nothing else, fully automatically. Combined with code splitting, this means your users load the least amount of code necessary.
This boils down to: if a component is unused, the CSS isn't included in the bundle. Effectively, that is what PurgeCSS does. But let's understand how each technique works.
Styled-components is 100% JavaScript-based, which is how it manages to keep track of what components are rendered on the client. This makes styled-components a run-time optimization. PurgeCSS works by analyzing your content files and, based off what selectors it finds, removes rules from your CSS files before including them in the final bundle. This makes PurgeCSS a build-time optimization.
You need to consider your project's unique constraints and concerns, but in general build-time optimizations are better than run-time optimizations.
Quick note on PurgeCSS' comparison logic
Before we go any further, I want to make sure you understand how PurgeCSS does its 'magic'. While an incredible tool, PurgeCSS doesn't do anything fancy. None of the tools for removing CSS are intelligent by nature; they all remove CSS by pretty simple comparison.
In PurgeCSS' case, it builds up a list of selectors from your HTML/JS/template files. You have the power to control what selectors make it into this list via extractors, but that is considered advanced and is out of scope for this post. After building up a list of selectors, PurgeCSS compares the selectors from CSS to that list. If a CSS selector is found that doesn't exist in the generated list, remove it! That's about it.
Feature comparison: what about nested selectors?
Effectively styled-components and PurgeCSS do the same thing. Let's see if we can poke a hole in that. Consider nested/combined selectors. Let's say you have a styled component like Parent
below:
const Parent = styled.div`
width: 300px;
height: 300px;
background: yellow;
.child {
width: 100px;
height: 100px;
background: black;
}
`
function App() {
return (
<div className="App">
<header className="App-header">
<Parent />
</header>
</div>
)
}
styled-components nested selector
The class names of App
and App-header
are from create-react-app, and are irrelevant for the whole blog post. We are only focusing on parent and child styles.
But looking at what React is going to render, we don't have any element with the class name 'child' as it is nested within the Parent
component. So ideally, the .child
CSS rule would not be included in our bundle, which for styled-components is injected CSS into the head of our document. Here's what was sent:
.child rule is sent to the browser in the head
tag of our document
Bummer, so styled-components doesn't handle nested selectors. PurgeCSS should be able to remove the selector from the equivalent CSS project right? Here's that same project, using React + CSS:
import './styles/App.css'
function App() {
return (
<div className="App">
<header className="App-header">
<div className="parent" />
</header>
</div>
)
}
export default App
React component using CSS directly from App.css
.parent {
width: 300px;
height: 300px;
background: yellow;
}
.parent .child {
width: 100px;
height: 100px;
background: black;
}
CSS for our React + CSS project
And here's the resulting CSS file after running PurgeCSS:
.parent {
width: 300px;
height: 300px;
background: yellow;
}
.parent .child {
width: 100px;
height: 100px;
background: black;
}
PurgeCSS output file
Wait, we still have that selector with .child
... what's going on here? As it turns out, PurgeCSS doesn't handle descendant selectors/combinators like these well, just like styled-components. If we go back to the quick note on PurgeCSS' comparison logic, this actually makes sense. PurgeCSS isn't yet smart enough to build up nested selectors; so even though the HTML does represent .parent .child
in this case, the list of selectors are only the smallest possible, e.g.
const listOfSelectors = ['parent', 'child', '', …]
And in our CSS, does this rule have a selector from the list?
.parent .child {
width: 100px;
height: 100px;
background: black;
}
Yep, clearly we have .parent
in our selector. PurgeCSS thinks this rule is used, even though it's not because of the increased specificity with .child
.
While this is hugely disappointing, this is a known issue the PurgeCSS team is developing towards. The goal of styled-components is to provide great developer experience while styling, which has many subgoals. PurgeCSS, on the other hand, has only one goal of eliminating unused CSS to increase performance. I bet PurgeCSS will get this functionality before styled-components does.
Alternatives to PurgeCSS
As mentioned before, there are 2 alternatives to PurgeCSS: UnCSS & PurifyCSS.
PurifyCSS
PurifyCSS works much the same way as PurgeCSS: build up a list of selectors from your content and delete the CSS rules with those selectors. The downside of this project is that it scopes out all the content from your project, so if you have a CSS class called 'arrow' and you happen to have the word 'arrow' in a paragraph, PurifyCSS will consider that selector used and keep it in the outputted CSS.
This problem was one of the main goals of PurgeCSS and it solves this quite well.
UnCSS
This project is very interesting. It actually solves the problem of excess CSS the best; it does so by loading your HTML into jsdom, and then querySelector
-ing all of the selectors found in your CSS against the emulated DOM. If a selector isn't found in the emulated DOM, UnCSS will not include the CSS rule in its output.
Because UnCSS compares in the 'other direction' (CSS → content, instead of content → CSS like PurgeCSS), it removes CSS much more accurately, including our edge case of nested selectors! In the example above, document.querySelector('.parent .child')
would return null
, and that CSS rule wouldn't be included.
If UnCSS is so accurate, why do we use PurgeCSS?
What do you think is quicker, statically analyzing content for text (CSS selectors) and doing simple string comparison against CSS files or emulating the entire DOM in JavaScript, loading your HTML/CSS into it, and then running document.querySelector
on the emulated DOM for each CSS rule?
UnCSS is splendid, but very costly to run. Imagine a huge project and doing all that work, just to get a few more KB of savings! PurgeCSS is the de facto standard because out of all the solutions, it's the most bang for your buck: remove most of the unused CSS in your project, but keep your build times low.
How to use both PurgeCSS & styled-components
As of writing this, there is no way to integrate both technologies for a couple of reasons:
- Styled-components works by injecting critical CSS directly into the
head
of your HTML; the CSS files needed for PurgeCSS never exist. - PurgeCSS is primarily run with PostCSS, which is not supported with styled-components.
The second of those two reasons is very disappointing, as PostCSS is a rich ecosystem of plugins to extend your CSS' functionality and build process. PostCSS is what Babel is for JavaScript, and unfortunately styled-components is missing out on an entire suite of new functionality.
I have some good news for you though: there is a very common case where you can use both!
CSS Frameworks
I'm not sure what the stats are, but I'm willing to bet that over 50% of websites and webapps started off using a CSS framework. I'm talking about frameworks like Bootstrap, Bulma, Ant, Material UI, Skeleton, and many more.
Using a CSS framework lets developers get up and running much faster than building custom HTML/CSS for components that already exist. And including a framework in your website alongside styled-components is a breeze - add in the classic <link rel="stylesheet" href="styles.css">
with your framework's stylesheets, and those classes will be available to you globally.
But this is exactly what PurgeCSS was written for. You only need a few components from the CSS framework, not thousands of classes you'll never use. So even though we can't integrate PurgeCSS and styled-components directly, they still mesh well together to bring your bundle size to the lowest possible.
Set up PurgeCSS with Gatsby
To help you get up and running with PurgeCSS, I'll show you how to set up PurgeCSS with Gatsby. The options used here can be applied to any other PurgeCSS setup as well.
- Install gatsby-plugin-purgecss:
yarn add gatsby-plugin-purgecss
- Configure the plugin in
gatsby-config.js
, ensuring you place this plugin AFTER all your other style plugins:
{
resolve: `gatsby-plugin-purgecss`,
options: {
printRejected: true,
purgeCSSOptions: {},
},
}
Default configuration for gatsby-plugin-purgecss
; be sure to add LAST after all CSS plugins
If you import your styles directly (e.g. import './styles.css'
instead of import styles from './styles.css'
), this is all you have to do. During your gatsby build
command you'll see something like this:
The savings we got from plugging and playing the PurgeCSS plugin
And will feel much better about shipping your CSS to the browser :)
There are two edge cases I'll cover here. The first: if you don't import your styles directly. If you set class names like the following:
import styles from './styles.css'
<div className={style.mySelector} />
You have a couple of options. You can safelist the selector so it never gets purged:
purgeCSSOptions: {
safelist: ['my-selector'], // Don't remove this selector
}
Using PurgeCSS' native safelist
configuration as part of the Gatsby plugin
Or you can use a comment above your component with the correct format of the selector so it gets purged, which is the method I prefer. That way, it's easier to manage if you end up never using the selector and it can be purged safely; the safelist option is a good fail-safe, but it's a bit aggressive in keeping styles. Here's an example with the comment:
import styles from './styles.css'
// my-selector
<div className={style.mySelector} />
PurgeCSS comment to ensure the correctly formatted selector gets purged
Ant Design
The other edge case to consider is if, like us at Anvil, you use Ant Design. PurgeCSS has a hard time with Ant Design to keep the correct styles, and PurgeCSS is aware of this. Here's the solution straight from the docs, but it isn't adapted for the Gatsby plugin.
Below is how I adapted the solution for Gatsby. The gist: use the LESS/CSS files as content. Using your style files as content is a bit confusing, but this makes it so we are getting the selectors in our list of 'used' styles, so when PurgeCSS compares against the same exact files, we are guaranteed to keep those styles we need.
For any components we need all of the styles for, we use the Gatsby plugin's ignore
field to ensure nothing from those directories gets purged.
{
resolve: `gatsby-plugin-purgecss`,
options: {
// set this to true for debugging purposes; see what was removed
printRejected: false,
// ignore the antd styles you want to completely keep
ignore: [
'node_modules/antd/lib/select',
'node_modules/antd/lib/checkbox',
],
purgeCSSOptions: {
content: [
path.join(process.cwd(), 'src/**/!(*.d).{ts,js,jsx,tsx}'),
// antd doesn't mesh well w purgecss, so keep the files we use
// by listing them as content files.
// reference: https://purgecss.com/ant_design.html
path.join(
process.cwd(),
'node_modules/antd/lib/{tooltip,modal,radio}/**/*.{css,less}'
),
// utility styles from ant, let's keep it
path.join(
process.cwd(),
'node_modules/antd/lib/style/**/*.{css,less}'
),
],
extractors: [
{
// on top of listing antd files as content, we need an extractor to work
// with css/less to get the selectors
extractor: (content) =>
content.match(/([a-zA-Z-0-9-@&:{}]+)(?= {)/g) || [],
extensions: ['css', 'less'],
},
],
},
},
}
gatsby-plugin-purgecss configured to keep select antd styles
Our final CSS savings isn't as much as I wanted, but over 50% is still pretty good:
Putting it all together
Both PurgeCSS and styled-components are popular for a reason. Web development has no one 'correct' way to implement a solution, but both technologies are great additions to any project. And when the two are combined in the case of using a CSS framework, you're guaranteed to get production-ready UX, DX, stylability, and performance. Have any other CSS optimization tips? We'd love to hear from you at developers@useanvil.com. Happy coding!
Posted on April 29, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.