Dark mode & light mode in next. Js 14 app router with material-ui without ui flickering

torver213

Peter Kelvin Torver

Posted on November 7, 2024

Dark mode & light mode in next. Js 14 app router with material-ui without ui flickering

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
Enter fullscreen mode Exit fullscreen mode

Open the project in your coding environment

2.Install the MUI dependencies:

npm install @mui/material @emotion/react @emotion/styled next-themes
Enter fullscreen mode Exit fullscreen mode

3.Install MUI library for nextjs

npm install @mui/material-nextjs/v15-appRouter
Enter fullscreen mode Exit fullscreen mode

4.Install Roboto fonts

npm install @fontsource/roboto
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

6.Install MUI icons library

npm install @mui/icons-material
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

8.Install the package for generating dummy data

  npm i @faker-js/faker
Enter fullscreen mode Exit fullscreen mode

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"
  }
}

Enter fullscreen mode Exit fullscreen mode

9.Create two directories in src directory

  • context
  • components
  1. 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
Enter fullscreen mode Exit fullscreen mode

Explanation

  • We created a theme object to be used throughout our application

  • Code 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>
  );
}

Enter fullscreen mode Exit fullscreen mode

Explanation

  • We called AppRouterCacheProvider from mui nextjs library and wrap our children inside the body element.
    The component also accept an option of enableCssLayer: false

  • We 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 in AppThemeContext.tsx file cssVariables: { 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 the page.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"

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Explanation

  • To implement theme toggle, we import useColorScheme on line 18 from @mui/material/styles

  • We called it on line 25 to get the mode and systemMode

  • 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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

13.Open src/app/page.tsx file and paste the following code


import { Homepage } from "@/components";

export default function Home() {
  return ( <Homepage />);
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 and dark in AppThemeContext.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!

💖 💪 🙅 🚩
torver213
Peter Kelvin Torver

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