sumup-oss / circuit-ui

SumUp's design system for the web
https://circuit.sumup.com
Apache License 2.0
914 stars 129 forks source link

Replace Emotion.js with CSS Modules #2153

Closed connor-baer closed 1 year ago

connor-baer commented 1 year ago

Problem

Emotion.js, a CSS-in-JS library, was adopted as SumUp’s default styling solution over five years ago. CSS-in-JS was chosen primarily for its great developer experience (DX): the style rules are automatically scoped to the local component, props can be interpolated directly into the styles, and the JavaScript theme enables autocomplete and type-checking.

This comes at the cost of the user experience (UX): styles are embedded in JavaScript, which is more expensive to parse and execute than plain CSS. Stylesheets are generated and injected at runtime, which negatively impacts performance further.[^1]

Emotion.js integrates deeply with React, SumUp’s prevailing UI framework. However, Emotion.js is fundamentally incompatible with the concurrent rendering and server-side features that were introduced in the latest version of React. For this reason, React maintainers recommend to stop using dynamic CSS-in-JS libraries and have stated that they won’t be supported by React moving forward.[^2]

@nicosommi first raised these issues with SumUp's web developer community in May 2022 (internal link). @robinmetral subsequently surveyed (internal link) the community who favored Linaria, a build-time CSS-in-JS library, over CSS Modules and Tailwind. After building a proof of concept for both Linaria (https://github.com/sumup-oss/circuit-ui/pull/1620) and CSS Modules (https://github.com/sumup-oss/circuit-ui/pull/2081), I believe CSS Modules are the better technical choice — at least for Circuit UI Web.

Hypothesis

Replacing Emotion.js with CSS Modules in Circuit UI Web will significantly improve the performance of SumUp’s web applications, future-proof the component library against ecosystem changes and start to decouple it from React.

Goals

Non-Goals

Approach

This section is split into changes to the public API, an introduction to CSS Modules, and the migration plan for Circuit UI.

Public API #

The migration to CSS Modules requires minimal work from developers to adapt their applications.

Global styles and theming

Circuit UI’s BaseStyles component includes global styles to reset browser styles to a reasonable default. Emotion.js’ ThemeProvider component wraps the entire component tree to make the JSON theme available to all components through React Context. Both will be replaced by plain CSS files that should be imported wherever the components were previously used:

// _app.tsx
-import { ThemeProvider } from '@emotion/react';
-import { light } from '@sumup/design-tokens';
+import '@sumup/design-tokens/light.css';
-import { BaseStyles } from '@sumup/circuit-ui';
+import '@sumup/circuit-ui/styles.css';

function App({ Component, pageProps }) {
    return (
-       <ThemeProvider theme={light}>
-           <BaseStyles />
            <Component {...pageProps} />
-       </ThemeProvider>
    );
}

The application code must be processed by a bundler that can handle CSS files. Next.js, Create React App, Vite, Parcel and others support importing CSS files out of the box.

Note that @sumup/circuit-ui/styles.css includes the base styles and the styles for every component regardless of whether it’s used. An obvious downside is that applications are likely to load unused styles. The performance benefits of the approach offset this: a plain CSS file is much faster to parse and execute than CSS embedded in JavaScript, can be streamed to the browser, and can be cached long-term to speed up subsequent visits.[^3]

Custom component styles

You can continue to pass the className and styles props to Circuit UI components. If your application uses Emotion.js, you can continue to use the css prop since it is transpiled to the className prop by Emotion.js’ Babel plugin.

Utility classes

Circuit UI exports some style mixins such as spacing, hideVisually, or shadow. These functions return an Emotion.js style object that can be passed to the css prop but not the className prop.

For applications that don’t use Emotion.js, Circuit UI will export a new collection of utility classes that can be passed to the classNames prop.

The legacy style mixins will be kept for backward compatibility.

Design tokens

The design tokens will be turned into CSS custom properties (aka CSS variables) similar to the existing semantic color tokens:

-${theme.borderRadius.circle}
+var(--cui-border-radius.circle)

The JavaScript theme object from @sumup/design-tokens will be deprecated. We will add a prefer-custom-properties ESLint rule to flag and automatically rewrite uses of the JS theme to CSS custom properties (#2158).

CSS Modules #

CSS Modules is a spec to import styles from a CSS file as locally scoped class names. By convention, CSS Module files have the extension .module.css.

/* Button.module.css */
.button {
    background: blue;
}

The CSS Module can be imported into a JS Module as an object of class names.

// Button.tsx
import styles from "./Button.module.css";

function Button({ children }) {
    return <button className={styles.button}>{children}</button>;
}

A bundler would transform the above example into something like this:

/* styles.css */
.button-asdi7a {
    background: blue;
}
// script.jsx
function Button({ children }) {
    return <button className="button-asdi7a">{children}</button>;
}

Migration #

Styled components

Most components in Circuit UI consist of HTML elements with conditional styles applied to them.

// Card.tsx
import styled from '@emotion/styled'; 
import { css } from '@emotion/react';

const baseStyles = ({ theme }) => css`
    background-color: var(--cui-bg-normal);
    border-radius: ${theme.borderRadius.mega};
    border: ${theme.borderWidth.mega} solid var(--cui-border-subtle);
`;

// Example of prop interpolation
const spacingStyles = ({ theme, spacing = 'giga' }) => css`
    padding: ${theme.spacings[spacing]};
`;

// Example of conditional styles
const invalidStyles = ({ invalid }) => invalid && css`
    border-color: var(--cui-border-danger);
`;

export const Card = styled.div(baseStyles, spacingStyles, invalidStyles);

Emotion.js’ styled function does much of the heavy lifting here. It toggles and merges the conditional classNames, forwards the ref, and enables using the as prop to change the DOM element.

Migrating the example component to CSS modules requires a bit more boilerplate to maintain the same functionality:

/* Card.module.css */
.base {
    background-color: var(--cui-bg-normal);
    border-radius: var(--cui-border-radius-mega);
}

.mega {
    padding: var(--cui-spacings-mega) var(--cui-spacings-mega);
}

.giga {
    padding: var(--cui-spacings-mega) var(--cui-spacings-giga);
}

.invalid {
    border-color: var(--cui-border-danger);
}
// Card.tsx
import { forwardRef } from 'react';
import { clsx } from '../styles/clxs.js';
import utilityClasses from '../styles/utility.js';
import classes from './Card.module.css';

export const Card = forwardRef(
  (
    { className, spacing = 'giga', as: Element = 'div', invalid, ...props },
    ref
  ) => (
    <Element
      {...props}
      ref={ref}
      className={clsx(
        classes.base,
        utilityClasses.shadow,
        classes[spacing],
        invalid && classes.invalid,
        className
      )}
    />
  )
);

The clsx function is a utility function to join a list of classNames while filtering out falsy values.

Testing

When using CSS Modules, code snapshot tests only capture the class names in the HTML and not the styles rules. That’s okay since style snapshots tend to be brittle anyway. We’ve long relied on Chromatic’s visual snapshot tests to catch and validate visual changes in components.

Bundling

Circuit UI is currently transpiled using TypeScript, which doesn’t understand CSS files. Instead, we will use Vite to transpile and bundle the components. Vite offers first-class support for CSS Modules and was introduced to Circuit UI to support ECMAScript Modules (ESM) in unit tests.

Linting

CSS Modules can be linted with Stylelint, which can catch many issues and enforce a consistent code style.

Circuit UI’s ESLint plugin can flag misspelled custom properties in TypeScript and JavaScript. I've added an equivalent Stylelint plugin (#2156).

TypeScript can be configured to recognize CSS Module imports as objects using global types. On top of that, we can enable autocomplete for class names using the typescript-plugin-css-modules.

Alternatives #

Linaria (#1620)

At first glance, Linaria offers a similar API to Emotion.js which promises an easy migration. Both export styled and css functions as their primary APIs. However, apart from their names, these functions are fundamentally different:

Due to these differences and constraints, migrating components from Emotion.js to Linaria would require significant rewrites.

Linaria requires the theme to be static to be able to evaluate it at build time. This means the design tokens cannot be changed through a ThemeProvider. A possible workaround could be to map the theme properties to CSS variables, at which point it would be easier to use CSS variables directly.

Linaria's bundler integration is extremely buggy. It chokes on (valid) TypeScript syntax. I fully gave up on it when I could no longer figure out which part of the codebase it wouldn't understand. Issues are piling up on the repo, which receives only sporadic updates.

In comparison, CSS Modules and Linaria require similar amounts of migration effort. Linaria’s approach of embedding CSS in JavaScript requires custom tooling to be transpiled and linted. This tooling needs to be maintained to keep up with new syntax. CSS Modules, on the other hand, are compatible by default with all CSS tooling and are widely supported in popular bundlers.

Tailwind

Tailwind's proprietary API increases the cognitive load for developers and requires a significant amount of tooling to work (well). It would require a complete rewrite of all styles as utility classes. It was the least popular choice among SumUp's web developers.

[^1]: The unseen performance costs of modern CSS-in-JS libraries in React apps

[^2]: Library Upgrade Guide: \<style> (most CSS-in-JS libs) · reactwg react-18 · Discussion #110 · GitHub

[^3]: Shopify’s Polaris component library loads a single CSS file weighing 54.4kb (gzipped). Circuit UI includes fewer components, so the CSS file will likely be smaller.

nicosommi commented 1 year ago

Great work on defining this issue @connor-baer! 🚀 🎉

I love the idea of moving to the native CSS modules API. Also, TIL about the Vite bundler. That's also great!

You can continue to pass the className and styles props to

So, this will allow developers to adopt CSS Modules at their own pace while still using Emotion until the've finished, right?

The clsx function is a utility function to join a list of classNames while filtering out falsy values.

At EOD, CSS Modules look like Tailwind in a way 😄 but I think that's good; it's such a successful framework! The only difference is the level and nature of the class composition. You already have some utility classes. So while we're not adopting "The Tailwind Library," IMO, we will end up with a similar solution that may naturally evolve into exporting behavior, styles and components with both already built in.

connor-baer commented 1 year ago

So, this will allow developers to adopt CSS Modules at their own pace while still using Emotion until the've finished, right?

Developers can continue to use Emotion.js in their apps alongside Circuit UI with CSS Modules. They can choose to adopt CSS Modules in their application as well, but that's out of scope for this RFC. I wasn't sure how controversial the decision to go with CSS Modules would be since developers favored Linaria in the survey. I suggest we try it out in Circuit UI and then write a separate RFC to propose the SumUp-wide adoption.

At EOD, CSS Modules look like Tailwind in a way 😄

I see a clear difference between CSS Modules and Tailwind. Utility classes have existed forever, Tailwind just takes them to the extreme. The number of utility classes that Circuit UI offers will be limited — basically just matching the style mixins we have now.

nicosommi commented 1 year ago

At EOD, CSS Modules look like Tailwind in a way 😄

I see a clear difference between CSS Modules and Tailwind. Utility classes have existed forever, Tailwind just takes them to the extreme. The number of utility classes that Circuit UI offers will be limited — basically just matching the style mixins we have now.

My bad phrasing... I meant the particular implementation, the usage of clsx function, not the CSS Modules technology per se. It's just putting together a bunch of class names together like when using Tailwind. But anyway, that is a fair point... keeping the number limited should make a difference. We'll see how it grows when more complex scenarios must be considered (media queries, multiple themes, multiple component clients).

connor-baer commented 1 year ago

This RFC has been accepted. You can follow the implementation progress in #2163.