Avoiding dynamic CSS-in-JS styles in React
Published on:Table of Contents
With React continuing to march towards a strong streaming server side rendering story, the React working group published a guide for how dynamic CSS-in-JS libraries can adapt. At the end of the guide, it gives a lukewarm sendoff to dynamic CSS-in-JS libraries:
While this technique for generating CSS is popular today, we’ve found that it has a number of problems that we’d like to avoid. Therefore we don’t have plans for adding any solutions upstream to handle this in React. […]
Our preferred solution is to use [stylesheets] for statically extracted styles and plain inline styles for dynamic values.
There is no need to panic. Dan Abramov clarified in an HN thread that they aren’t dropping support for CSS-in-JS libraries that inject styles, but doubled down on advocating for solutions that result in stylesheets and dynamic inline styles.
There are several ways to achieve the preferred solution:
- Handwrite the stylesheet
- CSS modules
- A CSS-in-JS solution that extracts rules at build time (what facebook uses)
- Atomic / utility CSS (eg: tailwind)
As a Next.JS user, I default to using the built-in css support, which includes CSS modules and styled-jsx for CSS-in-JS. And since styled-jsx
allows dynamic styles, I thought it would be a good idea to be consistent everywhere and only use styled-jsx
. One dynamic style use case was to render a button list and then have buttons swipe in off-screen in a staggered animation:
export const MyButton = ({ index }: { index: number }) => {
return (
<button className={`btn-${index}`}>
Click
<style jsx>{`
.btn-${index} {
animation: slideIn ${1 - 0.1 * (index + 1)}s ease-out;
}
@keyframes slideIn {
0% { transform: translateX(200px); }
100% { transform: translateX(0); }
}
`}</style>
</button>
)
}
This solution seemed like an easy win, but with the new de-emphasis on dynamic <style>
tags, can we rework our example into a static stylesheet with inline styles? Yes, we can with CSS modules and inline styles that set a CSS variable to the dynamic value.
/* MyButton.module.css */
.slide-in {
--dur: calc(var(--slide-duration) * 1s);
animation: slideIn var(--dur) ease-out;
}
@keyframes slideIn {
0% { transform: translateX(200px); }
100% { transform: translateX(0); }
}
import classes from "./MyButton.module.css";
export const MyButton = ({ index }: { index: number }) => {
const duration = { "--slide-duration": 1 - 0.1 * (index + 1) };
const style = duration as React.CSSProperties;
return (
<button className={classes["slide-in"]} style={style}>
Click
</button>
)
}
The biggest annoyance above is the need to use a type assertion to satisfy Typescript with CSS variables in React. But we gain build time CSS extraction without losing any behavior and without adding a dependency.
It seems possible for all dynamic styles to be converted to this form. If this process is too manual, linaria or astroturf may be what you are looking for if you still want to preserve CSS-in-JS and are willing to add some complexity into your build system.
Speaking of dependencies, Andrei Pfeiffer has a repo covering many of the available CSS-in-JS options. I highly recommend a read. It’s a tad out of date (frontend development moves quickly in a year’s time), but still loaded with good info. There’s an overview table in the repo and it’s very apparent how few libraries support static CSS extraction. Perhaps with the recommendations from the React working group, we’ll see more implementations adopt static CSS extraction.
So is the recommendation to put as much as possible in CSS modules? No, I wouldn’t go that far. CSS modules are a nice way to scope custom, static styles in a Next.js app without dependencies, but it shouldn’t be the main vessel in communicating style. There are a few flaws with CSS modules:
- Unused code. Since CSS modules do not have Typescript support, it can be difficult to find unused classes. One would need to check the class names individually. This unused code is shipped to your users.
- Lack of atomic css. There will never be a breakeven point for CSS modules where one can add CSS without increasing bundle size.
- Lack of design system. Some say there is a complete lack of design system utilities in CSS modules. That’s too extreme, I think one could create a pretty nice design system defined via CSS variables based on Open Props in a global stylesheet. But CSS modules doesn’t give any help in scaffolding this design system.
- Lack of co-location. If JSX is the co-location of HTML and JS, it seems reasonable to include CSS too, so one can construct a mental model of how it looks. Svelte and Vue co-locate all of them.
CSS modules are good, just in small doses.
Lately, the combination that I’ve been embracing is a build time atomic / utility CSS framework to style most everything, CSS modules for custom CSS that don’t fit the utility framework, and finally inline styles for dynamic styles.
The juggernaut in this space is tailwind and I found it remarkably painless to get started with it in Next.js. In addition to a standard tailwind config, just a couple lines needed to be added to our package.json (I try to avoid polluting the root directory with a bunch of minimal config files)
{
"postcss": {
"plugins": {
"tailwindcss": {}
}
}
}
Next.js already includes postcss and I haven’t found a need for autoprefixer
, as I tend to target modern browsers (see: Is Vendor Prefixing Dead?), so tailwindcss
is the only dependency that is needed to be installed.
I was worried at first that creating a custom postcss config would break CSS modules as Next.js default behavior is disabled when any customization is detected. Thankfully, this is not the case.
I could probably eliminate the need for CSS modules if I leaned into tailwind arbitrary values and properties, but I don’t want to bloat the global stylesheet with too many customizations that won’t be reused for other components. For large apps across many pages, I’d rather have the custom css for that page loaded when it is actually needed. And at least in Next.js, I don’t lose access to tailwind functionality like the screen() function
in CSS modules:
/* CSS Module with outline values + tailwind functions */
.overlay-outline {
--outline-width: theme(spacing.4);
outline-width: var(--outline-width);
outline-offset: calc(var(--outline-width) * -1);
}
@media screen(md) {
.overlay-outline {
--outline-width: theme(spacing.8);
}
}
With these tools, I feel empowered to tackle any design challenge, and this is coming from someone who finds themselves design challenged (apologies for the pun).
Tailwind wasn’t the only design system explored.
- twind is nice that there is no build configuration, but it suffers from being a dynamic CSS-in-JS solution with a runtime.
- unocss seems cool but doesn’t seem to have an integration story with Next.js.
Essentially Tailwind reached a critical mass of popularity where it is painless to set up, editor plugins work well, unused utilities aren’t shipped, and the styles that are shipped plateau.
It sounds weird at first to think about using Tailwind, CSS modules, and inline styles, as they could be seen as competing, but the more I use the combination, the more I like it; guardrails with Tailwind, customization with CSS modules, and dynamic styles with inline styles.
Comments
If you'd like to leave a comment, please email [email protected]