StyleX — Meta’s Solution To Scalable CSS

Jack Herrington
6 min readDec 5, 2023

--

Understanding what problems Facebook faced with CSS will help us understand more about the design decisions behind StyleX.

Three years ago, the folks behind Facebook design systems components were faced with a problem. They were doing a complete React rewrite of the entire web frontend of Facebook and they needed a way to handle the CSS. This is a struggle that a lot of big projects and companies face. There are so many options; build-time vs run-time, CSS vs CSS declared in JavaScript, and whether or not to go with a utility first system like Tailwind.

What Facebook decided to do was to build a new CSS platform — a third pillar to their application architecture. GraphQL and Relay handle the data, React handles the DOM, and now StyleX would handle the styling. Plus, they wanted this new CSS system to learn from the mistakes of the past.

Facebook’s previous architecture was similar to CSS modules. However, this approach had scaling problems. To combat the scaling problems, they runtime loaded the CSS as needed. But lazy loading caused selector precedence issues where navigating the site through different routes would load the CSS in a different order, producing unexpected styling.

StyleX fixes that by providing “Deterministic Resolution” where you are always guaranteed to get the styling you want. To understand the value of this deterministic resolution, let’s start with a simple button.

A StyleX Button

Let’s take a look at an example of a StyleX Button component:

import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
base: {
appearance: "none",
borderWidth: 0,
borderStyle: "none",
backgroundColor: "blue",
color: "white",
borderRadius: 4,
paddingBlock: 4,
paddingInline: 8,
},
});

export default function Button({
onClick,
children,
}: Readonly<{
onClick: () => void;
children: React.ReactNode;
}>) {
return (
<button {...stylex.props(styles.base)} onClick={onClick}>
{children}
</button>
);
}

First off, the styles are co-located right with the component that uses them, which is a big win from a DX and code readability standpoint, if you like the emotion style of writing CSS. But you still get the compile-time CSS wins that you don’t get with a run-time system like emotion.

Unfortunately, you don’t get that real Tailwind ease of use if you want to use those short-hand styles (though there is support for design tokens and you could create those short-hands if you want.) However, what we lose with not having the Tailwind short-hands, we gain in control over styling.

Control Over Styling

The problem with using Tailwind as a basis for your component system is allowing for controlled refinement of the styling. Let’s say that we want to allow consumers of button to only change the color and backgroundColor of our Button. With a Tailwind component, you could have specific props for that, but that wouldn’t scale if you wanted folks to be able to adjust more than just a few styles. So, what some authors allow for is an extraClasses property where you can add whatever you like. But then you can change whatever you like without restriction, which makes components hard to version later.

StyleX has a fantastic solution for this:

import type { StyleXStyles } from "@stylexjs/stylex/lib/StyleXTypes";

export default function Button({
onClick,
children,
style,
}: Readonly<{
onClick: () => void;
children: React.ReactNode;
style?: StyleXStyles<{
backgroundColor?: string;
color?: string;
}>;
}>) {
return (
<button {...stylex.props(styles.base, style)} onClick={onClick}>
{children}
</button>
);
}

We’ve added another property called style and constrained it to just the styles we want to be overridable. And because we put style after styles.base in the stylex.props call, we can be guaranteed that the override styles will appropriately override the base styles.

See how much more control we have over styling? This means that we can version Button with confidence because we have clear boundaries around what CSS can and cannot be changed.

This is how it looks to use our Button when we want to override the styling:

const buttonStyles = stylex.create({
red: {
backgroundColor: "red",
color: "blue",
},
});

<StyleableButton onClick={onClick} **style={buttonStyles.red}**>
Styleable Button
</StyleableButton>

It’s very clear what we are changing and TypeScript enforces that we can only override the styles that the component creators intended us to override.

Design Tokens And Theming

Being able to override styling at a granular level is great, but any reasonable design system needs support for design tokens and theming, and StyleX has excellent type safe support for both of those.

Let’s start by defining some tokens:

import * as stylex from "@stylexjs/stylex";

export const buttonTokens = stylex.defineVars({
bgColor: "blue",
textColor: "white",
cornerRadius: "4px",
paddingBlock: "4px",
paddingInline: "8px",
});

Notice that we can use names like bgColor instead of being restricted to specific CSS attributes. We can then map this token into our Button like so:

