0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js App RouterとMUIでダークモードの実装

Posted at

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にします。

src/theme/theme.ts
'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で読み込みます。下記のようになります。

src/app/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もインポートしています。

src/components/ThemeToggle.tsx
"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>
    </>
  );
};

トグルはヘッダーに設置したいため、ヘッダーコンポーネントも作成し、そこに読み込ませます。

src/components/Header.tsx
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に配置します。

src/app/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と同じものを指定することに注意してください。

src/app/layout.tsx
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を指定しました。

src/app/layout.tsx
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を付与します。

src/components/Header.tsx
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でダークモード実装の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?