CSS Modules — Solving the challenges of CSS at scale

In 1996, when CSS1 was released, the intent of the specification was to separate the presentation of a document from the document content. The operative word in that statement is “document”. The web was not mainstream then and content was still largely document based. Existing stylesheets were tiny by today's standards and any maintenance burden was easily offset by the tremendous benefit of decoupled styling.

In the modern web, we rarely think of content in the context of a document. The modern web is dominated by highly dynamic web applications with graphically intensive stylings. Today’s production stylesheets can easily reach into the thousands of lines of code with several hundred selectors.

Building and maintaining CSS as this scale presents unique challenges for development teams. Overcoming these challenges requires a balance of discipline, tooling and frameworks.

The challenges of CSS at scale

At Universal Mind, we’ve been architecting and building large web applications for many years, so we’re very familiar with the unique challenges of CSS at scale, both in development and in production.

Global Scope
CSS has one implicit scope and it’s global.

We train developers regarding the dangers of global scope in JavaScript, yet we overlook that same danger with CSS.

Regardless of which framework, pre-processor or build system you’re using, application CSS will execute at runtime in a single global scope. As a result, selector collisions and unintentional cascade will eventually creep in, leading to lost development velocity, cross-platform UI rendering issues and developer anxiety in changing an already fragile stylesheet.

Deeply nested overqualified selectors
As developers attempt to mitigate global scope issues, they’ll begin to over qualify selectors in an attempt to create a pseudo-scope. This never works out well.

.widget table row cell .content .header .title {
padding: 10px 20px;
font-weight: bold;
font-size: 2rem;
}

Overqualified selectors have several issues

  • They’re a performance nightmare. This small example would require the browser to make 7 fetch attempts across the DOM before rendering. The implications of a single selector are negligible, but multiply this over an entire codebase and you can inadvertently add seconds to your page rendering time.
  • They add a lot of unnecessary weight to your site. Byte count still matters, especially on mobile where data speeds are limited.
  • They limit reusability. Instead of working with the cascade, they’re fighting against it, limiting reuse and promoting duplication.

Refactoring
Once your app is deployed, how will you implement iterative changes to the styling? — Is there a clean way for you to determine which selectors will affect your component? — How will you identify dead code? Your CSS architecture should encourage encapsulation and promote easily maintainable code.

Steps in the Right Direction

There are several frameworks and methodologies in use today that address some of these challenges, but none of them individually solve every challenge, and each have their own challenges.

Preprocessors (Sass, Less etc.) 
CSS pre-processors have been around for the better part of the last decade. They’re a valuable part of any CSS architecture and address some but not all of the challenges inherent in CSS at scale.

  • Code maintainability improves dramatically with imports, values and mixins.
  • Refactoring becomes less scary and more predictable as module specific styling is encapsulated in its own relative file.

Despite the many benefits of pre-processors, their greatest contributions improve development and don’t mitigate the greatest run-time issue, global scope.

A typical Sass root file

// root.scss

@import 'reset';
@import 'global-values';
@import 'header';
@import 'item-list';
@import 'footer';

Pre-processors use import to reassemble modules prior to minification, so, in the end, you’re still placing all of your selectors in the global scope. This doesn’t solve the global scope challenge.

BEM (Block Element Modifier)
BEM methodology advocates modularity in CSS through the use of selector naming conventions.

[block]__[element]--[modifier] {
padding: 10px 20px;
font-weight: bold;
font-size: 2rem;
}
  • Block — Standalone entity that is meaningful on its own. (header, container, menu, input)
  • Element — Parts of a block and have no standalone meaning. They are semantically tied to its block. (menu item, list item, checkbox caption, header title)
  • Modifier — Flags on blocks or elements. Use them to change appearance or behavior. (disabled, active, checked, big, red, error)

Used properly, BEM is a sound approach to creating modular, reusable and structured CSS. It’s capable of solving the global scope challenge, and when combined with a pre-processor (like Sass), you have an approach that successfully addresses many of the challenges of CSS at scale. However, it’s not without its own issues.

  • Deeply nested elements can quickly lead to unruly selector names and require a lot of cognitive effort.
  • For BEM to work, you must be consistent in your implementation of the naming conventions. For large teams, this can be difficult to enforce.

