mui / material-ui

MaterialĀ UI: Ready-to-use foundational React components, free forever. It includes Material UI, which implements Google's Material Design.
https://mui.com/material-ui/
MIT License
91.86k stars 31.57k forks source link

[RFC] Zero-runtime CSS-in-JS implementation #38137

Open brijeshb42 opened 9 months ago

brijeshb42 commented 9 months ago

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? ā“

What are our options? šŸ’”

We went through some of the existing zero-runtime solutions to see if they satisfy the above requirements.

  1. 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.
  2. 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.
  3. 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.
  4. PandaCSS - PandaCSS supports all the things that we require: a styled function, Box props, and an equivalent of the sx 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 using panda 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 as ts-morph and ts-evaluate to parse and evaluate the CSS in its extractor package.
  5. 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:

  1. 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.
  2. 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:

  1. css to generate CSS classes outside of a component,
  2. globalCss to generate and add global styles. You could also directly use CSS files as most of the modern bundlers support it, instead of using globalCss.
  3. 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 -

  1. Next.js
  2. Vite

Related issue(s)

mwskwong commented 9 months ago
<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.

arobert93 commented 9 months ago

@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' }} />
brijeshb42 commented 9 months ago

@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.

See the POC code where Home is a server component.

JanStevens commented 9 months ago

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

astahmer commented 9 months ago

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 !

OlegLustenko commented 9 months ago

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.

damassi commented 9 months ago

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.

mwskwong commented 9 months ago

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.

NicestRudeGuy commented 9 months ago

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 commented 9 months ago

@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. 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.

See the POC code where Home is a server component.

Thanks @brijeshb42 on elaborate on that part, I would say that will be the best of both worlds.

mnajdova commented 9 months ago

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.

mwskwong commented 9 months ago

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.

Just wondering, with the introduction of data-mui-color-scheme="dark" (or light), does nested ThemeProvider still need to be supported?

brijeshb42 commented 9 months ago

@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.

brijeshb42 commented 9 months ago

@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.

SC0d3r commented 9 months ago

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!

brijeshb42 commented 9 months ago

@SC0d3r Yes. We'll still have sx prop. See our exploratory POC.

ShanonJackson commented 9 months ago

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

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

saviorhavely commented 9 months ago

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.

sag1v commented 9 months ago

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:

  1. Let the consumers of my lib pass it through a build.
  2. Somehow do it via a babel plugin, which might hurt performance (degrade DX) due to the fact we will need to parse AST.
brijeshb42 commented 9 months ago

@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).

achan-godaddy commented 9 months ago

