webpack + postcss + cssnext
Obligatory preamble
I’m a Sass man (ˢᵏᶦᵇᵃᵇᵒᵖᵇᵃᵈᵒᵖᵇᵒᵖ). I’ve been hooked on Sass for over 5 years, and I still love it. This isn’t an everybody-should-use-PostCSS post, because Sass still holds some advantages, and despite what Medium tells you, your stack doesn’t need to be rewritten every other month. However, I do gain several things from the switch.
First, I get to abandon a proprietary Sass syntax ($color-blue: #32c7ff;
) in favor of a
standards-backed format (--color-blue: #32c7ff;
). I can actually write CSS again, and not an
abstracted substitute. This is not only far more future-proof, but as CSS evolves, I’m taking
advantage of all the newest features (which also lets you do
cool stuff like this).
Second—and this is beyond the scope of this post—by writing more modular CSS, I’m less limited in how I load those styles, either directly from the browser or in JS.
Third, PostCSS boasts some really fast compile times (up to 36× faster than Ruby Sass). While this isn’t reason enough to use a technology, it sure is a nice bonus once you’ve decided to switch.
CSS is undergoing a much subtler shakeup than JavaScript has been for the past year and a half. But it’s still being shaken up, nonetheless, as the way we write markup and style is changing due to the former.
The setup
Note: I’m using ExtractTextPlugin to save the CSS as a separate file; you can use
_style-loader_
instead if you’re loading CSS with JS.
Command Line
Add webpack, css-loader, file-loader (we’re not using it directly, but it’s a dependency), PostCSS, postcss-loader, cssnext, and postcss-import. Run the following from your command line:
yarn add --dev webpack extract-text-webpack-plugin css-loader file-loader postcss postcss-loader postcss-cssnext postcss-import
Also install webpack globally (npm i -g webpack
) if you haven’t already—it’ll make compiling
simpler unless you
set up NPM scripts.
webpack.config.js
We’ll add something like this to webpack.config.js
(JS config largely omitted):
const webpack = require('webpack');
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
context: path.resolve(__dirname, 'src'),
entry: {
app: './app.js';
},
module: {
loaders: [
{
test: /.css$/,
use: ExtractTextPlugin.extract({
use: [
{
loader: 'css-loader',
options: { importLoaders: 1 },
},
'postcss-loader',
],
}),
},
],
},
output: {
path: path.resolve(__dirname, 'dist/assets'),
},
plugins: [
new ExtractTextPlugin('[name].bundle.css'),
],
// …
};
Note that we’ve set importLoaders: 1
on css-loader
. We’re setting this because we want PostCSS
to git @import
statements first
(longer explanation here).
postcss.config.js
This is just my personal preference: using a global postcss.config.js
file in the project root.
Sure, you can configure this all in webpack, and some may find that more convenient. But I find this
method to be a little less hassle. Create a postcss.config.js
file in your project root:
module.exports = {
plugins: {
'postcss-import': {},
'postcss-cssnext': {
browsers: ['last 2 versions', '> 5%'],
},
},
};
Pay attention to how postcss-import
comes first.This is the difference between cssnext
working or not. What this does is resolve @import
statements in your CSS first, and then take
all your CSS in as one file and run all of it with cssnext. If you swapped the order, cssnext would
only process your entry file and none of your @import
ed files would be autoprefixed/transpiled.
The browsers
option is for Autoprefixer, which comes
built-in. If you haven’t used this, this prevents you from writing browser prefix-less CSS.
This__ is how your CSS should be. If you know what a
[browserslist](https://github.com/ai/browserslist)
file is
and you’re using one already, you can actually use that instead of declaring that here.
Developing
With the setup complete, we’ll start a file at src/app.js
:
import styles from './app.css';
And our src/app.css
file can look something like this:
/* Shared */
@import 'shared/colors.css';
@import 'shared/typography.css';
/* Components */
@import 'components/Article.css';
With cssnext, we can write stuff like:
/* shared/colors.css */
:root {
--color-black: rgb(0, 0, 0);
--color-blue: #32c7ff;
}
/* shared/typography.css */
:root {
--font-text: 'FF DIN', sans-serif;
--font-weight: 300;
--line-height: 1.5;
}
/* components/Article.css */
.article {
font-size: 14px;
& a {
color: var(--color-blue);
}
& p {
color: var(--color-black);
font-family: var(--font-text);
font-weight: var(--font-weight);
line-height: var(--line-height);
}
@media (width > 600px) {
max-width: 30em;
}
}
And cssnext takes all of our variables, nested selectors, media queries, and all our other stuff
(read the docs to see all it supports) and exports it out to a
dist/assets/css.bundle.css
whenever we run webpack
or webpack -p
:
.article {
font-size: 14px;
}
.article a {
color: #32c7ff;
}
.article p {
color: rgb(0, 0, 0);
font-family: 'FF DIN', sans-serif;
font-weight: 300;
line-height: 1.5;
}
> @media (min-width: 601px) {
.article {
max-width: 30em;
}
}
And thanks to webpack, we still get all of the great module splitting and tree shaking.
Kicking it Up a Notch
I had a more demanding use case where I needed to load my webfont stack with one CSS manifest (separate from my other styles, for performance—similar to @zachleat’s Asynchronous Data URI font-loading method or Google Fonts, minus the subsetting & browser sniffing). The advantage of this is two-fold:
- It reduces 5 webfont header requests into 1 CSS request, optimizing asset delivery
- This project used a CDN, which means webfont files won’t load cross-domain anyway
My stylesheet lives in ./src/App.css
, and I declared all my @font-face
rules in
./src/Font.css
. This is what my webpack.config.js
file looked like (_note I was also using
_url-loader_
— __yarn add --dev url-loader_
):
const webpack = require('webpack');
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const extractStyles = new ExtractTextPlugin('app.css');
const extractFonts = new ExtractTextPlugin('fonts.css');
module.exports = {
context: path.resolve(__dirname, 'src'),
entry: {
app: './App.js',
},
module: {
loaders: [
{
test: /App.css/,
use: extractStyles.extract({
use: [
{
loader: 'css-loader',
options: { importLoaders: 1 },
},
'postcss-loader',
],
}),
},
{
test: /Font.css/,
use: extractFonts.extract({
use: 'css-loader',
}),
},
{
test: /.(woff|woff2)$/,
use: ['url-loader'],
},
],
},
output: {
path: path.resolve(__dirname, 'dist/assets'),
filename: '[name].bundle.js',
},
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
},
plugins: [extractStyles, extractFonts],
};
My ./src/Font.css
file isn’t anything special, but just for clarity:
@font-face {
font-family: 'FF DIN';
font-weight: 400;
font-style: normal;
src: url('./assets/ffdin-regular.woff');
}
/* … More fonts like this */
In my ./src/App.js
file, two lines connect the remaining dots:
import styles from './App.css';
import fonts from './Font.css';
Now when I run webpack -p
it will export 2 files (in addition to my JS):
./dist/assets/styles.css
./dist/assets/fonts.css
Note: I specified my output filenames up at the top of the file with _const exportStyles_
and
_const _``_exportFonts_
. They’ll go in the same folder as _output.path_
, along with
your JS. No, it doesn’t make much sense to me, either, but there you have it.
This setup is rightfully confusing, and I’ll admit someone smarter than me can come up with a
smarter config—if, for example, I changed the name of App.css
or Font.css
, I’d have to update
this config, too.
But regardless, the most important thing to grasp is I have 2 separate new ExtractTextPlugin
instances—one for styling and one for fonts. You need one **new ExtractTextPlugin**
instance
for every separate CSS bundle you want. If you tried to use the same instance for both, the plugin
would simply lump those both together.
Lastly, you can see I’m handling the base64 embedding of .woff
and .woff2
files with
url-loader
. If I didn’t want to embed the file, I could use
[file-loader](https://github.com/webpack/file-loader)
instead. I’m only loading those 2 formats
because it’s 2017, and that’s all you need for modern browsers now.
Result
I’m extremely happy with the end result of this, because not only do I have my webfonts loading asynchronously, but my CSS is also bundled separately. I get to have all the benefits of using webpack, but my app is also progressive and still works just fine even if JavaScript is disabled on the client end.
Caveats
cssnext isn’t a 1:1 replacement for Sass, so be sure to temper your expectations before committing and diving in. Principally among the features missing are Sass’s powerful function and mixins abilities. Plugins like PreCSS attempt to bridge the gaps and allow Sass syntax in the PostCSS ecosystem. But PreCSS isn’t a perfect replacement, and there may invariably be one or two things you need to modify with a Sass codebase to get it working.