Adam's Blog

Loading css, css-modules, and Sass with webpack

The css ecosystem is immense and, at times, intimidating. This post will start at the beginning. We'll go over loading basic css with webpack, then move on to css modules, and wrap up with Sass. If you have some experience loading css in webpack-based web applications, some of this may be old news for you.

Note that while the code samples in this post use React, none of the concepts are specific to it in the least. Also, this post does not cover css-in-js, for the simple reason that I haven't yet gotten around to diving into that ecosystem; I'm hoping by the time I do, it'll be a bit less crowded :)

Starting at the beginning: basic css loading

Let's say we're rendering this component.

const Component = () => (
  <div className="pane">
    <span>Pane Content</span>
    <ul className="list">
      <li className="list-item">Item 1</li>
      <li className="list-item">Item 2</li>
      <li className="list-item">Item 3</li>
    </ul>
  </div>
);

Without accompanying styles, it'll look something like this.

Unstyled Component

Let's add some basic styling. Let's start simple, and have the JS module this component sits in import a css file, with standard, global styling rules. The import will look like this

import "./styles.css";

Let's create that file, and add some purposefully ugly styles

.pane {
  background-color: green;
  max-width: 300px;
}

.pane span {
  color: purple;
}

.list {
  margin-left: 20px;
}

.list-item {
  list-style-type: lower-greek;
}

As we have it, this code leads to the following webpack error

Loading error

webpack only knows how to load standard JavaScript by default. To add other content, like css, we need to tell webpack how to handle it. Let's do that now. First, install the mini-css-extract-plugin and css-loader plugins, using your favorite package manager, in your favorite cli.

Now load the mini css extract plugin in your webpack.config.js file.

const MiniCssExtractPlugin = require("mini-css-extract-plugin");

Now, in the same config file, there should be a module object at the top of the config object, and somewhere under that, there should be a rules array. If either are missing, add them. Now, under rules, add this entry

{
  test: /\.css$/,
  use: [MiniCssExtractPlugin.loader, "css-loader"]
},

Finally, under the plugins array, also at the top level of your webpack.config object (add it if necessary), add this

new MiniCssExtractPlugin({
  filename: isProd ? "[name]-[contenthash].css" : "[name].css"
});

If you're new to webpack, and that went a little too fast for you, check out the webpack docs for a slower treatment of this.

Now, if we restart webpack, and reload our page, we should see this disgusting, but technically correct result

Unstyled Component

"Success" - hooray.

Adding CSS Modules

Right now we have code-split css. We can load css within any JavaScript module which uses it, and the CSS will only load if, and when that JS module is loaded. However, the css is global; if we add style rules for list-item in any other .css file, they'll conflict with the styles in this one. Wouldn't it be nice if we could have these styles be scoped only to the JS module which loads them? We can, with css-modules.

css-modules are a pre-processor step on your css file. It runs through all of your class names, and makes them unique. Moreover, it creates an exported object from the css file, on which these unique class names are exposed.

To enable this behavior, we'll first tweak the webpack loader rule, like so

{
  test: /\.css$/,
  use: [
    MiniCssExtractPlugin.loader,
    {
      loader: "css-loader",
      options: { modules: true, exportOnlyLocals: false }
    }
  ]
};

Note that the exportOnlyLocals may not be needed, as it should be the default; however, I've seen weird errors without it.

As we have it, our styles will still be loaded, but exposed behind dynamically generated class names. To apply them to our component at development time, we need to grab them off of the css module. Let's do that now

import styles from "./styles.css";
const { pane, list, ["list-item"]: listItem } = styles;

const Component = () => (
  <div className={pane}>
    <span>Pane Content</span>
    <ul className={list}>
      <li className={listItem}>Item 1</li>
      <li className={listItem}>Item 2</li>
      <li className={listItem}>Item 3</li>
    </ul>
  </div>
);

We now import an object from the css file. The keys of this object are the class names we wrote originally in the css file, and the property values are the dynamically generated class names. Note the weird syntax around the list-item class. JavaScript identifiers cannot be hyphenated, so you'll either need to alias it, or just use valid JS names in your css modules.

Edit - after publishing this, Marc Bernstein pointed out on Twitter that css-loader has a camelCase option that will convert hyphenated class names to camel-cased equivalents. You can read the docs on it here

Applying everything like so should reveal the same ugly output as before

