Dark mode & light mode in next. Js 14 app router with material-ui without ui flickering
Peter Kelvin Torver
Posted on November 7, 2024
Introduction
Dark mode has become a popular feature in modern web applications, enhancing user experience and adding a sleek, visually appealing touch. With Next.js 14 and Material-UI (MUI), you can seamlessly toggle between dark and light modes, giving users the flexibility to choose based on their preferences. One of the challenges, however, is ensuring that the UI doesn’t flicker during theme transitions due to Nextjs server side rendering.
In this article, we’ll implement a flicker-free dark mode and light mode in Next.js 14 using the new App Router, Material-UI, and MUI CSS variables to manage theme states smoothly.
Nowadays,I spend more time creating content on my personal platform - Codesermon - Do check it out!
Checkout my new platform - Codesermon
Prerequisites
Before we start, ensure you have:
- Node.js and npm installed.
- Next.js 14 project setup.
- Material-UI (MUI) for theme management.
Follow the steps below to install the necessary packages
Open your terminal and bootstrap a new nextjs project.
1.First, create a nextjs app
npx create-next-app@14.2.16 nextjs-mui-theme
Open the project in your coding environment
2.Install the MUI dependencies:
npm install @mui/material @emotion/react @emotion/styled next-themes
3.Install MUI library for nextjs
npm install @mui/material-nextjs/v15-appRouter
4.Install Roboto fonts
npm install @fontsource/roboto
5.Copy & paste the code below in src/app/layout.tsx
and
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
6.Install MUI icons library
npm install @mui/icons-material
7.Copy the code below and replace the code in the file src/app/globals.css
@import url(https://fonts.googleapis.com/icon?family=Material+Icons);
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
8.Install the package for generating dummy data
npm i @faker-js/faker
If you did the above, your package.json
should look like this
{
"name": "nextjs-mui-theme",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@faker-js/faker": "^9.2.0",
"@fontsource/roboto": "^5.1.0",
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/material-nextjs": "^6.1.6",
"next": "14.2.16",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.16",
"typescript": "^5"
}
}
9.Create two directories in src directory
- context
- components
-
Open context driectory and create a file
AppThemeContext.tsx
and paste the following code.To handle theme switching, we’ll create a custom
ThemeProvider
component. This component will wrap our application, detect the theme preference (dark or light), and apply it without causing UI flickering.
'use client'
import { createTheme, CssBaseline, responsiveFontSizes, ThemeProvider } from "@mui/material";
import { createContext, useContext, useMemo } from "react";
import type {} from '@mui/material/themeCssVarsAugmentation';
const AppThemeContext = createContext(null)
const AppThemeProvider = (props: any) => {
const theme = useMemo(() => {
return responsiveFontSizes(createTheme({
cssVariables: {
colorSchemeSelector: "class",
disableCssColorScheme: true
},
palette: {
primary: {
main: `rgb(10, 18, 42)`,
contrastText: 'rgb(255, 255, 255)',
},
secondary: {
main: `rgb(27, 59, 111)`,
contrastText: 'rgb(255, 255, 255)',
}
},
colorSchemes: {
light: {
palette: {
primary: {
main: `rgb(10, 18, 42)`,
},
secondary: {
main: `rgb(27, 59, 111)`,
}
}
},
dark: {
palette: {
primary: {
main: `rgb(10, 18, 42)`,
},
secondary: {
main: `rgb(27, 59, 111)`,
}
}
}
}
}))
}, [])
return <AppThemeContext.Provider value={null}>
<ThemeProvider theme={theme} disableTransitionOnChange>
<CssBaseline enableColorScheme />
{props.children}
</ThemeProvider>
</AppThemeContext.Provider>
}
export const useAppThemeContext = () => useContext(AppThemeContext)
export default AppThemeProvider
Explanation
We created a
theme
object to be used throughout our applicationCode line 14 - 17, specifies the cssVariables config which enables css variables in MUI
We defined the palette for our app
We defined the colorSchemes which specifies our light and dark mode themes. This can be modified based on your needs.
ThemeProvider: This provider applies the selected MUI theme (either dark or light) to the entire app. You
Notice that we passed theme object to the ThemeProvider component
We called CssBaseline from mui with the prop enableColorScheme which will enable color schemes based on user selection or system default and reset the proper styles for our app to display well on different devices.
We exported our custom AppThemeProvider which will wrap the whole of our application.
11.Open src/app/layout.tsx
file and replace the following code
import type { Metadata } from "next";
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import "./globals.css";
import InitColorSchemeScript from "@mui/material/InitColorSchemeScript"
import AppThemeProvider from "@/context/AppThemeContext";
import {MainNavbar} from "@/components"
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<AppRouterCacheProvider options={{enableCssLayer: false}}>
<AppThemeProvider>
<InitColorSchemeScript attribute="class" />
<MainNavbar />
{children}
</AppThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
Explanation
We called AppRouterCacheProvider from mui nextjs library and wrap our children inside the body element.
The component also accept an option of enableCssLayer: falseWe imported our custom
AppThemeProvider
to wrap our whole app to apply our MUI styles.We called
<InitiColorSchemeScript attribute="class" />
to initialize our default mode on the server without a flicker.
Since cssVariables is enabled and initialized, this can be applied on the on server without a flicker.
Remember the
attribute="class"
must be the same value with the custom theme cssVariables config inAppThemeContext.tsx
filecssVariables: { colorSchemeSelector: "class" }
- Since we want our navbar to persist across pages, we called
<MainNavbar />
and then{children}
which will be any component passed to the layout in this case thepage.tsx
component.
12.Open src/components
directory and create 4 files namely;
- index.tsx
- MainNavbar.tsx
- Homepage.tsx
- MediaCard.tsx
Open
src/components/index.tsx
and paste the code below;
export { default as MainNavbar } from "./MainNavbar"
export { default as Homepage } from "./Homepage"
Open
src/components/MainNavbar.tsx
and paste the code below;
'use client'
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Menu from '@mui/material/Menu';
import MenuIcon from '@mui/icons-material/Menu';
import Container from '@mui/material/Container';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import MenuItem from '@mui/material/MenuItem';
import AdbIcon from '@mui/icons-material/Adb';
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
import { useColorScheme } from '@mui/material/styles';
const pages = ['Products', 'Pricing', 'Blog'];
const settings = ['Profile', 'Account', 'Dashboard', 'Logout'];
function MainNavbar() {
const { mode, systemMode, setMode } = useColorScheme();
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(null);
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(null);
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget);
};
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget);
};
const handleCloseNavMenu = () => {
setAnchorElNav(null);
};
const handleCloseUserMenu = () => {
setAnchorElUser(null);
};
const toggleDarkTheme = React.useCallback(() => {
if(mode){
const currMode = mode === 'dark' ? 'light' : 'dark';
setMode(currMode);
}
},[mode, systemMode])
return (
<AppBar position="fixed">
<Container maxWidth="xl">
<Toolbar disableGutters>
<AdbIcon sx={{ display: { xs: 'none', md: 'flex' }, mr: 1 }} />
<Typography
variant="h6"
noWrap
component="a"
href="#app-bar-with-responsive-menu"
sx={{
mr: 2,
display: { xs: 'none', md: 'flex' },
fontFamily: 'monospace',
fontWeight: 700,
letterSpacing: '.3rem',
color: 'inherit',
textDecoration: 'none',
}}
>
LOGO
</Typography>
<Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleOpenNavMenu}
color="inherit"
>
<MenuIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorElNav}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
open={Boolean(anchorElNav)}
onClose={handleCloseNavMenu}
sx={{ display: { xs: 'block', md: 'none' } }}
>
{pages.map((page) => (
<MenuItem key={page} onClick={handleCloseNavMenu}>
<Typography sx={{ textAlign: 'center' }}>{page}</Typography>
</MenuItem>
))}
</Menu>
</Box>
<AdbIcon sx={{ display: { xs: 'flex', md: 'none' }, mr: 1 }} />
<Typography
variant="h5"
noWrap
component="a"
href="#app-bar-with-responsive-menu"
sx={{
mr: 2,
display: { xs: 'flex', md: 'none' },
flexGrow: 1,
fontFamily: 'monospace',
fontWeight: 700,
letterSpacing: '.3rem',
color: 'inherit',
textDecoration: 'none',
}}
>
LOGO
</Typography>
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
{pages.map((page) => (
<Button
key={page}
onClick={handleCloseNavMenu}
sx={{ my: 2, color: 'white', display: 'block' }}
>
{page}
</Button>
))}
</Box>
<Box sx={{ flexGrow: 0, pr: 2 }}>
<Tooltip title="Toggle Theme">
<IconButton size='large' color='inherit' onClick={() => toggleDarkTheme()} sx={{ p: 0 }}>
{
mode === "dark" ? <DarkModeOutlinedIcon /> : <LightModeOutlinedIcon />
}
</IconButton>
</Tooltip>
</Box>
<Box sx={{ flexGrow: 0 }}>
<Tooltip title="Open settings">
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
<Avatar alt="Remy Sharp" src="/static/images/avatar/2.jpg" />
</IconButton>
</Tooltip>
<Menu
sx={{ mt: '45px' }}
id="menu-appbar"
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorElUser)}
onClose={handleCloseUserMenu}
>
{settings.map((setting) => (
<MenuItem key={setting} onClick={handleCloseUserMenu}>
<Typography sx={{ textAlign: 'center' }}>{setting}</Typography>
</MenuItem>
))}
</Menu>
</Box>
</Toolbar>
</Container>
</AppBar>
);
}
export default MainNavbar;
Explanation
To implement theme toggle, we import
useColorScheme
on line 18 from @mui/material/stylesWe called it on line 25 to get the
mode
andsystemMode
We created a function toggleDarkTheme on line 44 to 49 to check if mode is set theme toggle the opposite then we called it on line 142
Open
src/components/Homepage.tsx
and paste the code below;
'use client'
import React from 'react'
import { faker } from '@faker-js/faker';
import { Box, Container, Grid2 } from '@mui/material';
import MediaCard from './MediaCard';
const Homepage = () => {
const data = Array.from({ length: 24 }).map(() => ({
id: faker.string.uuid(),
title: faker.lorem.lines(1),
content: faker.lorem.sentences(4),
src: faker.image.urlPicsumPhotos()
}))
return (
<Box sx={[(theme) => ({
backgroundColor: theme.vars.palette.grey[300],
...theme.applyStyles("dark", {
backgroundColor: theme.vars.palette.grey[900]
})
})]}
>
<Container maxWidth="xl" sx={{mt: 9, pt: 2}}>
<Grid2 container spacing={2}>
{data.map(item => (
<Grid2 key={item.id} size={{lg: 3, md: 4, sm: 12, xs: 12}}>
<MediaCard item={item} />
</Grid2>
))}
</Grid2>
</Container>
</Box>
)
}
export default Homepage
Open
src/components/MediaCard.tsx
and paste the code below;
import * as React from 'react';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
type Item = { id: string, title: string, src: string, content: string}
export default function MediaCard({item}: { item: Item}) {
return (
<Card sx={{ height: "100%"}}>
<CardMedia
sx={{ height: 140 }}
image={item.src}
title={item.title}
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{item.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.content}
</Typography>
</CardContent>
<CardActions sx={{justifyContent: "space-between"}}>
<Button color='inherit' variant='outlined' size="small">Share</Button>
<Button color='inherit' variant='outlined' size="small">Learn More</Button>
</CardActions>
</Card>
);
}
13.Open src/app/page.tsx
file and paste the following code
import { Homepage } from "@/components";
export default function Home() {
return ( <Homepage />);
}
14.Open your terminal within vs code or your chosen environment and run the command the blow to start the nextjs project.
npm run dev
The above command should start a nextjs server listening on port 3000 like;
http://localhost:3000
Go ahead and open it in the browser and navigate to the above url and you should see your project running!
With the above implementation you should be able to toggle between light and dark mode without UI flickering.
Minimize Flickering
Using ThemeProvider
’s defaultMode="system"
and InitColorSchemeScript
's defaultMode="system"
ensures that the app initially matches the user’s system theme on load. This approach minimizes the risk of a flicker effect and provides a smoother experience. If the above are not provided, the system's default mode will picked by default.
Conclusion
You’ve successfully implemented dark mode and light mode in your Next.js 14 app using MUI without flickering. Here are some additional tips for enhancing your implementation:
-
System Theme Detection: Mui ThemeProvider’
defaultTheme="system"
applies the system’s theme by default, ensuring that users start with their preferred theme. - Server-Side Rendering: If using SSR, this implementation handles theme detection automatically to avoid flickering.
-
Customization: You can further customize
light
anddark
inAppThemeContext.tsx
for brand-specific colors, fonts, and more!
By following these steps, you can provide a smooth, flicker-free dark mode experience in your Next.js app, enhancing both usability and visual appeal.
Get The Source Code
For the full implementation, you can find the source code here Nextjs Mui Theme Code
Youtube Video
A video is worth a thousand words!
Posted on November 7, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 7, 2024