Skip to main content

CSS

There is no one single "standard" way of dealing with CSS in webpack. But there is no confusion about this fact either. Webpack knows nothing about CSS and there is no Ecma rule that describes how CSS file should/could be referenced from JavaScript. Yet, there are plenty of tools to deal with CSS processing for the sake of feature support, browser support, maintainability, etc. We'll cover one example from every main aria. That should be enough to extend the setup for any particular situation.

The setup contains 4 different ways to use CSS within a webpack project: plain CSS, CSS processors, CSS modules and CSS-in-JS. Chances are only one or two options are needed for the particular web app. Pick a preferred option and extend as needed.

Plain CSS

Let's start with the simplest option - support for the plain CSS files. For this option we are going to need following packages:

Dependencies

npm i css-loader style-loader mini-css-extract-plugin @types/mini-css-extract-plugin -D --save-exact
  • css-loader - provides support for the import functionality for CSS files from JavaScript files
  • style-loader - provides support for injecting CSS sources into the DOM (header tag)
  • mini-css-extract-plugin - provides support for extracting CSS sources into separate files
  • @types/mini-css-extract-plugin - mini-css-extract-plugin type definitions

Webpack configuration

First, we need to extend parts file to make sure the rest of the webpack config files can reuse it.

webpack/parts.ts

import path from 'path'
import webpack, { Entry, Output, Node, Resolve, Plugin, RuleSetRule, Options } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin';
import HtmlWebpackHarddiskPlugin from 'html-webpack-harddisk-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin'

export const distFolder = () => path.resolve(__dirname, '../dist')

export const getParts = () => ({
context: path.join(__dirname, '../src'),

entry: {
main: './index',
utils: './utils/index'
} as Entry,

output: {
path: path.resolve(distFolder(), 'js'),
filename: '[name].bundle.js',
publicPath: '/js/'
} as Output,

node: {
fs: 'empty'
} as Node,

resolve: {
extensions: ['.ts', '.js', '.json']
} as Resolve,


rules: [{
// Include ts/js files.
test: /\.(ts)|(js)$/,

exclude: [ // https://github.com/webpack/webpack/issues/6544
/node_modules/,
],
loader: 'babel-loader',
}] as RuleSetRule[] ,

plugins: [
new webpack.EnvironmentPlugin(['NODE_ENV']),
new HtmlWebpackPlugin({
chunksSortMode: 'dependency',
filename: '../index.html',
alwaysWriteToDisk: true
}),
new HtmlWebpackHarddiskPlugin(),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [
path.resolve(__dirname, '../dist/css/**/*'),
path.resolve(__dirname, '../dist/js/**/*')
],
verbose: true
})
] as Plugin[],

optimization: (cacheGroups?: { [key: string]: Options.CacheGroupsOptions }): Options.Optimization => ({
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/i,
name: 'vendors',
chunks: 'all'
},
...cacheGroups || {}
}
},
runtimeChunk: {
name: 'vendors'
}
})
})

We have removed the module key for the sake of the plain rules array. We also made CleanWebpackPlugin delete content from explicitly specified folder list. Also, we changed optimization to be a lambda that takes an optional object of key - value pairs for the potential cacheGroups parameter extension.

webpack/webpack.config.dev.ts

For the webpack/webpack.config.dev.ts, just some minor customizations are needed.

// ...
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},

// ...

optimization: parts.optimization()

// ...

We changed the module key to be explicit and made sure all the rules from the parts file are provided. As for the CSS itself, we have a new rule. It is applied to all the files with the css extension. Those files are served by two loaders. In webpack, loaders are executed from right to left. css-loader implements standard JavaScript import and parses CSS file content, where style-loader moves the content to the DOM. Actually, it creates a style tag inside the head tag.

webpack/webpack.config.ts

Change webpack/webpack.config.ts:

import webpack, { Configuration } from 'webpack'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import Environments from './environments.js'
import { getParts } from './parts'

const environments = Environments()
console.log(`Running webpack config for environment: ${environments.current}`)

const parts = getParts()

