material-ui
material-ui copied to clipboard
[RFC] Zero-runtime CSS-in-JS implementation
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
sxprop 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
emotionas 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.tsfile 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
sxprop led us to pass on Linaria. - PandaCSS - PandaCSS supports all the things that we require: a
styledfunction, Box props, and an equivalent of thesxprop. 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-morphandts-evaluateto 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
styledAPI 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:
cssto generate CSS classes outside of a component,globalCssto generate and add global styles. You could also directly use CSS files as most of the modern bundlers support it, instead of usingglobalCss.keyframesto 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
themeobject (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)
- https://github.com/mui/material-ui/issues/34826
<Component sx={({ theme }) => ({ backgroundColor: theme.palette.primary... })} />
I'm a bit concerned about how the theme is accessed. Right now, if Component is a client component while the parent is a server component, we can't access the theme like this because functions are not serializable.
@mwskwong We should use CSS vars. A lot faster and easier to write. We'll miss the typecheck, but we'll have to live with that.
<Component sx={{ backgroundColor: 'primary' }} />
@mwskwong This is what you write in your code which will then be replaced at build time (dev or prod) with the generated css. theme will be callback argument passed by the said tool to your function. It won't matter in that case whether it's a client or a server component as far as the sx prop is concerned.
Nice! I wonder how nested themes would be supported 🤔.
Our use case: We heavily use nested themes where our main theme overwrites almost every component. For specific pages we have a unique look and feel where we use nested themes that partially update some components (ex font family for all h1-h4 or fully rounded buttons).
Another use case: we have a fully dark based theme except for our ecommerce pages they are completely light theme based (again with a nested light theme), but the header and footer stay in the dark theme for example 😅.
Looking forward! Regards
hey, this looks amazing !
regarding Panda, I wanted to add a little more infos:
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 using panda codegen) depending on the number of features you are using.
I can understand that. You could evaluate those css calls etc at build-time I guess tho, as it was done by the Qwik people here
Although we can’t directly use PandaCSS, we did find that it uses some cool libraries, such as ts-morph and ts-evaluate to parse and evaluate the CSS in its extractor package.
you could use the extractor on its own if that helps, it has no panda-specific dependencies !
This could be a brave idea, but what do you think about completely dropping or making opt-in CSS-IN-JS in favor of libraries like TailwindCSS or UnoCSS for styling?
It could be an ambitious, but long-term win solution and definitely a win-win decision
No matter what you will do, you will have to implement an intermediate layer.
One question: Is CSS-in-JS support incoming for RSC on the React side? There has been some discussion around this, though I can't find where.
One question: Is CSS-in-JS support incoming for RSC on the React side? There has been some discussion around this, though I can't find where.
The reason why runtime CSS-in-JS (not just CSS in JS in general to be exact) has so many problems in the new architecture of React is because as its name suggests, it needs JS in runtime to function, while RSC does the exact opposite.
For this RFC, we are talking about zero runtime CSS-in-JS, so ideally, everything will be converted into plain CSS during built-time and won't have the issue we are facing. Although one thing to consider is whether it can maintain a certain degree of dynamic since we no longer have access to resources like theme during runtime.
Why not use CSS Modules for theming ? I believe this would be simpler and easier to do. Though the dynamic styling part needs to be figured out. Maybe use good old styles object with CSSProperties from react for type safety.
We recently diteched our old DS which used context and CSS-IN-JS for theming and the new CSS Variables are way easier to do with Design tokens as well. Performant and can easily style components if required. Can also do Module Scss if you would like.
We took alot of inspiration from MUI the way its built and the components API as well. Thanks for building this amazing Library.
@mwskwong We should use CSS vars. A lot faster and easier to write. We'll miss the typecheck, but we'll have to live with that.
The situation I was mentioning can also appear to the new CssVarsProvider API, which is using CSS variables. e.g.
<List size="sm" sx={{ "--List-radius": theme => theme.vars.radius.sm }} />
Such a way of accessing the radius CSS var is more scalable. And yes, I can also do "--List-radius": "var(--joy-radius-sm)" (which is what I'm doing for the sake of RSC compatibility), but I'm taking the risk of making typos.
@mwskwong This is what you write in your code which will then be replaced at build time (dev or prod) with the generated css.
themewill be callback argument passed by the said tool to your function. It won't matter in that case whether it's a client or a server component as far as thesxprop is concerned.
Thanks @brijeshb42 on elaborate on that part, I would say that will be the best of both worlds.
Another use case: we have a fully dark based theme except for our ecommerce pages they are completely light theme based (again with a nested light theme), but the header and footer stay in the dark theme for example 😅.
@JanStevens, we can support this by injecting CSS variables wherever we had previously nested ThemeProvider components. The theme structure would be the same, the usage would be the same, and only the CSS variables' values would change.
Another use case: we have a fully dark based theme except for our ecommerce pages they are completely light theme based (again with a nested light theme), but the header and footer stay in the dark theme for example 😅.
@JanStevens, we can support this by injecting CSS variables wherever we had previously nested
ThemeProvidercomponents. The theme structure would be the same, the usage would be the same, and only the CSS variables' values would change.
Just wondering, with the introduction of data-mui-color-scheme="dark" (or light), does nested ThemeProvider still need to be supported?
@mwskwong We might still keep the ThemeProvider component as-is for compatibility but it'll be replaced at build time with a simple div or as prop. We are still exploring the finer details.
@astahmer I did explore using @pandacss/extractor package and even had a working demo. But later I found that ts-evaluator is not very foolproof compared to how Linaria evaluates values (using node:module). That led us to ditch the whole package along with ts-morph. I feel the extraction part itself is simpler but the main part of the implementation resides in how we can evaluate the extracted AST nodes.
One question: Is CSS-in-JS support incoming for RSC on the React side? There has been some discussion around this, though I can't find where.
The reason why runtime CSS-in-JS (not just CSS in JS in general to be exact) has so many problems in the new architecture of React is because as its name suggests, it needs JS in runtime to function, while RSC does the exact opposite.
For this RFC, we are talking about zero runtime CSS-in-JS, so ideally, everything will be converted into plain CSS during built-time and won't have the issue we are facing. Although one thing to consider is whether it can maintain a certain degree of dynamic since we no longer have access to resources like theme during runtime.
Having no JS after compilation will be a major upgrade for MUI (YES! less JS shipped is always a good thing 👌), I hope you guys still support sx cause without it life gonna pretty hard!
@SC0d3r Yes. We'll still have sx prop. See our exploratory POC.
Going to just brain dump alot of thoughts about Linaria and Styled-Components after many many years of using them before going full circle back to SCSS for the last 1.5 years and never looking back.
Linaria
- Linaria relies on build tooling much like css modules with the difference being it's got a much smaller community and is unlikely to be supported FIRST in all meta frameworks. Using custom webpack loaders will become less and less reliable over time as people explore using more performant tooling written in rust and go where extensions are required to be written in those languages respectively making support for custom build tooling more difficult.
- Linaria and CSS modules with scss are almost the same except that regular scss compiles down to css with near zero runtime impact, Linaria compiles down to regular css but still wraps every element in a component that passes class name meaning you're rendering a wrappper component for every tag.
- Linaria has variables, loops, functions, globally unique class names, ... etc - custom themeing via css variables.
- SCSS (with css modules) has variables, loops, functions, globally unique class names, etc - custom themeing via css variables. It's only the syntax that differs between them.
- SCSS has existed in the frontend ecosystem longer than React and will probably exist long after, hiring people who know scss is much more likely than hiring people who know Linaria, and css-in-js.
- Linaria does have support for nested styles and nested selectors however it ruins build time performance by exponential factors because modern tooling operates on a single file basis making resolving imports to their respective values at build time very expensive as you need to traverse backwards or forwards through the import tree resulting in many passes over the same file. Almost no other loaders in webpack or extensions in babel work like this because the performance is so bad.
- SCSS with CSS modules (css loader) will forever have first class lifetime support in all meta frameworks by default.
- Linaria obfuscates semantic HTML from developers
Converting to/from linaria, styled-components/emotion and css modules is easier than people might think and because it's mostly based on patterns I'd say it would be possible to code-mod most of it or do an incremental approach 1 component at a time.
Variables to/from
// in
Button.styles.ts
export const Button= styled.button`
background-color: ${(p) => p.theme.variants.secondary.backgroundColor};
`;
// out .scss
.button {
background-color: var(--secondary-background-color);
}
Modifers to/from
// Button.styles.ts
export const Button= styled.button`
background-color: ${(p) => p.theme.variants.secondary.backgroundColor};
{({primary}) => {
return css`color: red;`
}
`;
// out .scss
.button {
background-color: var(--secondary-background-color);
&--primary {
color: red;
}
}
// Button.tsx
return <button className={cx(styles.button, theme === "primary" && styles.buttonPrimary)}>{children}<button>;
Loops to/from - Just use scss loops. Reusable snippets to/from - Just use mixins
=== Final Thoughts ===
This may not be completely feasible with the MUI codebase but thought I'd share my long experience with Linaria to allow you to avoid potential headaches where possible.
Our overall UI library and application architecture was much different than MUI with less configurability so while this was the best choice for us for the reasons above it may still be a good option for you.
Will leave this here just to prove I was participating in Linaria community 4+ years ago https://github.com/callstack/linaria/issues/501
Panda-css is amazing, I did some tests when I was looking for a lib that would solve the css-in-js problem. However it is in its first releases and I want to see what else they are planning but I can say that I trust the team at Chakra-ui a lot.
Everything sounds great though i do have a major concern (or a question) here.
Lets say i have a Design System library that wraps MUI, we don't have a build process other than passing our code via babel cli for both cjs and esm transpilations.
My concern/question is, Do we now forced to use a bundler or any other tool or framework to be able to convert the styles in build time?
The 2 possible solutions i see here, and i might be way off:
- Let the consumers of my lib pass it through a build.
- Somehow do it via a babel plugin, which might hurt performance (degrade DX) due to the fact we will need to parse AST.
@sag1v We've already covered this in the RFC
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 (if the user has included the zero runtime config in their bundler setup).
But later I found that
ts-evaluatoris not very foolproof compared to how Linaria evaluates values (usingnode:module).
seems like everything that is pure can be compiled away down to css classes, and everything that has some form of conditional or complex js needs to either remain with some small runtime or compile down to something that looks like a classnames/clsx style. Just looking for recognized strings is basically what the tailwind compiler does to generate the classes.
// pure can be compiled down to classes
sx={{ padding: 3, color: 'red' }} => className="p-3px red"
// dynamic needs to retain some runtime
sx={{ padding: 3, color: isActive ? 'red' : 'blue' }} => className={classNames("p-3px", isActive ? 'red' : 'blue')}
So the strings/numbers become the important thing to interpret and then any complex code is given up on and left inline which leaves some runtime code but I don't know any way around it. The problem with inline code is always that it needs to be interpreted which is why vanilla extract just evals to css and leaves the dynamicism to their sprinkles js runtime.
I was wondering... Instead of creating MUI's own zero-runtime CSS-in-JS solution, what about helping PandaCSS migrate from PostCSS to LightningCSS? MUI spends less time re-inventing the wheel and makes a great, existing CSS solution even better.
@kylemh As explained in the RFC about panda, it's not about whether panda is using postcss or lightningcss to generate the css. Panda does not modify the original code. Which means whatever you write in your code along with how panda generates the classnames remain part of the final build and hence increase the overall bundle size together with its runtime.
Interesting. Doesn't that mean their claim to zero runtime is incorrect?
Also, another question... Will the solution MUI build be integrated as part of the monorepo or as a separate repository? If somebody wanted to use the CSS-in-JS library, will the relevant APIs be code-splittable?
It's zero-runtime in the sense that there's no runtime similar to emotion that generates dynamic stylesheets and adds it to the head.
Will the solution MUI build be integrated as part of the monorepo or as a separate repository? If somebody wanted to use the CSS-in-JS library, will the relevant APIs be code-splitable?
@kylemh the monorepo contains different packages that can be used independently. Any package you don't use won't be included in your bundle. It is true even now, for by e.g. using @mui/system, you are not getting any of the @mui/material (Material UI)'s code.
A question about the theming that I don't believe has been answered above.
How does it affect the "automatic theme string resolving"? (I don't know what you call it officially) ie:
<Box sx={{ color: 'grey.200' }}>This is theme.palette.gray['200']</Box>
<Box sx={{ color: 'accent.main' }}>This is a custom theme color theme.palette.accent.main</Box>
First, you provide your theme object through the bundler config. That way, this tool will have access to your theme with all it's customisations.
Then after static extraction and evaluation, we'll get the css object {color: 'grey.200'}. This css object will then go through the same process that it goes through right now (in runtime mode) except all of this will happen at build time. See exploration code.
After processing, your code will be modified to -
<Box sx="generated-class-name-1" />
<Box sx="generated-class-name-2" />
.generated-class-name-1 {
color: actualvalueofgrey200;
}
.generated-class-name-2 {
color: actualvalueofaccentmain;
}
The underlying Box component will just have to pass the value of sx as className to the finally rendered div or whatever HTMLElement provided through as prop.
Great work on the existing challenges and alternatives. The solution you propose seems sensible, and can be integrated downstream to dependent projects like react-admin without too much hassle (even though it'll require a major version bump).
I can't wait to see a POC on a small subset of the MUI components.
All green for me!
A question about the theming that I don't believe has been answered above.
How does it affect the "automatic theme string resolving"? (I don't know what you call it officially) ie:
<Box sx={{ color: 'grey.200' }}>This is theme.palette.gray['200']</Box> <Box sx={{ color: 'accent.main' }}>This is a custom theme color theme.palette.accent.main</Box>
If extracting the real color is hard it would also be possible to use css custom properties (css variables) for that. e.g.:
<Box sx="generated-class-name-1" style={{ "--generated-css-var-name-1": theme("grey.200") }} />
<Box sx="generated-class-name-1" style={{ "--generated-css-var-name-1": theme("actualvalueofaccentmain") }} />
.generated-class-name-1 {
color: var(generated-css-var-name-1);
}
perhaps static colors are a bad example but maybe this might help in more dynamic cases