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:
Library authoring ergonomics
Minimal build tooling
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.
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.
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:
Pros | Cons | |
CSS Modules | "Managed" CSS imports | Autogenerated unique class names |
Requires more elaborate build tooling | Emits a single CSS file | |
Non-imported CSS | Can emit as many CSS files as necessary | Less 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-typescript
and 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.