Next.jsとMUIでダークモードの実装をしたのでその時のメモです。
Next.jsのプロジェクトを作成
まずはNext.jsのプロジェクトを作成します。私が使用したNode.jsとNpmのバージョンは以下です。
- Node.js 22.11.0
- Npm 10.9.0
プロジェクトを作成するディレクトリに移動し下記コマンドでNext.jsのプロジェクトを作成します。
npx create-next-app
質問は下記を選択しました。
What is your project named? ... my-app
√ Would you like to use TypeScript? ... Yes
√ Which linter would you like to use? » Biome
√ Would you like to use Tailwind CSS? ... No
√ Would you like your code inside a `src/` directory? ... Yes
√ Would you like to use App Router? (recommended) ... Yes
√ Would you like to use Turbopack? (recommended) ... Yes
√ Would you like to customize the import alias (`@/*` by default)? ... Yes
√ What import alias would you like configured? ... @/*
プロジェクトを作成したらディレクトリを移動してVSCodeを開きます
cd my-app
code .
MUIのインストール
VSCodeでターミナルを開き、MUIを使用するために必要なものをインストールします。Next.js用に@mui/material-nextjsがあるのでそちらもインストールします。アイコンはライト/ダークモードの切り替えをするトグル部分に使用するため、インストールをしています。
npm i @mui/material @mui/material-nextjs @mui/icons-material @emotion/styled @emotion/cache
テーマの作成
MUIのインストールが終わったら、次にthemeファイルを作成します。src配下にthemeディレクトリを作成し、そこにtheme.tsファイルを作成します。MUIではデフォルトのフォントがRobotoのようですが、Next.jsのフォント最適化を適用させるには以下のように指定が必要なようです。またトグルでモードを変えるためにcolorSchemeSelectorを指定し、class付与でモードを変えることとしますので値をclassにします。
'use client';
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
typography: {
fontFamily: 'var(--font-roboto)',
},
colorSchemes: { light: true, dark: true },
cssVariables: {
colorSchemeSelector: "class",
},
});
export default theme;
作成したtheme.tsをlayout.tsxで読み込みます。下記のようになります。
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import theme from "@/theme/theme";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-roboto",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja" className={roboto.variable}>
<body>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<main>{children}</main>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
モードを変更するトグルの作成
ライト/ダークを切り替えるトグルを作成します。システムが選択されていた場合のアイコン表示も考慮が必要だったため、useColorSchemeからmodeだけでなく、systemModeもインポートしています。
"use client";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { useColorScheme } from "@mui/material/styles";
import { type FC, useState } from "react";
export const ThemeToggle: FC = () => {
const { mode, setMode, systemMode } = useColorScheme();
const currentTheme = mode === "system" ? systemMode : mode;
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<Button
id="mode-button"
aria-controls={open ? "mode-menu" : undefined}
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={handleClick}
variant="contained"
>
{currentTheme === "dark" ? <DarkModeIcon /> : <LightModeIcon />}
</Button>
<Menu
id="mode-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
slotProps={{
list: {
"aria-labelledby": "mode-button",
},
}}
>
<MenuItem
onClick={() => {
setMode("system");
handleClose();
}}
>
System
</MenuItem>
<MenuItem
onClick={() => {
setMode("light");
handleClose();
}}
>
Light
</MenuItem>
<MenuItem
onClick={() => {
setMode("dark");
handleClose();
}}
>
Dark
</MenuItem>
</Menu>
</>
);
};
トグルはヘッダーに設置したいため、ヘッダーコンポーネントも作成し、そこに読み込ませます。
import AppBar from "@mui/material/AppBar";
import Typography from "@mui/material/Typography";
import type { FC } from "react";
import { ThemeToggle } from "@/components/ThemeToggle";
export const Header: FC = () => {
return (
<AppBar
sx={{
p: 1,
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
position: "sticky"
}}
>
<Typography variant="h4" component="h1">
サンプルサイト
</Typography>
<ThemeToggle />
</AppBar>
);
};
作成したヘッダーコンポーネントをlayout.tsxに配置します。
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
+import { Header } from "@/components/Header";
import theme from "@/theme/theme";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-roboto",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja" className={roboto.variable}>
<body>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
+ <Header />
<main>{children}</main>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
ここまででダーク/ライトモードの切り替えができるようになりましたが、ダークモードの時に画面更新をすると、チラつきが起こっていますのでこちらを解消します。
ダークモードのチラつき修正
ダークモードのチラつきを解消にはInitColorSchemeScriptの設置が必要です。layout.tsxのThemeProviderの直下に配置します。この時、attributeにはcolorSchemeSelectorと同じものを指定することに注意してください。
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
+import InitColorSchemeScript from "@mui/material/InitColorSchemeScript";
import { ThemeProvider } from "@mui/material/styles";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import { Header } from "@/components/Header";
import theme from "@/theme/theme";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-roboto",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja" className={roboto.variable}>
<body>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
+ <InitColorSchemeScript attribute="class" />
<Header />
<main>{children}</main>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
これでダークモード時に画面をリフレッシュしてもチラつきが起きなくなったはずです。
しかし、今度はハイドレーションエラーが起きてしまっているので、こちらも解消したいと思います。
ハイドレーションエラーの修正
正直このハイドレーションエラーは仕方ないものなのかなと思っています。htmlにdarkというクラスが付与されているためです。なので私はこのハイドレーションエラーを無理やり黙らせることで回避しました。layout.tsxのhtml部分にsuppressHydrationWarningを指定しました。
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import InitColorSchemeScript from "@mui/material/InitColorSchemeScript";
import { ThemeProvider } from "@mui/material/styles";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import { Header } from "@/components/Header";
import theme from "@/theme/theme";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-roboto",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja" className={roboto.variable} suppressHydrationWarning>
<body>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<InitColorSchemeScript attribute="class" />
<Header />
<main>{children}</main>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
確かshadcn/uiでダークモードの実装も同じようにsuppressHydrationWarningが指定されていた記憶があります。もし他に良い解決策を知っている方いましたらコメントで教えていただけるとありがたいです。ひとまずここまででダークモードの実装完了です。
おまけ
AppBarがライト/ダークモードでカラーが違っているかと思います。カラーを合わせる場合はAppBarにenableColorOnDarkを付与します。
import AppBar from "@mui/material/AppBar";
import Typography from "@mui/material/Typography";
import type { FC } from "react";
import { ThemeToggle } from "@/components/ThemeToggle";
export const Header: FC = () => {
return (
<AppBar
sx={{
p: 1,
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
position: "sticky"
}}
+ enableColorOnDark
>
<Typography variant="h4" component="h1">
サンプルサイト
</Typography>
<ThemeToggle />
</AppBar>
);
};
Next.jsとMUIでダークモード実装の参考になれば幸いです。