Using MUI's CSS Variables Mode to Kill Dark-Mode Flash and Speed Up Theme Switching
MUI's cssVariables mode emits design tokens as native CSS custom properties, letting the browser apply dark mode before hydration and switch themes without re-rendering React.
23 Jan 2026, 02:37 UTC

If your MUI app flashes the light theme before dark mode kicks in on page load, the fix is usually not a loading spinner — it is moving your design tokens out of JavaScript and into native CSS custom properties. MUI's cssVariables option in createTheme does exactly that: the palette, spacing, and typography tokens are emitted as --mui-* variables on :root, so the browser can resolve the correct colors before React even hydrates.
This matters most in server-rendered apps (Next.js, Remix) and anywhere users toggle themes frequently. The behavior described here applies to MUI v6-era releases; the colorSchemes API is version-sensitive, so check your installed major before copying the config.
Why the JS-only theme causes a flash
In the classic setup, the theme is a JavaScript object. The server renders HTML without knowing the user's saved preference, the client hydrates, reads localStorage, and only then applies the dark palette. Between first paint and hydration, users see the wrong theme — the flash of incorrect theme (FOIT's uglier cousin).
CSS variables mode inverts this. The tokens live in a stylesheet the browser applies immediately, and a tiny blocking script sets the color-scheme attribute on <html> before first paint. Switching schemes then becomes an attribute change, not a React re-render.
A worked configuration
Define both schemes and enable CSS variables:
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
cssVariables: {
colorSchemeSelector: '[data-mui-color-scheme]',
},
colorSchemes: {
light: {
palette: {
primary: { main: '#1565c0' },
background: { default: '#ffffff', paper: '#f5f5f5' },
},
},
dark: {
palette: {
primary: { main: '#90caf9' },
background: { default: '#121212', paper: '#1e1e1e' },
},
},
},
});
export default function App({ children }) {
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}Then, in your document head (in Next.js App Router, in the root layout), add the initialization script so the saved scheme is applied before paint:
import InitColorSchemeScript from '@mui/material/InitColorSchemeScript';
// inside <head> or at the top of <body>:
<InitColorSchemeScript attribute="data-mui-color-scheme" />With this in place, your existing sx usage keeps working unchanged. A value like bgcolor: 'background.default' now compiles to var(--mui-palette-background-default) instead of an inlined hex, so it follows whichever scheme is active.
Verifying it actually works
Three concrete checks:
- Open DevTools, inspect
<html>, and confirm--mui-palette-*custom properties exist on:root. - Toggle the
data-mui-color-schemeattribute betweenlightanddarkand watch the palette change. In React DevTools, confirm no components re-render — the switch is pure CSS. - Set a dark preference, reload an SSR page with throttled network, and confirm the first paint is already dark.
Limits and common mistakes
Persistence is your job
MUI does not store the user's preference. You must write the chosen scheme to a cookie or localStorage and make sure InitColorSchemeScript reads from the same place. A cookie is preferable for SSR because the server can read it too.
Hard-coded overrides silently break it
Any component styled with a literal color — sx={{ color: '#333' }}, a hex in a styled() override, or a third-party MUI extension that inlines colors — will not follow the scheme. These failures are silent: nothing errors, the element just stays light in dark mode. Audit overrides for hard-coded palette values and replace them with token references like 'text.primary'.
Version sensitivity
The colorSchemes key and InitColorSchemeScript are v6-era APIs. On v5, the equivalent requires different setup; on future majors the API surface may shift. Confirm against the docs for your installed version before rolling out.
Scoping is possible but easy to get wrong
Setting colorSchemeSelector to a class (e.g. '.dark-section') lets you theme part of a page or coexist with legacy styles. The mistake is forgetting that the selector must match an ancestor of everything you want themed — a selector targeting :root and one targeting a nested class behave very differently when schemes toggle.
When to adopt it
Adopt CSS variables mode if you server-render, support dark mode, or switch themes at runtime and care about render cost. Skip the migration only if you have a large body of hard-coded overrides you cannot yet clean up — in that case, fix the overrides first, or the switch will expose every one of them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.