Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[RFC] CSS theme variables support #27651

Closed
13 of 16 tasks
siriwatknp opened this issue Aug 8, 2021 · 14 comments
Closed
13 of 16 tasks

[RFC] CSS theme variables support #27651

siriwatknp opened this issue Aug 8, 2021 · 14 comments
Labels
discussion new feature New feature or request package: system Specific to @mui/system RFC Request For Comments

Comments

@siriwatknp
Copy link
Member

siriwatknp commented Aug 8, 2021

Problems

  • Dark theme flash on SSR (server-side rendering)
    One obvious example is material-ui documentation. If you toggle dark theme and press hard refresh, you will see a flash of light mode before it turns dark.

  • Bad debugging experience
    the value in dev tool is the color code ex. #f5f5f5 which is hard to know which design token the component is using. This gives bad experience to both developer (working locally) and non-developer (checking the color in production website)

    Screen Shot 2564-08-08 at 14 20 45

    What is the variable that the component is using? no idea unless look at the code.

  • Performance
    the current implementation consider light & dark as a separate theme which means by toggling to another mode will cause component in the tree to rerender. (However, the impact is not significant ~100-300ms depends on how large the page is)

Solution

Replace runtime calculation of palette inside the component with css variable reference.

// before
const Button = styled('button')(({ theme }) => ({
  backgroundColor: theme.palette[ownerState.color].main,
  color: theme.palette[ownerState.color].contrastText,
}))

// after
const Button = styled('button')(({ theme }) => ({
  backgroundColor: theme.vars[`palette-${ownerState.color}-main`], // result will be 'var(--palette-primary-main)'
  color: theme.vars[`palette-${ownerState.color}-contrastText`], // result will be 'var(--palette-primary-contrastText)'
}))

Now, the component stylesheet does not change between render because the backgroundColor and color are just string. To achieve perfect dark mode, all themes must be prepared at build time because we want to set the correct mode before browser render the DOMs.

:root {
  --bg: #fff;
  --text: #000;
}
[data-theme="dark"] {
  --bg: #000;
  --text: #fff;
}

This is a simple script that have to be placed at the top of <body> (before main React script) to set the user's selected mode.

// at this point, there is nothing paint on screen yet.
(function() { try {
  var mode = localStorage.getItem('data-theme');
  if (mode) {
    document.body.setAttribute('data-theme', mode);
  }
} catch (e) {} })();

Then browser renders the DOMs and paint with correct styles (because of css specificity).

Summary: css variables allow us prepare stylesheet with different themes at build time so that we can apply the user's selected theme (on the client) before rendering the whole application.

Benefits

