webpack + postcss + cssnext

© http://www.zazzle.com/stevenfrank

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 @imported 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:

  1. It reduces 5 webfont header requests into 1 CSS request, optimizing asset delivery
  2. 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.