But later I found that ts-evaluator is not very foolproof compared to how Linaria evaluates values (using node: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.

kylemh commented 9 months ago

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.

brijeshb42 commented 9 months ago

@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.

kylemh commented 9 months ago

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?

brijeshb42 commented 9 months ago

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.

mnajdova commented 9 months ago

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.

Paso commented 9 months ago

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>
brijeshb42 commented 9 months ago

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.

fzaninotto commented 9 months ago

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!

jantimon commented 9 months ago

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

toniocodo commented 9 months ago

Great job MUI team! I love where this is going, I'm just afraid that 0 runtime will limit the possiblities regarding computed values and auto-bound theme shordtcut (such as <Box m={2} /> translating to margin: 16px with default 8px spacing). Is it plan to also support responsive value object such as <Box m={{ xs: 2, md: 4, xl: 6 }}>.

mnajdova commented 9 months ago

Great job MUI team! I love where this is going, I'm just afraid that 0 runtime will limit the possiblities regarding computed values and auto-bound theme shordtcut (such as translating to margin: 16px with default 8px spacing).

We plan to keep supporting this.

Is it plan to also support responsive value object such as <Box m={{ xs: 2, md: 4, xl: 6 }}>.

Yes, this will be supported too.

MetaMmodern commented 9 months ago

There is a zero-runtime styling solution already, it's called CSSšŸ˜„šŸ˜„šŸ˜„ truly 0 runtime, no JS execution at all

It would be nice to see some article or docs on how to build your costom theme with custom design system on top of Mui using plain css/sass/less and using only global css selectors. For people who don't like the core idea of CSS-in-JS and delivering styles packed inside js this can be as second breath to improving performance and caching even further.

layershifter commented 9 months ago

As author of Griffel and issue-contributor to Linaria let me comment on extraction/evaluator part. This conversation happened before with MUI team, but it would be great to have it public šŸ±

Basically there are two approaches to handle style transforms: static and runtime.

Static

Static is what used in CompiledCSS & Stylex and relies on AST analysis to extract styles. That's clean, fast and nice, but the following scenario becomes impossible:

import { vars } from './vars'

const classes = css`
  color: vars.bgColor,
`

In this scenario it's not enough to analyze the source file as there is an import to ./vars. While style definitions are usually have the strict structure, the contents of other files are a complete rabbit hole. For example, vars variable can use third party library:

// vars.ts
import colors from 'some-colors-lib'

export const vars = {
  bgColor: colors.generate().hex(),
}

As colors.generate() is a function it becomes impossible to resolve it using only AST.

We write a lot of code in Compiled to statically evaluate CSS. And I mean a lot of code. I don't think we can reliably continue down this to get worthwhile ROI for it, even though it "feels best", as there are countless edge cases we need to handle, and will need to handle in the future as the library rolls out. What's worse is its not portable, so if we were to keep investing in this area, when we move to a newer bundler (e.g. esbuild) we'd have to throw the code away (or re-use it inside esbuild, but that would only be an interim solution).

https://github.com/atlassian-labs/compiled/issues/723

Runtime

Runtime is what is used in Griffel, Linaria and Vanilla Extract. Griffel relies on Linaria evaluation and Vanilla Extract uses vite-node.

Approaches are bit different, but the goal is the same: resolve expressions from styles using Node runtime and place results back in AST. In the scenario above the function colors.generate().hex() will be invoked and resolved to a real value.

This also allows to have the same code for runtime & transforms (AST => values => JS code => AST).

The caveats are performance (basically, it's a bundler inside a bundler) and unsafe code for Node (https://github.com/vitest-dev/vitest/issues/3525). Linaria is in a better position as it's designed for this purpose and also avoids evaluation of unrelated code.

You can check the reference implementation of Griffel processor for Linaria v4 there (https://github.com/microsoft/griffel/pull/414).

The hard truth is that I am still working with @Anber to push Linaria v4 into the state when it can compile Microsoft projects šŸ˜æ


The final decision on the approach depends on the final API, but it's good to consider possible limitations.

Anber commented 9 months ago

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.

It's not entirely true. At present, Linaria serves more as an engine for zero-runtime evaluations rather than solely functioning as a CSS-in-JS library. The modules @linaria/core and @linaria/styled are considered reference implementations. Additionally, as mentioned by @layershifter, they have developed their own CSS-in-JS library with a distinct syntax, which is actually built upon the Linaria.

brijeshb42 commented 9 months ago

@Anber I completely agree that it can serve as a building block for another library. I've explored this in the RFC as well. The problem while evaluating using Linaria was that with tagged template literals, Linaria already did the process of hoisting the inlined function expression and that was then taken over by the relevant styled processor (@linaria/react/processors). But the same would not work with css object -

const H1 = styled.h1({
  color: (props) => props.isRed ? 'red' : 'blue',
});

but would work with

const H1 = styled.h1`
  color: ${(props) => props.isRed ? 'red' : 'blue'};
`

So essentially, I will not be able to support dynamic styles in the same way it can be done with template literals. Please correct me if I am missing something.

Anber commented 9 months ago

@brijeshb42 https://github.com/callstack/linaria/blob/master/packages/testkit/src/babel.test.ts#L2744 like this? :)

brijeshb42 commented 9 months ago

@Anber Not really. I was able to make something similar work. See this. I was asking how dynamic styles would work when thought of in the same way how Linaria hoists expressions in template literals.

Anber commented 9 months ago

@brijeshb42 The implementation depends on the nature of the theme object.

If the theme object is a simple recursive one with string keys and primitive values, references like theme.colors.primaryMain.value can be replaced with CSS variables like var(--theme-colors-primaryMain-value). In this case, it comes down to makeStyles.

However, if the theme object can take any form, or if a value from the theme needs to be modified in a template (e.g., hslToRgb(theme.colors.primaryMain.value)), it's better to define locally scoped variables inside the component. Consequently, your SliderRoot will be compiled to something like this:

const SliderRoot = themedStyled("span", {
    // ā€¦
    class: ".extracted-class-name",
    theme: {
        "--PREFIX-theme-colors-primaryMain-value": theme => theme.colors.primaryMain.value,
        "--PREFIX-theme-colors-secondaryMain-value": theme => theme.colors.secondaryMain.value,
    }
})
siriwatknp commented 8 months ago

@brijeshb42 Would you mind adding links, sandbox, or docs to play with the POC?

brijeshb42 commented 7 months ago

@siriwatknp The POC is within the apps folder. There are two apps. One using nextjs and other using vite. There's README in both the apps explaining how to get started.

o-alexandrov commented 4 months ago

Have you considered tokenami? tokenami seems to tick all the boxes, while offering a good DX.


I think stylex should be the preferred option, due to the popularity (because of Facebook). Second best, imho, is tokenami

oliviertassinari commented 3 months ago

I feel like it's not super clear why the team behind Material UI is working on a new styling solution. We have https://mui-org.notion.site/pigment-css-Why-a-standalone-project-25205c31c8564e0daee6f79523de9b5c#67fa15ea6a03492ba92ac299f22ef98e where we gather all the challenges with existing solutions.

Here is how I understand the situation:

Why not Tailwind CSS

Why not tokenami?

Why not Panda CSS

Why not StyleX?

Why not Emotion?

Why not Linaria

No strong concern, I believe our current solution is to package it in a way that is fully consumable with existing emotion/styled-components APIs for a smooth migration story.

So we could say, we use Linaria. The documentation experience isn't great though, šŸ˜…, I could see ourselves creating a lot of it.

I believe we solve all the issues raised in https://dev.to/srmagura/why-were-breaking-up-wiht-css-in-js-4g9b.


Our current progress: https://github.com/mui/material-ui/pull/40685.

o-alexandrov commented 3 months ago

@oliviertassinari you mentioned about Atomic CSS:

=> We need a solution that outputs a single clear class name. Material UI v4 was the dream on this front IMHO.

In both, tokenami and stylex, the maintainers are open to discussing new API (ex. outputting a constant class name):

The constant class name feature is a simple extra parameter (ex. object to accommodate all other possible requests with name property) that stylex could accept and toggled on and off with a bundling pluginā€™s option.

Whereas nested CSS selectors rule could also be moved to the bundling plugin to enforce and toggled on and off.

Why not to consider to touch base with stylex maintainers (ex. @nmn) and request what is missing for MaterialUI. Itā€™d be a win-win for everyone.

nmn commented 3 months ago

I would love to know the reasoning against atomic styles. The only concern that I saw is that with atomic styles like Tailwind, it becomes hard to customise styles. This is not the case with StyleX. One of the biggest reasons for StyleX existing is reliably style merging across components so that design system components can be predictably customised. FWIW, Panda does pretty well on this metric as well.

I think it would be useful to consider generating atomic styles because they actually give you more reliable style merging than traditional CSS.

Now, to address the particulars of using StyleX:

  1. StyleX does not support the "styled components" API with a string to define styles and we don't plan to. However, it should be easy to combine StyleX with a separate Babel transform that supports the API you want. I've published a similar package, tw-to-stylex that compiles Tailwind to stylex calls.

  2. Support for descendent selectors. This is currently not supported, but planned for the near future. We will be supporting this in a way where encapsulation doesn't break. Applying a className on one element should never style a different element. But checking if a parent is being hovered while applying styles on a child should be fine. The approach is similar in spirit to group from Tailwind.

Next I want to chime in and say that other than StyleX, I would also take a look at Panda, Griffel and Linaria. I'm less sure about Tokenami, because although the actual CSS is in a static file, it relies on heavy usage of CSS variables within inline styles. I'm not sure if that will suffer from the same performance issues as inline styles or not.


@layershifter also made the following comment:

but the following scenario becomes impossible:

import { vars } from './vars'

const classes = css`
  color: vars.bgColor,
`

We have found a way to support a pattern similar to this with our theming APIs, but he's right that it doesn't allow arbitrarily importing values across files and using them.

ivanbanov commented 3 months ago

From the video Dream CSS and JS Solution (4:20), the main problem presented against StyleX boils down to "for me it's a dealbreaker because I like nested selector", to be fair it's your project and your decisions, but it does not sound like a tech reason.

About PandaCSS generating only atomic classes, as other people said here, I wonder why not contribute to the project, it seems like it could be achieved there, and a new option like atomicClass: false could be handy for other people. I like components as selectors too, but it feels much more like a debug option to find your component based on class name, following this idea this specific class name is not needed in the build time, for that you can still rely on runtime.

I understand it could generate friction due to slow decisions cross-project and a lack of freedom to make decisions for MUI itself, which sounds like the main reason behind the whole move of creating a whole new style engine.

Jack-Works commented 3 months ago

IMOP, It is a non-starter if the final solution is vendor lock-in (e.g. requiring developers to use babel/webpack). AFAIK a lot of zero-runtime CSS-in-JS solution requires a compiler, and that compiler usually only being implemented in one ecosystem (as a babel plugin or webpack plugin)

layershifter commented 3 months ago

IMOP, It is a non-starter if the final solution is vendor lock-in (e.g. requiring developers to use babel/webpack). AFAIK a lot of zero-runtime CSS-in-JS solution requires a compiler, and that compiler usually only being implemented in one ecosystem (as a babel plugin or webpack plugin)

That's what https://wyw-in-js.dev/ tries to solve and provides plugins for every bundler.

nmn commented 3 months ago

I wouldn't call using a compiler "vendor lock-in".

Most JS code already uses Babel or SWC. There is no dependency on a particular bundler in any of the compiler-based CSS solutions.

chrisweb commented 3 months ago

I agree that it would be great if in the future we could use both StyleX and MUI together and based on what got mentioned in the first message on top (of this RFC proposal) of this discussion, I think this is exactly what the MUI team is planning:

It should be possible to swap out the underlying CSS-in-JS pre-processor

So as I understand it, if StyleX has the required features (in the future) you will be able to swap the MUI CSS-in-JS pre-processor with StyleX (or any other css solution that meets the requirements)

From a strategic point of view I think it will benefit MUI if it is compatible with StyleX (at some point in the future), because I think StyleX will get a lot of traction for the simple reason that it is a library that is being made by the same company that is behind React and because they use it for projects like instagram

I however also understand why the MUI team is working on their custom solution right now, because they need a solution as soon as possible (and not one where the discussions haven't even started yet), the next.js app directory has been out for a while and I assume there are a lot of devs that are trying to use MUI with it, but then they read all the articles mentioning problems related to emotion and RSC and start to consider migrating to other UIs that already have zero runtime CSS-in-JS solution built in... it is great to hear that the StyleX devs are open for discussions as it means that in the future MUI + StyleX might be a thing but this is probably not soon enough for MUI

I don't think MUI working on their own custom CSS-in-JS pre-processor is a bad thing, it will be the "standard implementation" but that doesn't mean it will be the only solution, so to me it seems we get both, a solution that is ready sooner than later and the possibility to combine MUI with our favorite CSS solution (at a later date)