const config: Configuration = {

context: parts.context,

mode: 'production',

entry: parts.entry,

output: parts.output,

resolve: parts.resolve,

module: {
rules: [
...parts.rules,
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},

node: parts.node,

plugins: [
...parts.plugins,

new webpack.SourceMapDevToolPlugin({
filename: '[name].js.map',
lineToLine: true
}),

new MiniCssExtractPlugin({
filename: '../css/[name].css',
chunkFilename: '../css/[name].css',
}),
],

optimization: {
...parts.optimization({
styles: {
name: 'styles',
test: /\.css$/,
chunks: 'initial',
enforce: true,
minSize: Infinity
}
}),

minimize: true,
removeAvailableModules: true,
}
}

export default config

Instead of the style-loader used for the development flow, we substitute it with the MiniCssExtractPlugin. This plugin is used to extract all CSS content into separate files, according to how CSS is imported from the JavaScript code. It also accounts for the code split files as well, so that CSS content will be loaded only when corresponding JavaScript chunk is loaded. Another change is around the optimization. We are adding another cache group for CSS files that represents a separate bundle.

  • name - represents a chunk name (gets translated in to the file name for a chunk file)
  • test - regular expression to filter out files included in a chunk (*.css file only)
  • chunks- specifies what chunks are included into the bundle (initial - static chunks, all - static and dynamic chunks)
  • enforce - enforce bundle rule
  • minSize - minimum size fo the bundle to be considered a separate file

minSize is set up for Infinity. That means the min size rule will never be satisfied and a separate bundle file will never be created. style cache group config is here only for the demo purposes. It shows an example of the technique to enforce chunk rules for CSS or any another content type. Removing minSize value setting creates a separate chunk file specifically for CSS content loaded from files rendered during first application load.

Example

src/app/index.css

body {
background-color: azure;
}

src/app/index.ts

import './index.css'
import { toCapital } from '@utils'

export type DynamicImport = Promise<{ default: () => string }>
///...

CSS Processors

CSS processors let you generate CSS code base on an enhanced CSS or alternative CSS syntax. There are many different processors and the actual choice depends on a level of experience and comfortably of a team and a feature set provided by the processor itself. For the rest of the explanation, PostCSS is used but the steps to configure a different processor should be very similar, as long as an appropriate webpack loader exist such as sass-loader for SASS and less-loader for LESS.

Dependencies

npm i postcss-loader postcss-preset-env cssnano -D --save-exact
  • postcss-loader - webpack loader for PostCSS processor
  • postcss-preset-env - collection of PostCSS polyfills determined by the browser support
  • cssnano - CSS compressor/optimizer

Webpack configuration

webpack/webpack.config.dev.ts

Alter webpack/webpack.config.dev.ts content:

//...
rules: {

//...
{
test: /\.pcss$/,
use: ['style-loader', 'postcss-loader']
}
//..
}
//...

For the dev workflow, we introduced the rule for handling *.pcss files with two loaders. First loader, postcss-loader is similar to css-loader in that it knows how to interpret JavaScript module import and content parsing for post-css files. From here, just as before, the content is passed to the style-loader for further processing.

webpack/webpack.config.ts

Alter webpack/webpack.config.ts content:

//...
rules: {
//...
{
test: /\.pcss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
}
//..
}
//...

For production, we are using postcss-loader as well, but in order to place processed CSS content in separate files, MiniCssExtractPlugin loader is needed. MiniCssExtractPlugin does not work directly with post-css loader, so we have to use css-loader as an intermediary step.

PostCSS configuration

postcss.config.js

const postcssPresetEnv = require('postcss-preset-env');
const cssnano = require('cssnano');
const Environments = require('./webpack/environments');

const prodPlugins = [
cssnano({
preset: 'default'
})
];

module.exports = function(ctx) {

const environments = Environments(ctx.env);

return {
plugins: [
postcssPresetEnv({
features: {
'nesting-rules': true,
'color-mod-function': { unresolved: 'warn' }
}
})]
.concat(environments.isProduction ? prodPlugins : [])
};
}

PostCSS configuration mostly consist of plugins setup. In our case we have configured postcss-preset-env to support CSS rules for nesting and the support of color-mod function for color values definition. We also included CSS content optimizer cssnano but only for production usage.

Example

src/app/feature/index.pcss

root {
--font-size: 30px;
}

li {
font-size: var(--font-size);
}

src/app/feature/index.ts

import './index.pcss'

export default (): string => 'feature'

CSS modules

Another useful technique of managing CSS content is called CSS Modules. A CSS Module is nothing but a CSS file with all the class names applicable to a local scope only. It's like a namespace for that particular file. It is achieved via a library support and a webpack build step. During webpack compilation, CSS file content is proceeded in the way that all the class names are substituted with unique randomly generated values scoped to the current file. Later, those values could be used within the JavaScript code as a way to refer to a particular CSS style definition.

Dependencies

So far, we have been using two flavors of CSS, plain and PostCSS enhanced. We can configure CSS modules for CSS or PostCSS files only, or for both. For plaint CSS, modules are supported by css-loader, for PostCSS by postcss-loader. There is only one additional dependency to be included.

npm i css-modules-typescript-loader -D --save-exact
  • css-modules-typescript-loader - webpack loader that creates TypeScript definition files based for CSS module file content.

By default TypeScript compiler does not have any information on what content of the CSS module file is referenced by the code. This breaks TypeScript compilation step. We could have fallen back to require method instead of Ecma import syntax for CSS modules, but that is more of the workaround. To make TypeScript compilation go smooth, we are introducing another loader that reads CSS module file content and generates TypeScript definition file with all the exported class names, hence making the compiler step pass.

Webpack configuration

We already configured webpack to process *.css and *.pcss files. For CSS modules, different file extension is needed to prevent loaders collision. As a convention, we can use *.module.css extension for plain CSS modules and *.module.pcss for PostCSS modules.

webpack/webpack.config.dev.ts

Final content change for webpack/webpack.config.dev.ts:

    //...
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
exclude: /\.module\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.pcss$/,
exclude: /\.module\.pcss$/,
use: ['style-loader', 'postcss-loader']
},
{
test: /\.module\.css$/,
use: [
'style-loader',
'css-modules-typescript-loader',
{
loader: 'css-loader',
options: {
modules: true,
}
}
]
},
{
test: /\.module\.pcss$/,
use: [
'style-loader',
'css-modules-typescript-loader',
{
loader: 'css-loader', options: { modules: true, importLoaders: 1 }
},
'postcss-loader'
]
}
]
}
//...