import * as stylex from "@stylexjs/stylex";
import type { StyleXStyles, Theme } from "@stylexjs/stylex/lib/StyleXTypes";

import "./ButtonTokens.stylex";
import { buttonTokens } from "./ButtonTokens.stylex";

export default function Button({
onClick,
children,
style,
theme,
}: {
onClick: () => void;
children: React.ReactNode;
style?: StyleXStyles;
theme?: Theme<typeof buttonTokens>;
}) {
return (
<button {...stylex.props(theme, styles.base, style)} onClick={onClick}>
{children}
</button>
);
}

const styles = stylex.create({
base: {
appearance: "none",
borderWidth: 0,
borderStyle: "none",
backgroundColor: buttonTokens.bgColor,
color: buttonTokens.textColor,
borderRadius: buttonTokens.cornerRadius,
paddingBlock: buttonTokens.paddingBlock,
paddingInline: buttonTokens.paddingInline,
},
});

So now, we are creating the styles for our Button based on a combination of hard-coded values like borderWidth and themed values like color which is based on the textColor design token.

We also support using that theme by adding a theme property and using it as the basis of our stylex.props.

On the consumer side, we can create a theme using createTheme and basing that theme on the button tokens:

const DARK_MODE = "@media (prefers-color-scheme: dark)";

const corpTheme = stylex.createTheme(buttonTokens, {
bgColor: {
default: "black",
[DARK_MODE]: "white",
},
textColor: {
default: "white",
[DARK_MODE]: "black",
},
cornerRadius: "4px",
paddingBlock: "4px",
paddingInline: "8px",
});

We can even use an object syntax to specify the theme values based on media queries. For example, in this case, in dark mode we invert the button colors.

Then in our page code, we can either send the theme directly to the component:

<Button onClick={onClick} **theme={corpTheme}**>
Corp Button
</Button>

Or we can put the buttons within a container that specifies the theme:

<div {...stylex.props(corpTheme)}>
<Button onClick={onClick}>
Corp Button
</Button>
</div>

Through the magic of CSS variables, any Button inside that div will now get that theme.

Of course, all of this works with React Server Components and Server Side Rendering because it is all computed at compile time and the classes are injected into the code as strings.

Conditional and Dynamic Styles

Often, we think of build time CSS as static, but StyleX supports both conditional and dynamic styles. Let’s add an emphasis flag to our original button:

import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
...,
emphasized: {
fontWeight: "bold",
},
});

export default function Button({
onClick,
children,
emphasized,
}: Readonly<{
onClick: () => void;
children: React.ReactNode;
emphasized?: boolean;
}>) {
return (
<button
{...stylex.props(styles.base, emphasized && styles.emphasized)}
onClick={onClick}
>
{children}
</button>
);
}

All we need to do is add another section for the emphasized style to the styles definition and then check the flag and conditionally add the styles. It’s so easy!

I’m just scratching the surface of what StyleX can do. Styles can be dynamic if you need to run-time generate values like positions or colors. Options like variant are easily supported by simply adding another stylex.create to define the variant then using the correct variant style based on a prop.

The StyleX team has also ported over all of OpenProps to StyleX which puts a huge collection of spacing options, colors, animations, and more right at your fingertips.

Conclusions

The net result of building StyleX and then using it as key component of Facebook.com’s React rewrite was that the site ran on around 130Kb of CSS to start. While that might seem like a lot, that CSS covered every feature of every route. The browser loads that once and is done. No more loading order issues. After three years of development, that is now up to 170Kb, but when you think about the number of developers and features, the trend there is impressive.

StyleX has been in use at Facebook for three years and it is battle hardened. Now, it is making its way into open source where you can take advantage of all of the work and experience that has gone into it.

I’m sure many of you will dismiss StyleX because it’s not as off the shelf easy to use as Tailwind. I don’t deny that is the case, but, in my opinion these two incredibly powerful systems, Tailwind and StyleX, are meant for opposite ends of the spectrum.

I see Tailwind as having a lot of value for small teams working rapidly and more than likely without a set design system outside of perhaps some color, spacing, and breakpoint values.

StyleX is designed to support much larger projects, teams and even groups of teams. StyleX provides us invaluable tooling at this end of the spectrum. I am very appreciative for that. I’ve worked in large companies and building a design system across teams is neither easy nor well understood. It’s great to see Meta bringing a new tool at that end of the spectrum into open source. It is very much appreciated.

--

--