-
-
Notifications
You must be signed in to change notification settings - Fork 32.7k
Description
What's the problem? 🤔
This RFC is a proposal for implementing a zero-runtime CSS-in-JS solution to be used in a future major version of Material UI and Joy UI.
TLDR: We are planning to develop a custom implementation of a zero-runtime CSS-in-JS library with ideas from Linaria and Compiled.
With the rising popularity of React Server Components (RSCs), it’s important that we support this new pattern for all components that are compatible. This mainly applies to layout components such as Box, Typography, etc., as they are mostly structural and the only blocker for RSC compatibility is the use of Emotion.
Another aspect is the use of themes. Currently, they need to be passed through a Provider component (especially if an application is using multiple themes) which uses React Context. RSCs do not support states/contexts.
In the last major version, we moved the styling solution to Emotion for more performant dynamic styles. Since then, Internet Explorer has been deprecated, enabling us to go all in on CSS Variables. We already use this with an optional provider (CSS theme variables - MUI System).
What are the requirements? ❓
- Minimal runtime for peak performance and negligible JS bundle size as far as the runtime is concerned.
- Supporting RSC as part of the styling solution means no reliance on APIs unavailable to server components (React Context).
- Keep the same DX. You should still be able to use the
sx
prop along with container-specific props like<Box marginTop={1} />
etc. - It should be possible to swap out the underlying CSS-in-JS pre-processor. We have already explored using
emotion
as well asstitches
, as mentioned below. - Source map support. Clicking on the class name in the browser DevTools should take you to the style definitions in the JS/TS files.
- Minimal breaking changes for easier migration.
What are our options? 💡
We went through some of the existing zero-runtime solutions to see if they satisfy the above requirements.
- vanilla-extract - This ticks most of the boxes, especially when used along with libraries like dessert-box. But its limitation to only be able to declare styles in a
.css.ts
file felt like a negative point in DX. - Compiled - Compiled is a CSS-in-JS library that tries to follow the same API as Emotion which seems like a win, but it has some cons:
- Theming is not supported out of the box, and there’s no way to declare global styles.
- Atomic by default. No option to switch between atomic mode and normal CSS mode.
- Linaria - Linaria in its default form only supports CSS declaration in tagged template literals. This, along with no theming support as well as no way to support the
sx
prop led us to pass on Linaria. - PandaCSS - PandaCSS supports all the things that we require: a
styled
function, Box props, and an equivalent of thesx
prop. The major drawback, however, is that this is a PostCSS plugin, which means that it does not modify the source code in place, so you still end up with a not-so-small runtime (generated usingpanda codegen
) depending on the number of features you are using. Although we can’t directly use PandaCSS, we did find that it uses some cool libraries, such asts-morph
andts-evaluate
to parse and evaluate the CSS in its extractor package. - UnoCSS - Probably the fastest since it does not do AST parsing and code modification. It only generates the final CSS file. Using this would probably be the most drastic and would also introduce the most breaking changes since it’s an atomic CSS generation engine. We can’t have the same
styled()
API that we know and love. This would be the least preferred option for Material UI, especially given the way our components have been authored so far.
Although we initially passed on Linaria, on further code inspection, it came out as a probable winner because of its concept of external tag processors. If we were to provide our own tag processors, we would be able to support CSS object syntax as well as use any runtime CSS-in-JS library to generate the actual CSS. So we explored further and came up with two implementations:
- emotion - The CSS-in-JS engine used to generate the CSS. This Next.js app router example is a cool demo showcasing multiple themes with server actions.
- no-stitches - Supports the
styled
API from Stitches. See this discussion for the final result of the exploration.
The main blocker for using Linaria is that it does not directly parse the JSX props that we absolutely need for minimal breaking changes. That meant no direct CSS props like <Box marginTop={1} />
or sx
props unless we converted it to be something like <Component sx={sx({ color: 'red', marginTop: 1 })} />
. (Note the use of an sx
function as well.) This would enable us to transform this to <Component sx="random-class" />
at build-time, at the expense of a slightly degraded DX.
Proposed solution 🟢
So far, we have arrived at the conclusion that a combination of compiled
and linaria
should allow us to replace styled
calls as well as the sx
and other props on components at build time. So we’ll probably derive ideas from both libraries and combine them to produce a combination of packages to extract AST nodes and generate the final CSS per file. We’ll also provide a way to configure prominent build tools (notably Next.js and Vite initially) to support it.
Theming
Instead of being part of the runtime, themes will move to the config declaration and will be passed to the styled
or css
function calls. We’ll be able to support the same theme structure that you know created using createTheme
from @mui/material
.
To access theme(s) in your code, you can follow the callback signature of the styled
API or the sx
prop:
const Component = styled('div')(({ theme }) => ({
color: theme.palette.primary.main,
// ... rest of the styles
}))
// or
<Component sx={({ theme }) => ({ backgroundColor: theme.palette.primary... })} />
Although theme tokens’ structure and usage won’t change, one breaking change here would be with the component
key. The structure would be the same, except the values will need to be serializable.
Right now, you could use something like:
const theme = createTheme({
components: {
// Name of the component
MuiButtonBase: {
defaultProps: {
// The props to change the default for.
disableRipple: true,
onClick() {
// Handle click on all the Buttons.
}
},
},
},
});
But with themes moving to build-time config, onClick
won’t be able to be transferred to the Button prop as it’s not serializable. Also, a change in the styleOverrides
key would be required not to use ownerState
or any other prop values. Instead, you can rely on the variants
key to generate and apply variant-specific styles
Before
const theme = createTheme({
components: {
MuiButton: {
styleOverrides: {
root: ({ ownerState }) => ({
...(ownerState.variant === 'contained' &&
ownerState.color === 'primary' && {
backgroundColor: '#202020',
color: '#fff',
}),
}),
},
},
},
});
After
const theme = createTheme({
components: {
MuiButton: {
variants: [
{
props: { variant: 'contained', color: 'primary' },
style: {
backgroundColor: '#202020',
color: '#fff'
},
},
],
},
},
});
Proposed API
The styled
API will continue to be the same and support both CSS objects as well as tagged template literals. However, the theme
object will only be available through the callback signature, instead of being imported from a local module or from @mui/material
:
// Note the support for variants
const Component = styled('div')({
color: "black",
variants: {
size: {
small: {
fontSize: '0.9rem',
margin: 10
},
medium: {
fontSize: '1rem',
margin: 15
},
large: {
fontSize: '1.2rem',
margin: 20
},
}
},
defaultVariants: {
size: "medium"
}
})
// Or:
const ColorComponent = styled('div')(({ theme }) => ({
color: theme.palette.primary.main
});
The theme
object above is passed through the bundler config. At build-time, this component would be transformed to something like that below (tentative):
const Component = styled('div')({
className: 'generated-class-name',
variants: {
size: {
small: "generated-size-small-class-name",
medium: "generated-size-medium-class-name",
large: "generated-size-large-class-name",
}
}
});
/* Generated CSS:
.generated-class-name {
color: black;
}
.generated-size-small-class-name {
font-size: 0.9rem;
margin: 10px;
}
.generated-size-medium-class-name {
font-size: 1rem;
margin: 15px;
}
.generated-size-large-class-name {
font-size: 1.2rem;
margin: 20px;
}
*/
Dynamic styles that depend on the component props will be provided using CSS variables with a similar callback signature. The underlying component needs to be able to accept both className
and style
props:
const Component = styled('div')({
color: (props) => props.variant === "success" ? "blue" : "red",
});
// Converts to:
const Component = styled('div')({
className: 'some-generated-class',
vars: ['generated-var-name']
})
// Generated CSS:
.some-generated-class {
color: var(--generated-var-name);
}
// Bundled JS:
const fn1 = (props) => props.variant === "success" ? "blue" : "red"
<Component style={{"--random-var-name": fn1(props)}} />
Other top-level APIs would be:
css
to generate CSS classes outside of a component,globalCss
to generate and add global styles. You could also directly use CSS files as most of the modern bundlers support it, instead of usingglobalCss
.keyframes
to generate scoped keyframe names to be used in animations.
Alternative implementation
An alternative, having no breaking changes and allowing for easy migration to the next major version of @mui/material
is to have an opt-in config package, say, for example, @mui/styled-vite
or @mui/styled-next
. If users don’t use these packages in their bundler, then they’ll continue to use the Emotion-based Material UI that still won’t support RSC. But if they add this config to their bundler, their code will be parsed and, wherever possible, transformed at build time. Any static CSS will be extracted with reference to the CSS class names in the JS bundles. An example config change for Vite could look like this:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// abstracted plugin for vite
import styledPlugin from "@mui-styled/vite";
import { createTheme } from "@mui/material/styles";
const customTheme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
},
components: {
MuiIcon: {
styleOverrides: {
root: {
boxSizing: 'content-box',
padding: 3,
fontSize: '1.125rem',
},
},
},
}
// ... other customizations that are mainly static values
});
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [
styledPlugin({
theme: customTheme,
// ... other tentative configuration options
}),
react(),
]
}));
For component libraries built on top of Material UI, none of the above changes would affect how the components are authored, except for the need to make it explicit to users about their
theme
object (if any), and how that should be imported and passed to the bundler config as discussed above.
Known downsides of the first proposal
Material UI will no longer be a just install-and-use
library: This is one of the features of Material UI right now. But with the changing landscape, we need to compromise on this. Several other component libraries follow a similar approach.
Depending on the bundler being used, you’ll need to modify the build config(next.config.js
for Next.js, vite.config.ts
for Vite, etc.) to support this. What we can do is provide an abstraction so that the changes you need to add to the config are minimal.
Resources and benchmarks 🔗
Playground apps -
Related issue(s)