Persistent Light and Dark Mode in Material UI Without a Flash of Wrong Colors
A synchronous head script plus a memoized ThemeProvider setup stops the flash of wrong colors when reloading a Material UI app in dark or light mode.
06 Jul 2025, 11:09 UTC

Goal: Render the Saved Theme Before First Paint
When a user reloads a Material UI application, the browser often paints the default light background before React hydrates and applies the saved dark theme. The result is a brief flash of incorrect colors. The solution is a two-part pattern: a tiny synchronous script in the HTML head that resolves the user's preference before the JavaScript bundle loads, and a React theme controller that reads that value on the very first render.
Prerequisites
- A React application with
@mui/materialinstalled (versions 5 through 7). - Emotion as the style engine (the MUI default) or styled-components.
- Edit access to the HTML document head, such as
public/index.htmlor the Next.js_documentfile. - No server-side rendering (SSR), or willingness to add
suppressHydrationWarningwhere required.
Implementation Steps
1. Define light and dark themes
Create two theme objects with createTheme. Memoize them in your component so the object references stay stable and child components do not re-render unnecessarily.
import { createTheme } from '@mui/material';
export const lightTheme = createTheme({
palette: { mode: 'light' },
});
export const darkTheme = createTheme({
palette: { mode: 'dark' },
});
2. Add a blocking script to the HTML head
Place the following script before your bundled JavaScript. It must be inline and synchronous. Adding defer or async lets the browser paint first, which reintroduces the flash.
<script>
(function () {
try {
var stored = localStorage.getItem('themeMode');
var mode = stored
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', mode);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
This runs before React mounts. It reads localStorage, falls back to the operating-system preference via matchMedia, and writes the result to <html data-theme="dark">. The try/catch guards against privacy modes that block storage access.
3. Read the mode in React and wrap the tree
For client-side rendered apps, initialize state from the attribute the script already set. Reading localStorage directly during render causes hydration mismatches in SSR apps; reading the document attribute avoids that.
import { useMemo, useState, useCallback, useEffect } from 'react';
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material';
import { lightTheme, darkTheme } from './themes';
function useThemeMode() {
const [mode, setMode] = useState(() => {
if (typeof document !== 'undefined') {
return document.documentElement.getAttribute('data-theme') || 'light';
}
return 'light';
});
const systemDark = useMediaQuery('(prefers-color-scheme: dark)');
const hasStored = typeof localStorage !== 'undefined' && localStorage.getItem('themeMode');
// Track OS changes only when the user has not made an explicit choice.
useEffect(() => {
if (!hasStored) {
const next = systemDark ? 'dark' : 'light';
setMode(next);
document.documentElement.setAttribute('data-theme', next);
}
}, [systemDark, hasStored]);
const toggle = useCallback(() => {
const next = mode === 'dark' ? 'light' : 'dark';
localStorage.setItem('themeMode', next);
document.documentElement.setAttribute('data-theme', next);
setMode(next);
}, [mode]);
return { mode, toggle };
}
export function AppTheme({ children }) {
const { mode, toggle } = useThemeMode();
const theme = useMemo(
() => (mode === 'dark' ? darkTheme : lightTheme),
[mode]
);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
<button onClick={toggle}>
Switch to {mode === 'dark' ? 'light' : 'dark'} mode
</button>
</ThemeProvider>
);
}
CssBaseline normalizes base styles and applies the theme's background and text colors to the page. It does not persist state; persistence comes from the script and the toggle handler above.
Expected Checks After Implementation
- Reload persistence. Toggle to dark mode, reload the page, and confirm the app renders dark immediately. Inspect
localStorageand verify it containsthemeModewith the valuedark. - System preference fallback. Clear
localStorage, open browser DevTools, emulateprefers-color-scheme: darkin the rendering panel, and reload. The app should render in dark mode without a manual toggle. - Instant toggle. Click the switch and confirm every MUI component updates without a page reload.
- Clean console. Load the app and check the browser console for hydration warnings or theme-related errors during the initial render.
Recovery Options When the Flash Persists
If a flash of wrong colors remains, work through these checks in order:
- Script timing. Confirm the inline script appears in the served HTML before the
<script>tag that loads your bundle. A script placed after the bundle, or markeddefer, cannot block first paint. - Attribute and palette alignment. Verify that the
data-themeattribute on<html>matches thepalette.modeof the theme passed toThemeProvider. A mismatch means React is rendering one mode while the document attribute says another. - Missing CssBaseline. Ensure
CssBaselineis rendered insideThemeProvider. Without it, the page background may stay at the browser default even when components are themed. - Next.js hydration mismatch. In Next.js, add
suppressHydrationWarningto the<html>tag and reuse the same blocking script in_document.tsx. This tells React to ignore the attribute difference on the server-rendered root element.
Limitations and Alternatives
The blocking script adds a negligible synchronous cost to first paint, but it is a hard requirement for flash prevention. In SSR frameworks, reading localStorage during the React render phase will produce hydration errors; rely on the pre-hydration script or a mounted guard instead. Material UI v6 and later also offer a CSS-variables theme with built-in color-scheme helpers, which can reduce JavaScript overhead. The ThemeProvider approach described here remains compatible across v5 through v7 and does not lock the application to a single theming strategy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.