We introduced two additional rules, one for CSS modules - /\.module\.css$/, another for PostCSS modules - /\.module\.pcss$/. Webpack applies rules from top to bottom, so to prevent a rule from being applied to a different file type due to file extension collisions (ex. regex for CSS file includes searches for the regex for CSS module files), we have to extend existing rules. For CSS, we introduced exclude clause to make sure it is not applied to CSS modules (exclude: /\.module\.css$/). Same config change we applied to PostCSS rule. As for the CSS/PostCSS module rules, we use an appropriate loader that pipes content to the css-modules-typescript-loader. css-modules-typescript-loader uses css-loader underneath, so all the options provided will be transferred to it. We use module: true option for CSS modules and an additional importLoaders: 1 for PostCSS module.

importLoaders option is a necessity for playing nicely with how loaders cooperate within webpack: https://github.com/webpack-contrib/css-loader/issues/228

webpack/webpack.config.ts

Final content for webpack/webpack.config.ts:

//...
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
exclude: /\.module\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
},
{
test: /\.pcss$/,
exclude: /\.module\.pcss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
},
{
test: /\.module\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-modules-typescript-loader',
{
loader: 'css-loader',
options: {
modules: true,
}
}
]
},
{
test: /\.module\.pcss$/,
use: [
MiniCssExtractPlugin.loader,
'css-modules-typescript-loader',
{
loader: 'css-loader', options: { modules: true, importLoaders: 1 }
},
'postcss-loader'
]
}
]
}
//...

For the production setup, situation is very similar. But instead of the style-loader we are using the loader provided by MiniCssExtractPlugin to extract generated CSS content into separate files.

Example

src/index.module.css

item-regular {
background-color: azure;
}

src/index.module.css.d.ts (auto-generated)

interface CssExports {
'item-regular': string;
}
declare var cssExports: CssExports;
export = cssExports;

src/index.ts

//...
import* as cssStyles from './index.module.css'
import * as pcssStyles from './index.module.pcss'
import * as styles from './index.style'

const f = func()
f.next()
let count = 0

const list = createList(styles.list)
list.className = styles.list
//...

CSS-in-JS

CSS modules technique was all about generating random names for CSS classes and being able to refer to them from JS code. CSS-in-JS pattern takes this approach to the next level. Instead of composing CSS by manipulating CSS code itself, it is composed using JavScript code. From now, CSS styles are defined in JavaScript as strings or objects with key - value pairs simulating CSS rules. So how does final CSS get created? It depends on when the library providing CSS-in-JS functionality executes the transformation to plain CSS code. If that happens in run time, we get style tag in the head of the page attached dynamically as components get rendered. If the transformation happens during the webpack compilation, generated code becomes part of a separate CSS file loaded by the browser during application execution.

For our setup, a library that provide dynamic CSS code generation during application runtime is used. If different approach is preferred, refer to the following list of libraries

Dependencies

npm i typestyle -D --save-exact

We will be using typestyle for the sake of TypeScript support and zero configuration.

Example

src/index.style.ts

import { style } from 'typestyle'

export const list = style({
borderBottom: 'black 1px solid'
})

Linting

Just like we applied linting to TypeScript files, it could be applied to .css/.pcss files as well.

Dependencies

npm i stylelint stylelint-config-standard -D --save-exact
  • stylelint - linting library for CSS code
  • stylelint-config-standard - standard rule set for stylelint

Configuration

.stylelintrc

{
"extends": "stylelint-config-standard",
"rules": {
"indentation": [4]
}
}

Add linting scripts to package.json

"lint": "npm run lint:sources && npm run lint:styles",
"lint:sources": "eslint './src/**/*.ts' --max-warnings=0",
"lint:fix:sources": "npm run lint:sources -- --fix",
"lint:styles": "stylelint 'src/**/*.css' 'src/**/*.pcss'",
"lint:fix:styles": "npm run lint:styles -- --fix",

Final version

Reference implementation