CSS in React libraries is all compromises

Over my career, I've easily authored over two dozen different component libraries. Some libraries were general-purpose design system UI libraries, and several libraries focused on specific domains like uploading content, specialized previewing libraries, etc. I built nearly all of them with React and used a mixture of CSS styling approaches and build tools. Putting aside the myriad considerations that go into the non-styling aspects of a component library, I'm convinced that in the year 2023, there does not exist a styling solution for React component libraries that successfully balances:

  1. Library authoring ergonomics

  2. Minimal build tooling

  3. Consumer ergonomics

Let's dive into each of these topics. I'll describe my (highly subjective) viewpoint on what "good" looks like for each. I'll then explain how these desires are at odds with one another.

Library authoring ergonomics

As an individual writing the library's code, I've grown to appreciate two aspects of writing CSS for my components:

Colocation

I strongly prefer "colocating" styles with their underlying components. In the React ecosystem, colocation typically takes one of two forms:

1. CSS or CSS Module imports

// src/Button/Button.tsx
import * as styles from './Button.module.css';
import { clsx } from 'clsx';

interface ButtonProps { /* ... */ }

export function Button(props: ButtonProps) {
    const { className, ..rest } = props;
    return <button className={clsx(styles.button, className)} {...rest}>;
}
/* src/Button/Button.module.css */

.button {
  /* your styles go here */
}

2. CSS in JS

// src/Button/Button.tsx
import * as styles from './Button.module.css';
import { css } from '@emotion/css';

const styles = css`
    // your styles go here
`;

interface ButtonProps { /* ... */ }

export function Button(props: ButtonProps) {
    const { className, ..rest } = props;
    return <button className={clsx(styles, className)} {...rest}>;
}

While I certainly appreciate keeping related code next to each other so I can find it, colocation's primary benefit is "delete-ability." Related code kept near each other is typically better maintained and is consequently easier to delete (or just move) whenever you need to refactor.

I like that CSS imports and CSS in JS put the component "in control" of their CSS dependency. With CSS/CSS Module imports, the component's module must explicitly import the CSS file.

// CSS import
import './Button.css';

// CSS Module style import
import styles from './Button.module.css';

And with CSS in JS, the styles typically live in the same module as the component or are imported like any other JavaScript dependency.

import { css } from '@emotion/css';

const styles = css`
  // styles...
`;

However, each has downsides. JavaScript does not natively support importing CSS. Thus, this is not something the browser (or other JavaScript runtimes) know what to do with:

// ❌ nope
import './Button.css';

// ❌ nope again
import styles from './Button.module.css';

To resolve this, you'll need a “higher-order” build tool (e.g., Webpack, Rollup, Vite) to handle these non-native imports for you. If you're using a meta-framework, they likely support this out-of-the-box.

With CSS in JS, there are a few downsides. I've heard many developers argue they dislike the authoring experience and find writing CSS inside JavaScript backticks or objects awkward. It doesn't bother me. However, what does bother me is the undisputed performance hit. CSS in JS is literally that: CSS wrapped in JavaScript strings bundled up with the rest of your JavaScript code and then dynamically injected into the CSSOM as plain CSS via style elements at runtime. This means the application's JavaScript artifacts are larger, and all that extra CSS (as JS) has to be downloaded, parsed, and executed. Byte for byte this is much more expensive than downloading plain CSS. One of the maintainers of Emotion (a popular CSS in JS library) has a great writeup on why his team stopped using CSS in JS.

Unique class name generation

Your library's class names mustn't conflict with the host application's. For example, if your library exports a Button component, you may attempt to style it with the class name button.

.button {
  /* button component styles */
}

Now, what happens if the host application also defines a component with the class name of button? While allowing host applications to customize your library's styles may be desirable, you certainly don't want this to happen by accident. To avoid these global class name collisions, you need to do something. While some frameworks utilize modern CSS techniques for scoping (e.g., Astro uses :has() along with autogenerated component IDs), the most reliable approach is using tools to autogenerate a unique class name.

💡
 Another technique for avoiding name collisions requires creating a manual "namespace." You can use tools like SASS to assist with this work. BEM (Block Element Modifier) is an alternative way to namespace your class names methodically. As someone who advocated for BEM for many years, I've unfortunately learned that even if I get my teammates to stop complaining about how ugly the class names are, they are confused about when to make something a block versus an element. So, after years of trying to make BEM happen, I've moved on.

Minimal build tooling

The best build tooling is no build tooling. Unfortunately, that's practically impossible.