CSS Variables open many doors for improving DX and design opportunities.

  • fix flash dark mode on SSR (with the correct implementation)
  • multiple themes not only light & dark (ex, trueBlack comfort)
  • contextual styles without using javascript (<ThemeProvider theme={darkTheme}>
    // the naming is for demonstration purpose
    ...
    <div data-theme="dark">
      <Button></Button>
      <Card>...</Card>
    </div>
    the content inside <div data-theme="dark"> will behave as dark mode regardless of user's preference.
  • better debugging experience (anyone in the teams including non-devs can see the design tokens directly in the website without looking at the code.
  • improve performance, the toggle between themes still cause react to rerender the tree but does not cause styles recalculation (className hash generated by CSS-in-JS does not change) does not cause the whole application to rerender.

Requirements & Features

check out the POC #28639

Required

  • no flash for SSR
  • able to start with a single theme and extends to multiple themes
  • turn on/off enable system preferences
  • changing between themes should affect all tabs
  • the API should not deviate much from @mui/material so that the migration afford is acceptable (breaking change is expected because the implementation in the components has to change.

Configurable

  • local storage key that stores $colorScheme can be configured
  • attribute (default [data-*="$colorScheme"]) can be configured or change to class selector
  • css variable prefix can be applied (ex. --md-palette-... or --joy-palette-...)
  • able to disable transition on change (inspired by next-themes)
  • able to force theme at the page or section level
  • accept custom storage like using cookies (default to localStorage)
  • turn on/off color-scheme

This is a diagram that shows the high level of what the change looks like

Before After
Screen Shot 2564-09-27 at 16 24 36

Developer needs to implement a state to store user-selected mode and sync when their preference change then recalculates the theme and pass to MUI ThemeProvider.

Screen Shot 2564-09-27 at 16 23 16

MUI takes control of user-selected mode and provides the APIs for developers to configure & input multiple themes, reading & setting mode via react hook.

Note: ThemeProvider is still available.

API interface

The challenge of css variables support is the integration with both the 2nd design system we are working on and the existing material-design components (aka @mui/material). To make it easier to maintain and able to work alongside other products, the css variables support should be a dedicated module that exposes a few APIs for a design system.

CssVarsProvider (use ThemeProvider internally)

  • Generate css variables based on themes
  • attach generated css variables to global styles
    :root: { ... };
    [data-theme="dark"]: { ... };
  • attach extra field vars to theme so the component can reference to css variable with object notation
  • contain mode state and provide hook for changing mode on the client.

code

useColorScheme

A hook to get the current mode and setColorScheme function to change the mode.

const { colorScheme, setColorScheme } = useColorScheme();

<button onClick={() => setColorScheme('dark')}>{mode}</button>

getInitColorSchemeScript

Call this function at the top of the to set the initial color scheme before the DOM is rendered on screen. eg. with Nextjs

// pages/_document.js
import { getInitColorSchemeScript } from '@mui/system';

<body>
  <script
    dangerouslySetInnerHTML={{
      __html: getInitColorSchemeScript(),
    }}
  />
</body>

the script will check the local storage and apply the right mode to DOM before the application is rendered.

Decision on the APIs

What's the vars structure?

***This might change, please read the proposal

The vars is an object that references to CSS variable. This object is created from theme input and then we need to attach it to the theme so that the components can access this object.

// given this theme input example which will be passed to `<CssVarsProvider theme={theme} />`
{
  palette: {
    primary: {
      main: '#007FFF',
      contrastText: '#fff',
    }
  }
}

Option 1: same input structure (nested)

{
  palette: { ... },
  vars: {
    palette: {
      primary: {
        main: 'var(--palette-primary-main)',
        contrastText: 'var(--palette-primary-contrastText)',
      }
    }
  }
}

Option 2: replace the theme with vars and move theme input inside another key

{
  palette: {
    primary: {
      main: 'var(--palette-primary-main)',
      contrastText: 'var(--palette-primary-contrastText)',
    }
  },
  raw: {
    palette: {
      primary: {
        main: '#007FFF',
        contrastText: '#fff',
      }
    }
  }
}

Option 3: flatten This option is hard for @mui/material to migrate

{
  palette: { ... },
  vars: {
    'palette-primary-main': 'var(--palette-primary-main)',
    'palette-primary-contrastText': 'var(--palette-primary-contrastText)',
  },
}

Need verification

  • compare generated css file size between bundling all the themes and applying one theme at a time. (with 100 variables like --mui-palette-primary-main: ... gain ~3.5kB)

Plan & Progress

  • create an unstable_api in system (could be moved to its own package later). [system] Add unstable_createCssVarsProvider api #28965
    • no flash for SSR
    • able to start with single theme and extends to multiple themes
    • local storage key that store $colorScheme can be configured
    • attribute [data-*="$colorScheme"] can be configured or change to class selector
    • css variable prefix can be applied (ex. --md-palette-... or --joy-palette-...)
    • changing between theme should affect all tabs
  • 2nd design system starts to consume the apis + add more features.
    • separate mode ('light' | 'dark') from colorScheme ('light' | 'dark' | ...) (inspired by Primer)
    • able to config default colorScheme for day & night mode
    • turn on/off enable system preferences
    • able to disable transition on change (inspired by next-themes)
    • accept custom storage like using cookies (default to localStorage)
    • support iframe
    • turn on/off color-scheme
  • @mui/material (after the module is settled) [RFC] Strategy for adopting CSS variables in `@mui/material` #30485
    • improve the colors palette without breaking change, hopefully (in order to remove runtime calculation such as alpha lighten, ...)
    • expose another Provider that consume CSS variables module
    • convert components to use cssVars (names might be changed)
      // Button
      const Button = styled('button')(({ theme: { vars, ...rest } }) => {
        const theme = vars || rest;
        // the implementation would not change much, and allow developer to migrate smoothly.
        return {
          backgroundColor: theme.palette.primary.main, // var(--palette-primary-main)
          borderRadius: theme.shape.borderRadius, // var(--shape-borderRadius)
          ...
        }
      })

Related issues

Resources & Inspiration

@siriwatknp siriwatknp added the new feature New feature or request label Aug 8, 2021
@oliviertassinari oliviertassinari added the package: system Specific to @mui/system label Aug 8, 2021
@oliviertassinari
Copy link
Member

@siriwatknp Notes:

  • The related issue: [system] Support for prefers-color-scheme with no flashes #15588, Support CSS variables as theme option #12827.
  • On the dark/light aspect, we also need to take into account the color-scheme CSS property of the <html> element for the scrollbar, native forms, etc: [theme] Improve darkScrollbar helper #25016 (comment)
  • "Case I: JS is disabled on the client" I would propose that we skip this case. AFAIK, disabling JS is no longer a thing among end-users. Simpler.
  • "improve performance, the toggle between themes still require javascript but does not cause the whole application to rerender." AFAIK we would need to update the theme on the toggle, which would rerender the whole app. I don't buy in this narrative.
  • I think that it would be great to talk about the new APIs in this RFC, we don't talk about them in a concrete way:
    • How will we write custom light/dark mode logic? (e.g. the ones we have in the rebranding, it will make quite a load of values moves to the theme level. In a way, it's good for forcing uniformization).
    • Does it require a new context provider or can we use the exiting ThemeProvider?
    • How are the different mode values declared in theme?
    • Are the CSS variables made for public usage?
    • What happens with the numerical scale values? Do we simply not handle them in CSS variables? e.g. margin: 34.5,
    • Assuming that the theme would now contain CSS variables. How do we access the resolved values programmatically?

@eps1lon
Copy link
Member

eps1lon commented Aug 9, 2021

AFAIK we would need to update the theme on the toggle, which would rerender the whole app. I don't buy in this narrative.

Obviously we would need to revisit how we use the theme. There wasn't a need before but if we can enable not re-rendering then we should try it. I suspect that for dark mode (which only affects color) we can probably get this fairly easy by splitting contexts.

@oliviertassinari
Copy link
Member

oliviertassinari commented Aug 9, 2021

we would need to revisit how we use the theme.

@eps1lon Agree. To be clear on where I was coming from, I was trying to consider what will happen with:

https://github.com/mui-org/material-ui/blob/ebbe3728f52dbf6e1b71dfb2d1a5149d19acb5fb/docs/src/components/home/ProductSuite.tsx#L16-L18

@TimoWilhelm
Copy link
Contributor

Would it be possible to add a SHA hash to getInitColorSchemeScript() to avoid having to add 'unsafe-inline' to a content-security-policy header?

@siriwatknp
Copy link
Member Author

Would it be possible to add a SHA hash to getInitColorSchemeScript() to avoid having to add 'unsafe-inline' to a content-security-policy header?

I think it should be possible because the getInitColorSchemeScript() returns a React element, so you can append any props as you like.

<body>
  {React.cloneElement(getInitColorSchemeScript({ enableSystem: true }), {
    // ...any props
  })}
  <Main />
</body>

@TimoWilhelm
Copy link
Contributor

TimoWilhelm commented May 11, 2022

I'm using the following function in my next.config.mjs to get the SHA hash for my CSP header, which works for now:

import { renderToStaticMarkup } from 'react-dom/server.js';
import { getInitColorSchemeScript } from '@mui/material';
import crypto from 'crypto';

function getMuiInitColorSchemeScriptHash() {
  const muiInitColorSchemeScriptElement = renderToStaticMarkup(
    getInitColorSchemeScript(),
  );

  const muiInitColorSchemeScript = /<script>([\s\S]*?)<\/script>/i.exec(
    muiInitColorSchemeScriptElement,
  )[1];

  return crypto
    .createHash('sha256')
    .update(muiInitColorSchemeScript)
    .digest('base64');
}

This could be simplified, since the script is already a string in the getInitColorSchemeScript(). Maybe it could return the hash amd script as properties?

It might also be a good idea to minify the script content before setting the innerHTML using something like terser.

@siriwatknp
Copy link
Member Author

It might also be a good idea to minify the script content before setting the innerHTML using something like terser.

Agree!

@siriwatknp
Copy link
Member Author

siriwatknp commented Jul 7, 2022

Since CSS variables support is still an experiment, I want to propose another way of using CSS variables from the theme.

Current

The CssVarsProvider attach an object to theme.vars that has the same structure as the theme with the values of CSS variables:

eg.
console.log(theme.vars.palette.primary.main) // var(--mui-palette-primary-main)

There are 2 things that pop into my head when migrating the mui docs to use CSS variables.

  1. Using theme.vars.* might break the app if you want to switch to another theme. consider this implementation:
const StyledComponent = styled('button')(({ theme }) => ({
  color: theme.vars.palette.brand.main,
})

If you put another theme that does not have brand object, you will see this error on the screen [TypeError] cannot read property of 'main' which is really a bad DX because you don't know where the error comes from (let's say that you don't know how the component is implemented)
2. Autocomplete is nested, meaning you have to be familiar with the theme structure. If you want to see what colors you have in primary palette, you have to type theme.vars.palette.primary. and then you will see the list of available colors. This is hard if you are not familiar with the theme structure so you have to know that primary is in theme.vars.palette.
3. It is hard to provide a fallback to the variable.

color: theme.vars.palette.primary.main.replace(/\)/, ', var(--custom-color))')
// so it becomes `var(--mui-palette-primary-main, var(--custom-color))`
// this is how CSS variables work for providing a fallback
// https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties#custom_property_fallback_values

Using a function instead

We have already attached a function called theme.getCssVar() that accept a string and turn it into CSS variable reference.:

console.log(theme.getCssVar('palette-primary-main')) // var(--mui-palette-primary-main)

so the implementation will be like this:

const StyledComponent = styled('button')(({ theme }) => ({
  color: theme.getCssVar('palette-primary-main'),
})

Benefits:

  1. Your app would not break if you switch between themes or migrate to a different theme because the theme will always have getCssVar attached to it (by the CssVarsProvider). If another theme does not have the variable, the CSS will fallback to the initial value which is a great DX because you can always inspect it in the devtool and then you will see that the variable is missing (you can decide between providing that variable or changing the component's implementation to use your own variable)
  2. Flat autocomplete, this is easier to see what tokens do you have in your theme. You can also augment the type to include your own tokens (what's cool is that the token does not need to come from MUI)

Screen Shot 2565-07-07 at 11 44 36

3. Using the function is easier when you want to provide the fallback. Just add the fallback to the second argument
theme.getCssVar('palette-primary-main', 'var(--custom-color)')

Note: this proposal does not change the way you create the theme, it just changes the way you get the variable. If this sounds good, we will remove the theme.vars and move forward with theme.getCssVar instead.

@mnajdova
Copy link
Member

mnajdova commented Jul 7, 2022

Leaving some comments on the pros/cons, I am not sure what is the best way going forward, I feel like we should do a poll about it at some point to see what the community would vote for.

On the current approach problems:

  1. Using theme.vars.* might break the app if you want to switch to another theme.

I wouldn't say this is necessary bad, as you would like to catch these errors before going to production. Silently crashing may be worse, as in big apps it's hard to validate all screens and all different states.

  1. It is hard to provide a fallback to the variable.

Agree, we anyway need to have some util to help here I guess :)

@joshkay
Copy link

joshkay commented Sep 3, 2022

After transitioning to the Experimental_CssVarsProvider I started getting the following error with Next.js due to the generated style ids clashing between client & server.

Warning: Prop `className` did not match. Server: "MuiPaper-root MuiPaper-elevation MuiPaper-elevation4 MuiAppBar-root MuiAppBar-colorPrimary MuiAppBar-positionFixed mui-fixed mui-style-16wruoc-MuiPaper-root-MuiAppBar-root" Client: "MuiPaper-root MuiPaper-elevation MuiPaper-elevation4 MuiAppBar-root MuiAppBar-colorPrimary MuiAppBar-positionFixed mui-fixed mui-style-1td9ar0-MuiPaper-root-MuiAppBar-root"

@siriwatknp
Copy link
Member Author

After transitioning to the Experimental_CssVarsProvider I started getting the following error with Next.js due to the generated style ids clashing between client & server.

Warning: Prop `className` did not match. Server: "MuiPaper-root MuiPaper-elevation MuiPaper-elevation4 MuiAppBar-root MuiAppBar-colorPrimary MuiAppBar-positionFixed mui-fixed mui-style-16wruoc-MuiPaper-root-MuiAppBar-root" Client: "MuiPaper-root MuiPaper-elevation MuiPaper-elevation4 MuiAppBar-root MuiAppBar-colorPrimary MuiAppBar-positionFixed mui-fixed mui-style-1td9ar0-MuiPaper-root-MuiAppBar-root"

Can you open a new issue with reproduction? You can tag me, I will take a look.

@siriwatknp siriwatknp changed the title [RFC] CSS Variables Support [RFC] CSS theme variables support Oct 26, 2022
@siriwatknp
Copy link
Member Author

It is live in the documentation as experimental APIs: https://mui.com/material-ui/experimental-api/css-theme-variables/overview/, so I'm closing this RFC!

@TrySpace
Copy link
Contributor

Has anyone got this working with next.js 14 app router yet, without any content flashing?

I've been racking my brain over this trying all kinds of different combinations of the mui instructions for nextjs and the experimental CSS theme variables method.

I either get a dark theme loading correctly without flashing, but then when I try the light theme it will load correctly after 1 refresh of the page, but then on the second refresh, it flashed the dark theme shortly, AFTER initially loading correctly and then go back to the correct one. And vice-versa.

I've tried reading cookies with nextjs on the server to correctly SSR the layout, which can serve the correct themed components (after the first load has set the cookie), which seems to be a nice solution, but its not perfect when the user blocks cookies.

Is there something about the experimental CSS theme vars method that does this, or is it possibly nextjs that messes this up?

I've also tried using next-themes which seem to work nice without @mui, but as soon as I try to integrate it with the either the default mui Themeprovider or the CSS method, the above described behavior occurs again.

Any existing examples or sandboxes using either the next-themes with nextjs app router and Mui? I can't find any...

@siriwatknp
Copy link
Member Author

@TrySpace Can you provide a repository that I can take a look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion new feature New feature or request package: system Specific to @mui/system RFC Request For Comments
Projects
None yet
Development

No branches or pull requests

7 participants