Use MUI CSS theme variables to prevent dark-mode SSR flickering in Next.js App
May 06, 2023
/
8 min read
--- views
•
--- comments
Introduction
When building modern web applications, dark mode has become a popular feature that many users appreciate. However, implementing dark mode in a Next.js app with Material UI (MUI) can sometimes lead to flickering issues during Server-Side Rendering (SSR). In this blog post, we'll explore how to use MUI CSS theme variables to prevent dark-mode SSR flickering in your Next.js application.
Here is a demo of what we are going to build: https://nextjs-mui-dark-mode.vercel.app/.
Here is the link to the repository of the final project: https://github.com/arlenxuzj/nextjs-mui-dark-mode.
MUI CSS Theme Variables
CSS variables, or custom properties, are a powerful feature of CSS that allows developers to define reusable values for styling properties. By defining a value once and using it in multiple places throughout a stylesheet, developers can create consistent styling across an application, as well as make global changes to styling elements._action_1n6lm_13
MUI CSS theme variables is an experimental feature that allows developers to define custom properties and use them in MUI components. One of the advantages of using MUI CSS theme variables is that they can be used to define a dark mode theme for your application and prevent flickering issues during SSR.
Implementing Dark Mode with Next.js and MUI
Download the Starter Code
To get started, download the starter code from MUI GitHub repository:
material-ui/examples/material-next-ts at master · mui/material-ui
MUI Core: Ready-to-use foundational React components, free forever. It includes Material UI, which implements Google's Material Design. - material-ui/examples/material-next-ts at master · mui/m…
github.com
You can download this code by executing the following command in your terminal:
curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/material-next-ts
cd material-next-ts
Install it and run:
npm install
npm run dev
Add Custom Theme
Then in the project, replace the contents of src/theme.ts
with the following code:
src/theme.ts
import { purple } from '@mui/material/colors';
import { experimental_extendTheme as extendTheme } from '@mui/material/styles';
const theme = extendTheme({
colorSchemes: {
light: {
// palette for light mode
palette: {
primary: {
main: purple[700]
}
}
},
dark: {
// palette for dark mode
palette: {
primary: {
main: purple[200]
}
}
}
}
});
export default theme;
experimental_extendTheme
is an API that allows you to extend the default MUI theme with your own customizations. In this case, we are extending the default MUI theme with a custom color scheme for light and dark mode.
Add CSS Variables Provider
Next, replace the contents of pages/_app.tsx
with the following code:
pages/_app.tsx
import { CacheProvider, EmotionCache } from '@emotion/react';
import CssBaseline from '@mui/material/CssBaseline';
import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';
import type {} from '@mui/material/themeCssVarsAugmentation';
import { AppProps } from 'next/app';
import Head from 'next/head';
import createEmotionCache from '../src/createEmotionCache';
import theme from '../src/theme';
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
export interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
}
export default function MyApp(props: MyAppProps) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
return (
<CacheProvider value={emotionCache}>
<Head>
<meta name='viewport' content='initial-scale=1, width=device-width' />
</Head>
<CssVarsProvider theme={theme} defaultMode='system'>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<Component {...pageProps} />
</CssVarsProvider>
</CacheProvider>
);
}
The theme we created earlier is passed to the Experimental_CssVarsProvider
component as a prop. The defaultMode
prop is set to system
, which means that the theme will be set to dark mode if the user's operating system is set to dark mode.
Based on the MUI document, the theme variables type is not enabled by default. You need to import the module augmentation to enable the typings by import type {} from '@mui/material/themeCssVarsAugmentation';
, which is in line 4 of the code above. The import can be in any file that is included in your tsconfig.json
file.
Server-Side Rendering
Now, update the MyDocument
component in pages/_document.tsx
to the following:
pages/_document.tsx
// ...
import { getInitColorSchemeScript } from '@mui/material/styles';
export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
return (
<Html lang='en'>
<Head>
<link rel='shortcut icon' href='/favicon.ico' />
<meta name='emotion-insertion-point' content='' />
{emotionStyleTags}
</Head>
<body>
{getInitColorSchemeScript({
defaultMode: 'system'
})}
<Main />
<NextScript />
</body>
</Html>
);
}
// ...
The getInitColorSchemeScript
function is used to generate a script that will be injected into the HTML document.
Placing this function before the Main
component can prevent the dark-mode SSR flickering during the hydration phase.
Add Example Code
Finally, replace the contents of pages/index.tsx
with the following code:
pages/index.tsx
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import {
Box,
Button,
MenuItem,
Select,
Stack,
Typography,
useColorScheme
} from '@mui/material';
import { useEffect, useState } from 'react';
export default function Home() {
const { mode, setMode, systemMode } = useColorScheme();
const [currentMode, setCurrentMode] = useState<
'light' | 'dark' | 'system' | undefined
>(undefined);
const [resolvedMode, setResolvedMode] = useState<string | undefined>(
undefined
);
useEffect(() => {
setCurrentMode(mode);
setResolvedMode(systemMode || mode);
}, [mode]);
return (
<Box
component='main'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
alignItem: 'center',
p: 3
}}
>
<Box
sx={{
display: 'flex',
gap: 4
}}
>
<svg
width={200}
height={200}
fill='var(--mui-palette-text-primary)'
viewBox='0 0 128 128'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M30.2 45.9h24.1v1.9H32.4v14.4H53v1.9H32.4v15.8h22.2v1.9H30.2V45.9zm26.3 0h2.6l11.4 15.8L82 45.9l15.8-20-26 37.5 13.4 18.4h-2.7L70.4 65 58.2 81.8h-2.6l13.5-18.4-12.6-17.5zm29.7 1.9v-1.9h27.5v1.9H101v34h-2.2v-34H86.2zM0 45.9h2.7l38.2 56.8-15.8-20.9L2.3 48.6l-.1 33.2H0zm113.5 33.4c.5 0 .8-.3.8-.8s-.3-.8-.8-.8-.8.3-.8.8.4.8.8.8zm2.2-2.1c0 1.3 1 2.2 2.4 2.2 1.5 0 2.4-.9 2.4-2.5v-5.5h-1.2v5.5c0 .9-.4 1.3-1.2 1.3-.7 0-1.2-.4-1.2-1.1h-1.2zm6.3-.1c.1 1.4 1.2 2.3 3 2.3s3-.9 3-2.4c0-1.2-.7-1.8-2.2-2.2l-.9-.2c-1-.2-1.4-.6-1.4-1.1 0-.7.6-1.2 1.6-1.2.9 0 1.5.4 1.6 1.2h1.2c-.1-1.3-1.2-2.2-2.8-2.2-1.7 0-2.8.9-2.8 2.3 0 1.1.6 1.8 2 2.1l1 .2c1 .2 1.5.6 1.5 1.2 0 .7-.7 1.2-1.7 1.2s-1.8-.5-1.9-1.2H122z'></path>
</svg>
<Typography
variant='h1'
fontWeight='400'
sx={{
alignSelf: 'center'
}}
>
+
</Typography>
<svg
width={120}
height={120}
viewBox='0 0 128 128'
style={{
alignSelf: 'center'
}}
>
<path
fill='#1FA6CA'
d='M.2 68.6V13.4L48 41v18.4L16.1 41v36.8L.2 68.6z'
></path>
<path
fill='#1C7FB6'
d='M48 41l47.9-27.6v55.3L64 87l-16-9.2 32-18.4V41L48 59.4V41z'
></path>
<path fill='#1FA6CA' d='M48 77.8v18.4l32 18.4V96.2L48 77.8z'></path>
<path
fill='#1C7FB6'
d='M80 114.6L127.8 87V50.2l-16 9.2v18.4L80 96.2v18.4zM111.9 41V22.6l16-9.2v18.4l-16 9.2z'
></path>
</svg>
</Box>
<Stack spacing={2}>
<Typography variant='h4'>
Persisted {currentMode}
{currentMode === 'system' && ` (${resolvedMode})`} mode
</Typography>
<Select
value={currentMode || ''}
fullWidth
onChange={e => {
setMode(e.target.value as 'light' | 'dark' | 'system');
}}
>
<MenuItem value='system'>System</MenuItem>
<MenuItem value='light'>Light</MenuItem>
<MenuItem value='dark'>Dark</MenuItem>
</Select>
<Button
variant='contained'
endIcon={
resolvedMode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />
}
sx={theme => ({
textTransform: 'none',
background: `linear-gradient(to top right, #0a7ffe 0%, ${theme.vars.palette.primary.main} 100%)`
})}
onClick={() => {
setMode(resolvedMode === 'dark' ? 'light' : 'dark');
}}
>
Toggle {resolvedMode === 'dark' ? 'Light' : 'Dark'} Mode
</Button>
</Stack>
</Box>
);
}
The useColorScheme
hook in line 15 is used to read and update the current mode. It returns an object, and three properties are used:
mode
: The current mode. Can be'light'
,'dark'
or'system'
. Note: on the server,mode
is alwaysundefined
.setMode
: A function to set the current mode. Accepts'light'
,'dark'
or'system'
.systemMode
: The current system mode. Can be'light'
or'dark'
. If themode
is not'system'
, this will beundefined
.
If you want to use theme variables, you can use the theme.vars
which is shown in line 104. It is an object that refers to the CSS theme variables.
Test Dark Mode Flickering
- Toggle dark mode in your application.
- Open DevTools and set the CPU throttling to the lowest value (don't close the DevTools).
- Refresh the page. You should see all the components in dark mode at first glance.
Also, you can see some contents will change after the page is loaded. This is because the mode
is always undefined
on the server. So some contents of page must be updated on the client.
To avoid this, you can add following code to pages/index.tsx
:
pages/index.tsx
// ...
export default function Home() {
const { mode, setMode, systemMode } = useColorScheme();
const [currentMode, setCurrentMode] = useState<
'light' | 'dark' | 'system' | undefined
>(undefined);
const [resolvedMode, setResolvedMode] = useState<string | undefined>(
undefined
);
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
useEffect(() => {
setCurrentMode(mode);
setResolvedMode(systemMode || mode);
}, [mode]);
+ if (!mounted) {
+ return null;
+ }
return (
<Box
component='main'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
alignItem: 'center',
p: 3
}}
>
{/* ... */}
</Box>
);
}
However, it will make your website SEO unfriendly. If you are building a blog website and only have a mode icon in the header, it is not a problem.