Unpacking an elaborate Webpack, Vite/esbuild, or Rollup configuration eludes most developers I've worked with. Many don't understand the boundaries and composition of Babel, TypeScript compilers, plugins, loaders, Webpack, Rollup, or other tools. Moreover, build tools are yet another list of dependencies that developers must maintain: security updates, mismatched dependencies, breaking changes, etc. I'm not afraid of them, but "knowing" them is only half the battle. For these reasons, I live squarely in the "less is more" camp of build tools.

I love TypeScript. I find it essential as both a software author and a consumer of other libraries. Thus, at a minimum, the TypeScript compiler (tsc) or some tool that converts TypeScript to JavaScript is necessary for my toolchain. But can I realistically stop there? The answer is: "It’s complicated."

As mentioned before, because CSS in JS is just JavaScript, I could get by with only tsc. But I'll need to reach for something like Rollup (plus plugins) if I want to use CSS or CSS Module (non-native) imports.

Consumer ergonomics

When I ship a library, getting my library's components in their code has to be easy. The ideal case would look like this (just import the component):

import { FancyComponent } from 'my-react-library';

export function Home() {
  return (
    <div>
      {/* stuff ... */}
      <FancyComponent />
      {/* more stuff ... */}
    </div>
  );
}

That code may look simple enough, but ask yourself, “How is the CSS of FancyComponent loaded into the host application?”

CSS in JS

This is one area where CSS in JS shines. Because the styles are also managed by JavaScript, importing a component is as simple as the example above. If you're not using CSS in JS, the CSS for FancyComponent or any other component from 'my-react-library' needs to be loaded somehow. Of course, the downside is the same: CSS in JS is not a performant way to load CSS. So, while it offers greater consumer ergonomics, the negative performance hit makes CSS in JS an unviable solution for me.

CSS/CSS Modules imports as a single CSS artifact

If, in your library, you choose to import your CSS dependencies in each component, then you're likely using something like Rollup + Rollup Plugin Post CSS. These tools generate a single CSS artifact for your library's code. Consequently, for a consumer to use your library code, they'll need to import the CSS artifact:

import 'my-react-library/styles.css';

As I said, using Rollup + Rollup Plugin Post CSS results in a single CSS artifact. If your library is small, that's likely OK. However, if you're creating a large component library, particularly one where consumers are likely only to use small parts of it, then this is not a great solution because the host application will end up loading lots of unnecessary CSS.

Supporting multiple CSS artifacts

Emitting multiple CSS artifacts is particularly important when your library is large. This is the counter-point to a "single CSS artifact:" When your library is large, you explicitly do not want your consumers to download all your CSS because much of it will be unused.

At Vista, our core UI component library publishes multiple CSS artifacts to a CDN. This approach is ideal because it's a relatively large library used throughout the website. Thus, if I work on the home page component and only use a few of our component library's components, the home page only loads the necessary CSS from our library. Additionally, because I've loaded them from a CDN, subsequent page visits in the funnel will benefit from the cached CSS.

However, producing multiple CSS artifacts from a library requires you to forego explicitly importing CSS dependencies in your library's component code.

// ❌ you can't do this
import './Button.module.css';
// ❌ nor this
import './Button.css';

export function Button() {
  // ...
}

And, since we can't import the CSS, CSS Modules are now off the table, and with it, CSS Modules' ability to autogenerate unique class names 😖. We truly can't have it all.

💡
At Vista, one of our UI component library's requirements is offering a React and non-React API. For this reason, we prefer human-readable class names (as opposed to the autogenerated ones created by CSS modules). However, this is not true for many other libraries we've built.

So, where does this leave me?

While I appreciate the simplicity of the CSS in JS approach, the performance impact on my customers makes it a non-starter. That then leaves me with these two options:

ProsCons
CSS Modules"Managed" CSS importsAutogenerated unique class names
Requires more elaborate build toolingEmits a single CSS file
Non-imported CSSCan emit as many CSS files as necessaryLess build tooling is required
Must manually namespace your class names

The reality is that most of the libraries I work on are quite small. Thus, splitting CSS into multiple files doesn't today offer enough upside to forego the benefits of CSS Modules. So, for these reasons, CSS Modules are my default choice. To achieve this, I build my libraries with Rollup, @rollup/plugin-typescriptand rollup-plugin-postcss. It's more build tools than I'd prefer to use, but I can live with it.

I plan to look at tsup shortly. It's a newer build chain built on top of esbuild and has (experimental) support for CSS imports. Given that it's esbuild, I'd expect it to be faster than Rollup but might otherwise not offer any major benefits.