Better CSS with JavaScript

Learn how to modernize your CSS build using css-js-loader.

Many thanks to Izaak Schroeder for Webpack wizardry and to Mike Gazdag and the rest of the team at MetaLab for their knowledge and feedback. Also, all credit to Pete Hunt for his initial work on this problem.

At MetaLab, we are continuously striving to improve our FE development process, within which the CSS pipeline is an integral part. While tools like Sass and Stylus offer definite advantages over vanilla CSS, we have found that these tools can cause unwanted friction in a modern component-based project. We want the features of a preprocessor: variables, mixins, and functions, but we dislike how preprocessors give CSS its own context, isolated from the rest of the build process. A separate CSS build that is isolated from a project’s main JavaScript build is not an option when every component is an independent module that encapsulates its own styles. We want the code that defines our styles to share the tools and configuration with the rest of our project. We need our CSS build to run in a unified context with the JavaScript build.

Our solution is to define all of our styles in .css.js (CSS JS) modules by leveraging a variety of community tools composed through Webpack.

🚨 If you just want to see code, here’s a complete example project on github.

Background

The idea of “writing CSS with JavaScript” is far from a new concept. There is an excellent 2014 talk by Vjeux that serves as a fantastic introduction.

Fast forward a few years and there are numerous options for “CSS in JS” build systems. With no shortage of fresh ideas in this space, there are a lot of differences between the available solutions. Some solutions target inline element styles, some create <style> tags or inject inline style attributes at run-time, and some generate static .css files at build-time.

One similarity among all existing approaches is a lack of composability. Each is available as a “complete” solution that takes ownership of the CSS build process. This is in contrast to our approach, which produces a build process by composing existing tools and libraries. As a result, it offers an excellent balance of simplicity, flexibility, compatibility and performance.

Here’s what it offers:

☑️ Unified JavaScript and CSS codebase and build.
☑️ Shared imports between JavaScript and CSS.
☑️ Feature-parity with CSS preprocessors.
☑️ Works with “pure CSS” tooling (PostCSS, css-split-webpack-plugin)
☑️ Support for full CSS syntax (including@keyframes and @media)
☑️ Locally scoped identifiers (with CSS Modules)
☑️ Target static .css files (with extract-text-webpack-plugin)
☑️ Hot style reloading (with react-hot-loader)
☑️ Compatibility with different projects (not bound to React components).


Getting Started

Our “CSS in JS” solution isn’t a single package that you can add to your project. It’s essentially just a set of config patterns that we’ll refer to as .css.js.

The basic concept:

  1. CSS styles are defined as plain objects exported from .css.js files.
  2. At build time.css.js file exports are converted to CSS markup.
  3. Converted CSS markup is processed by Webpack css-loader.

To anyone experienced with loading CSS through Webpack, the majority of required the setup will be immediately familiar since it is composed from standard tools. Only the addition of css-js-loader to a standard CSS build is needed in your Webpack config to add support for .css.js files.

Here’s a very basic config example:

// Base Webpack config.
module.exports = {
entry: {
index: 'src/index.js',
},
module: {
loaders: [{
test: /\.css$/,
loaders: ['style-loader', 'css-loader'],
}],
},
};

// Updated CSS JS config.
module.exports = {
entry: {
index: 'src/index.js',
},
module: {
loaders: [{
test: /\.css(\.js)?$/,
loaders: ['style-loader', 'css-loader'],
}, {
test: /\.css\.js$/,
loader: 'css-js-loader',
}],
},
};

Just add css-js-loader below any CSS loaders (Webpack executes loaders in order from bottom to top) and update the test expression of css-loader to optionally match .css.js files. You could of course define entirely separate loader entries for .css and .css.js, but this “hybrid” approach, with single loaders supporting different file types, helps keep your config as minimal and as consistent as possible.

To process .css.js files written as ES6, ensure that any JS loaders are included below the entry for css-js-loader to ensure the they are run first.

module.exports = {
entry: {
index: 'src/index.js',
},
module: {
loaders: [{
test: /\.css(\.js)?$/,
loaders: ['style-loader', 'css-loader'],
}, {
test: /\.css\.js$/,
loader: 'css-js-loader',
}, {
test: /\.js$/,
loader: 'babel-loader',
}],
},
};

Now any file with a .js extension is handled by babel-loader, including .css.js files. Any CSS is handled css-loader and style-loader regardless of whether it came from a .css or a .css.js file.

CC images courtesy of sinsiwinsi, the_colmans, and christine-black

Webpack loaders are a composed “series of tubes.” Each file type travels a different path depending on its extension with individual loaders shared among different file types.

The compositional nature of a css-js-loader build allows us to include only the features we need, and allows for an incredible amount of extensibility through community provided tools.


Defining Styles

