Flicker‑Free Dark Mode in Material UI with CSS‑Variables Theming
Move Material UI theming into real CSS custom properties to eliminate the dark‑mode flash on load. This blog shows the v6 extendTheme + cssVariables setup, a header toggle with system mode, and the exact head‑script placement that makes it work.
18 Sept 2025, 02:58 UTC

The flash you can’t ignore
Every time a user lands on a page that respects prefers-color-scheme, the browser paints the default (usually light) theme before React hydrates and swaps to dark. That brief white flash breaks trust, especially on content‑heavy dashboards. Material UI’s CSS‑variables mode moves the theme values into real CSS custom properties so the correct colors exist before any JavaScript runs.
How CSS‑variables theming differs from the classic ThemeProvider
With the traditional ThemeProvider the theme lives only in React context. Components read theme.palette.primary.main at render time, so a theme toggle forces a full re‑render of the subtree. In CSS‑variables mode (CssVarsProvider in v5, extendTheme({ cssVariables: true }) in v6+), MUI emits --mui-palette-*, --mui-spacing-*, and typography tokens on :root (or a scoped selector). The browser can switch the entire color scheme by toggling a single attribute—no React re‑render required.
- No flash: An inline script in
<head>reads the persisted preference (or the OS setting) and setsdata-mui-color-schemebefore the body is painted. - First‑class “system” mode:
useColorSchemereturns'light' | 'dark' | 'system'and persists the choice tolocalStorageautomatically. - Token access outside React: Third‑party CSS, vanilla JS, or a design‑token pipeline can read
var(--mui-palette-primary-main)directly.
Worked example: a docs‑style header with light / dark / system toggle
Assume @mui/material@6.1.0 (the API is stable from v6 onward). Install the peer dependencies first:
# Run in your project root (requires write access to node_modules)
npm install @mui/material @mui/system @emotion/react @emotion/styled
Create the theme file src/theme.ts:
import { extendTheme, type Theme } from '@mui/material/styles';
const light = {
palette: {
primary: { main: '#1976d2' },
background: { default: '#fff', paper: '#f5f5f5' },
},
};
const dark = {
palette: {
primary: { main: '#90caf9' },
background: { default: '#121212', paper: '#1e1e1e' },
},
};
export const theme = extendTheme({
colorSchemes: { light, dark },
cssVariables: true, // enables CSS‑variables mode
// optional: limit the selector to a wrapper instead of :root
// cssVariables: { colorSchemeSelector: 'class' },
} as const) satisfies Theme;
Add the initialization script to public/index.html (or your SSR entry template) before any stylesheet link:
<head>
<script dangerouslySetInnerHTML={{
__html: `(() => {
try {
var saved = localStorage.getItem('mui-color-scheme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var scheme = saved === 'system' ? (prefersDark ? 'dark' : 'light') : (saved || 'light');
document.documentElement.setAttribute('data-mui-color-scheme', scheme);
} catch (e) { /* ignore */ }
})();`
}} />
</head>
Wrap the app in CssVarsProvider (v5) or the new provider (v6) and add a header toggle:
// src/App.tsx
import { CssVarsProvider, useColorScheme, Button, AppBar, Toolbar, Typography, Menu, MenuItem } from '@mui/material';
import { theme } from './theme';
import React, { useState } from 'react';
function Header() {
const { mode, setMode } = useColorScheme();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleClick = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
const handleClose = () => { setAnchorEl(null); };
const handleSelect = (newMode: 'light' | 'dark' | 'system') => {
setMode(newMode);
handleClose();
};
return (
<AppBar position="sticky">
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }}>My Docs</Typography>
<Button onClick={handleClick} aria-label="color scheme">
{mode === 'system' ? '⚙' : mode === 'dark' ? '🌙' : '☀'}
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleClose}>
<MenuItem onClick={() => handleSelect('light')}>Light</MenuItem>
<MenuItem onClick={() => handleSelect('dark')}>Dark</MenuItem>
<MenuItem onClick={() => handleSelect('system')}>System</MenuItem>
</Menu>
</Toolbar>
</AppBar>
);
}
export default function App() {
return (
<CssVarsProvider theme={theme}>
<Header />
<main sx={{ p: 3 }}>Content goes here</main>
</CssVarsProvider>
);
}
Reload the page in a dark‑mode browser profile. You should see the dark background instantly—no white flash. Open DevTools → Elements → :root and verify that --mui-palette-primary-main and --mui-palette-background-default change when you pick a different mode from the menu.
Trade‑offs and limitations
- Dynamic per‑render palette changes (e.g., a component that computes a color based on props) don’t map cleanly to static custom properties. You’ll need to fall back to the JS theme context for those cases.
- Third‑party libraries that read
theme.palettefrom React context will still see the old values unless they are updated to consume CSS variables. - SSR placement matters. The init script must be in
<head>and execute before any stylesheet that references the variables. Placing it after the body or inside a bundled script re‑introduces the flash. - Version pinning. In v5 the entry point is
CssVarsProviderwithexperimental_extendTheme. In v6+ it’sextendTheme({ cssVariables: true })and the provider is stillCssVarsProvider. Mixing the two APIs causes runtime errors.
Quick verification checklist
- Confirm
package.jsonlists@mui/material@^6.0.0(or the v5 equivalent). - Run the dev server, open the page in a dark‑mode OS setting, and watch for any light‑mode flash on first paint.
- Inspect
document.documentElement.stylein the console:getComputedStyle(document.documentElement).getPropertyValue('--mui-palette-primary-main')should return the dark primary color. - Switch the toggle to “System”, reload, and verify
localStorage.getItem('mui-color-scheme')equals'system'. - For SSR builds, view the page source and ensure the inline script appears before any
<link rel="stylesheet">.
Bottom line
If your product cares about a polished first paint—especially on content‑heavy or public‑facing pages—adopting MUI’s CSS‑variables theming is a low‑effort win. The migration is incremental, the API is stable from v6, and the only hard requirement is placing the init script correctly. For teams that still need per‑render theme calculations, keep a hybrid approach: use CSS variables for the global palette and fall back to the JS context where dynamic values are unavoidable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.