Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace Emotion.js with CSS Modules #2153

Closed
connor-baer opened this issue Jun 16, 2023 · 4 comments
Closed

Replace Emotion.js with CSS Modules #2153

connor-baer opened this issue Jun 16, 2023 · 4 comments
Labels
📝 RFC Request for comment 🛠️ tech Changes to the tech stack or infrastructure

Comments

@connor-baer
Copy link
Member

connor-baer commented Jun 16, 2023

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 (#1620) and CSS Modules (#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

  • Performance: The styling solution should output plain CSS at build-time in cacheable file(s). It should be compatible with HTML streaming.
  • Developer Experience (DX): The styling solution should be able to produce scoped styles. It should support linting to catch errors and enforce best practices. It should support utility classes in some shape or form. Styles should be easy to import or integrate: Circuit UI Web has additional constraints as a library compared to an application. By inlining the CSS into the JavaScript bundle, we didn’t have to worry about bundler configuration for plain CSS — now we do.
  • Compatibility: The styling solution should be framework-agnostic and support all browsers that are included in Circuit UI's browser support policy (e.g. through automatic vendor prefixing).

Non-Goals

  • Prescribe a styling solution for all web applications at SumUp

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:

  • Linaria's styled function only accepts a single tagged template literal, so composing styles by toggling entire style blocks is not possible. Dynamic styles through automatic CSS variables can only be used for style values, not entire style rules.
  • Linaria's css function returns a string class name that can be composed using a custom utility function. However, the css function doesn't have access to props, so dynamic styles can only be achieved by manually declaring CSS variables inline.

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.

Footnotes

  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
Copy link
Member

nicosommi commented Jun 19, 2023

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
Copy link
Member Author

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
Copy link
Member

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
Copy link
Member Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
📝 RFC Request for comment 🛠️ tech Changes to the tech stack or infrastructure
Projects
None yet
Development

No branches or pull requests

2 participants