Unstyled Component

Best of Both Worlds?

So far so good, but what if, like me, you think global styles aren't so bad, sometimes. What if you have some styles that you plan to be universal in your app, used almost everywhere, and manually importing them as dynamic values just isn't worth the effort? Examples might include a .btn, .table, or even a .pane class. What if the .pane class is intended to be used far and wide, with exactly one meaning. Can we make that class (and others) be global, while using css-modules for module-specific stylings, like our list classes, above.

You can, and you have two options: you can define each and every global css class with :global() (see the css-modules docs for more info), or, my preferred approach, you can use a naming scheme to differentiate global css files from css-modules.

Specifically, what if we decide that files ending with .module.css are css modules, and any other .css file is an old-school, global css file. webpack makes this possible with the oneOf construct. Basically, turn your entry in the rules section, from before, into this

{
  test: /\.css$/,
  oneOf: [
    {
      test: /\.module\.css$/,
      use: [
        MiniCssExtractPlugin.loader,
        {
          loader: "css-loader",
          options: { modules: true, exportOnlyLocals: false }
        }
      ]
    },
    {
      use: [MiniCssExtractPlugin.loader, "css-loader"]
    }
  ]
};

This tells webpack to match .css files against the first rule that's valid. If the .css file ends in .module.css, use css modules. Else, use global styles. Let's try this out.

Let's rename our original styles.css to be styles.module.css, and remove the .pane styles. It'll look like this now

.list {
  margin-left: 20px;
}

.list-item {
  list-style-type: lower-greek;
}

Now, let's add a new styles.css file, and put our pane styles from before, into it

.pane {
  background-color: green;
  max-width: 300px;
}

.pane span {
  color: purple;
}

Now, we'll import the global css styles (probably in one place, at the root of our application) like we did originally

import "./styles.css";

and we'll grab the dynamic class names for the things we left in the css module, as we did above

import styles from "./styles.module.css";
const { list, ["list-item"]: listItem } = styles;

const Component = () => (
  <div className="pane">
    <span>Pane Content</span>
    <ul className={list}>
      <li className={listItem}>Item 1</li>
      <li className={listItem}>Item 2</li>
      <li className={listItem}>Item 3</li>
    </ul>
  </div>
);

If all went well, everything should look identical to before.

Getting Sassy

Lastly, let's say you want to add Sass. Being subject to normal developer constraints, you certainly can't convert each and every css file to be scss, so you want to support both, side-by-side. Fortunately this is the easiest part of the post. Since scss is a superset of css, we can just run all .css and .scss files through the sass-loader as a first step, and leave all the rest of the css processing the same, as before. Let's see how.

First, we'll install some new dependencies

npm i node-sass sass-loader --save

Now, we'll add a slight tweak to our webpack rules

{
  test: /\.s?css$/,
  oneOf: [
    {
      test: /\.module\.s?css$/,
      use: [
        MiniCssExtractPlugin.loader,
        {
          loader: "css-loader",
          options: { modules: true, exportOnlyLocals: false }
        },
        "sass-loader"
      ]
    },
    {
      use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"]
    }
  ]
};

We added sass-loader as a new, first loader (loaders are processed from right to left). Did you catch the other change? It's the two ?'s in the test properties. ? means optional in regular expressions, so all this means is, our rules now apply to both .css and .scss files. Plain .css files are processed by the sass-loader, but again, css is a subset of scss, so this is effectively a no-op.

To make sure things still work, let's convert our css files to scss, add some Sass, and maybe even tweak the styles to be even cooler, and make sure everything still works.

First, for styles.css, we'll rename it to styles.scss, and add a few upgrades.

$paneColor: pink;
$paneSpanColor: purple;

.pane {
  background-color: $paneColor;
  max-width: 300px;
}

.pane span {
  color: $paneSpanColor;
}

Now, we'll rename styles.module.css to be styles.modules.scss and make it look something like this

$listStyleType: armenian;

.list {
  margin-left: 20px;
}

.list-item {
  list-style-type: $listStyleType;
}

after re-starting our webpack process, our cool component should look like this

Unstyled Component

Concluding thoughts

In the end, a few lines of webpack config allowed us to easily load global, or scoped css, with optional sass processing in either case. Of course this is only scratching the surface of what's possible. There's no shortage of PostCSS, or other plugins you could toss into the loader list.

Happy Coding!