As stated above, the basic concept is here is that CSS styles are defined as plain objects exported from .css.js files. The default export of each file can contain any number of CSS properties and blocks. The exported object is, in effect, a very basic AST that can represent any CSS language structure.

A simple .css.js file:

export default {
'.blueText': {
color: 'blue',
fontSize: 12,
},
};

Yields:

.blueText {
color: blue;
font-size: 12px;
}

Any string or number property on the exported object represents a CSS property and any nested object value represents a nested CSS block. At-rules such as media queries, animation keyframes, and font-face definitions are supported as nested objects.

Nested CSS structures:

export default {
'.hiddenSmall: {
display: 'none',
},

'@media (min-width: 480px)': {
'.hiddenSmall': {
display: 'block',
},
},

'.spinner > div': {
animation: '3s infinite spin',
},

'@keyframes spin': {
'0%': {
transform: 'rotate(0)',
},
'100%': {
transform: 'rotate(360deg)',
},
},

'@font-face': { ... },
};

A large single object full of string keys isn’t the nicest thing to work with. To provide a cleaner interface for defining static classes using ES6, css-js-loader offers an additional syntax. Named exports alongside the default export are individually parsed as class definitions. This provides a consistent visual continuity between .css.js files and other .js files.

Essentially this:

export const blueText = { ... };

export const greenText = { ... };
export default {
'.redText': { ... },
};

Is an alias for this:

export default {
'.blueText': { ... },
'.greenText': { ... },
'.redText' { ... },
};

CSS Modules

Since css-js-loader returns CSS documents, it is fully compatible with css-loader and CSS Modules. The combination of CSS Modules with .css.js is really makes this solution a pleasure to use when styling components.

The “named class export” pattern mentioned above offers a uniquely consistent experience when working with CSS Modules. On importing a .css.js file loaded as a CSS Module into a component, the destructured contents map directly to named exports of the source .css.js file. Although the process behind the scenes is more complicated, there is a direct continuity between the style and component files.

Style file:

export const base = { ... };
export const inner = { ... };

Component file:

import React from 'react';
import {base, inner} from './component.css.js';
export default () =>
<div className={base}>
<div className={inner}>
...
</div>
</div>;

Furthermore, tools like eslint-plugin-import no longer have to be configured to ignore style imports and are capable of warning if you of misspelled or missing CSS classes.


Writing Maintainable Styles

The main motivation for using css-js-loader over a CSS preprocessor is for access to the JS context.

Here’s a more involved example to illustrate the advantage of a shared context. This hypothetical Button component is defined as its own encapsulated module.

src
├─┬ component
│ └─┬ button
│ ├── button.config.js
│ ├── button.css.js
│ └── button.js
└─┬ util
└── css.util.js

The button.config.js file is accessible to both the button.css.js file and .button.js, allowing the component to be highly configurable and maintainable.

Any “mixins” and other shared style utilities are plain JS and are equally useful within components that apply inline styles at runtime. Sharing logic between a CSS and JS build has not been possible before this.


Since css-js-loader is just a Webpack loader that returns CSS, it can be chained with other CSS loaders. And while the loader itself is not capable of transforming non-standard CSS syntax into something understandable by a browser, we can achieve the desired result through loader composition. By adding additional tools like postcss-loader to our build, we can extend the basic functionality that css-js-loader provides.

Adding postcss-loader to the existing loader chain:

module.exports = {
entry: {
index: 'src/index.js,
},
module: {
loaders: [{
test: /\.css(\.js)?$/,
loaders: ['style-loader', 'css-loader'],
}, {
test: /\.css(\.js)?$/,
loader: 'postcss-loader',
options: {
plugins: () => [require('postcss-nesting')],
},
}, {
test: /\.css\.js$/,
loader: 'css-js-loader',
}, {
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
}],
},
};

The postcss-nesting plugin allows us to use nested selectors:

export default {
'.container': {
display: 'flex',
'& .button': {
cursor: 'pointer',
'&:hover': {
color: 'blue',
},
},
},
};

Which generates the expected:

.container {
display: flex;
}
.container .button {
cursor: pointer;
}
.container .button:hover {
color: blue,
}

The other advertised features, including hot reloading and static .css file creation are also provided by additional loaders and plugins. Their usage is unchanged when combined with css-js-loader, so I won’t go into depth here on how to set them up.


Conclusiony Type Thing

A lot of the examples, particularly the Webpack config samples are simplified beyond real-world usability. There is a lot of nuance that must go into any production-ready build config that is far beyond the scope of this article. Success with these tools will come most easily to those who are familiar with setting up Webpack, and in particular, with CSS loaders. Just about any problem encountered in a css-js-loader Webpack config would be common to a regular css-loader config. Tutorials, examples and StackOverflow questions not directly pertaining to css-js-loader are still a very relevant resource.

I’ve set up a slightly more complete example that makes use of Webpack config partials to target both production and development builds.


Additional Resources