There’s a better way…

CSS Modules

CSS Modules deliver the best of both worlds. They preserve everything we know and love about CSS and layer in component architecture paradigms that we’re already in love with.

They generate locally scoped class names that are easy to reason about, without introducing complex conventions.

Creating a CSS Module is no different then creating any other CSS file. With CSS Modules, you’re free to name your classes whatever you like, without fear of global scope issues. No more complicated BEM names. The CSS syntax is unchanged, so all of your existing tooling will continue to work as expected.

An example CSS module

/* components/demo/ScopedSelectors.css */
.root {
border-width: 2px;
border-style: solid;
border-color: #777;
padding: 0 20px;
margin: 0 6px;
max-width: 400px;
}

.text {
color: #777;
font-size: 24px;
font-family: helvetica, arial, sans-serif;
font-weight: 600;
}

Loading a CSS module into the local scope of your component is as simple as using require or import, just like you would any other JavaScript module

/* components/demo/ScopedSelectors.js */
import styles from './ScopedSelectors.css';

Wait, what!? You can require CSS? We’ll get to that in a bit.

Once your module is loaded you can reference your CSS class names like you would any other property.

A simple React example

import React, { Component } from 'react';
import styles from './ScopedSelectors.css';

export default class ScopedSelectors extends Component {
render() {
return (
<div className={styles.root}>
<p className={styles.text}>Scoped Selectors</p>
</div>
);
}
};

Notice we reference the “active” class from our module using dot-notation. Referencing a loaded CSS module is the same as any other JavaScript object.

All this is made possible by the CSS Module loader, which uses require or import (common JavaScript paradigms) to compile your CSS and attach it to the local scope of your module, with globally unique class names.

Using our React example above, our rendered output would look something like this

<div class=”ScopedSelectors__root___16yOh”>
<p class=”ScopedSelectors__text___1hOhe”>Scoped Selectors</p></div>

The module loader has transformed {styles.root} and {styles.text} into globally unique class names. Resulting in styles that are unique and local to your component. The generated class names use a form of BEM notation which includes the component name, class name and a unique hash, ensuring the class name is globally unique, even if you have two classes with the same name in separate modules.

Promoting reuse through composition
Efficient class reuse is critical to minimizing duplication and maintaining a consistent UI in your app. CSS Modules facilitate class reuse through composition. One class can “compose” one or more other classes.

Considering our previous example, we could easily abstract the general layout and typography attributes of both classes into higher level files, thereby promoting reuse in other components within the application.

/* components/demo/ScopedSelectors.css */
.root {
composes: box from "shared/styles/layout.css";
border-color: red;
}

.text {
composes: heading from "shared/styles/typography.css";
color: red;
}

The layout file is another CSS module

/* shared/styles/layout.css */
.box {
border-width: 2px;
border-style: solid;
padding: 0 20px;
margin: 0 6px;
max-width: 400px;
}

as is the typography module

/* shared/styles/typography.css */
.heading {
font-size: 24px;
font-family: helvetica, arial, sans-serif;
font-weight: 600;
}

Working with Pre-Processors
CSS Modules do not exclude using a pre-processor. If you’re using a tool like WebPack, using Sass with CSS Modules is very simple.

Are CSS Modules right for my project?
CSS Modules are not suitable for every project. A component based architecture is required (React, Angular 2), and aside from Rails, they only work with JavaScript applications. Furthermore, if you’re already using BEM, it may not make sense to switch to CSS Modules.

For your next project, large or small, I would strongly recommend you consider using CSS Modules. Currently supported loaders include Webpack, JSPM, Browserify and there’s also a WIP plugin for Rails.

Conclusion

Looking back at our three challenges of CSS at scale, CSS Modules solve them all and add much needed structure to our projects.

  • Global Scope — CSS Modules eliminate collisions in the global scope, by leveraging uniquely generated class names using a modified BEM notation.
  • Overqualified Selectors — Because CSS modules live at the component level, there’s no need to write write deeply nested selectors. Class names can be kept simple and relevant to the component.
  • Refactoring — Refactoring is made simple, because we’re working at the component level, we can easily determine which styles apply to the component. For styles that use composition, we can quickly locate the other affected components.

Related Repos and Reading