💬

Next.js と Material-UI でダークモードをいい感じに実装する

2021/03/31に公開
1

こんにちは無職です。ダークモードの実装がメインなので、Next.js や Material-UI に関して詳細は省きます。基本的な導入に関しては 公式の example を見ていただければ早いと思います。

セットアップ

Next.js プロジェクトの作成。

npx create-next-app

ディレクトリ名を決めます。

√ What is your project named? ... with-materialui-example

プロジェクトが作成されたら、アプリを立ち上げてみましょう。

cd withmaterialui-example

yarn dev

localhost:3000 を開きましょう。

image-20210331191833169

見慣れた光景ですね。次に Material-UI を導入します。

npm install @material-ui/core

_app.js を変更し、_document.jsMyThemeProvider コンポーネントを作成しましょう。

_app.js は以下。

pages/_app.js
import React from 'react'
import Head from 'next/head'
import MyThemeProvider from '../components/MyThemeProvider'

export default function MyApp(props) {
  const { Component, pageProps } = props;

  React.useEffect(() => {
    const jssStyles = document.querySelector('#jss-server-side');
    if (jssStyles) {
      jssStyles.parentElement.removeChild(jssStyles);
    }
  }, []);

  return (
    <React.Fragment>
      <Head>
        <title>My page</title>
        <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
      </Head>
      <MyThemeProvider>
        <Component {...pageProps} />
      </MyThemeProvider>
    </React.Fragment>
  );
}

_document.js は以下。

pages/_document.js
import React from 'react'
import Document, { Html, Head, Main, NextScript } from 'next/document'
import { ServerStyleSheets } from '@material-ui/core/styles'

export default class MyDocument extends Document {
  render() {
    return (
      <Html lang="ja">
        <Head></Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

MyDocument.getInitialProps = async (ctx) => {
  const sheets = new ServerStyleSheets();
  const originalRenderPage = ctx.renderPage;

  ctx.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
    });

  const initialProps = await Document.getInitialProps(ctx);

  return {
    ...initialProps,
    styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()],
  };
};

MyThemeProvider.js は以下。

components/MyThemeProvider.js
import React from 'react'
import { CssBaseline } from '@material-ui/core'
import { ThemeProvider } from '@material-ui/styles'
import { createMuiTheme } from '@material-ui/core/styles'

export default function MyThemeProvider({ children }) {

  const theme = createMuiTheme({});
  
  return (
    <ThemeProvider theme={theme}>
        <CssBaseline />
        {children}
      </ThemeProvider>
  )
}

ダークテーマに設定してみましょう。theme を次のように変更します。

const theme = createMuiTheme({
    palette: {
      type: "dark",
    },
  });

image-20210331213706237

ちゃんと反映されましたね。それでは切り替えられるようにしましょう。

ダークモードの実装

コードは以下のようになります。

MyThemeProvider.js
import React from 'react'
import { CssBaseline } from '@material-ui/core'
import { ThemeProvider } from '@material-ui/styles'
import { createMuiTheme } from '@material-ui/core/styles'

export default function MyThemeProvider({ children }) {
  const [darkMode, setDarkMode] = React.useState(false);
  
  const handleDarkModeOn = () => {
    localStorage.setItem("darkMode", "on");
    setDarkMode(true);
  };
  
  const handleDarkModeOff = () => {
    localStorage.setItem("darkMode", "off");
    setDarkMode(false);
  };

  React.useEffect(() => {
    if (localStorage.getItem("darkMode") === "on") {
      setDarkMode(true);
    } else if (localStorage.getItem("darkMode") === "off") {
      setDarkMode(false);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        setDarkMode(true);
    } else {
      setDarkMode(false);
    }
  }, []);

  const theme = createMuiTheme({
    palette: {
      type: darkMode ? "dark" : "light",
    },
  });
  
  return (
    <ThemeProvider theme={theme}>
        <CssBaseline />
        {darkMode ? (
        <button onClick={handleDarkModeOff}>Change to LightMode</button>
        ) : ( 
        <button onClick={handleDarkModeOn}>Change to DarkMode</button>
      )}
        {children}
      </ThemeProvider>
  )
}

左上に、切り替えボタンが表示されます。

image-20210331214306009

クリックすると切り替わるはずです。それでは解説していきます。

まず、useState を使用して、ダークモードかどうかを判断するためのステートを作成します。

const [darkMode, setDarkMode] = React.useState(false);

darkMode ステートだけで管理すると問題があります。ユーザーがページをリロードすると、初期値に戻ってしまうのです。今回の場合、ステートの初期値が false なので、リロードすると必ず light テーマが適用されてしまいます。そこで、ローカルストレージを利用します。ローカルストレージに保存したデータはリロードしても消えません。

const handleDarkModeOn = () => {
    localStorage.setItem("darkMode", "on");
    setDarkMode(true);
};

const handleDarkModeOff = () => {
    localStorage.setItem("darkMode", "off");
    setDarkMode(false);
};

ページを開いたときにローカルストレージを覗いて、必要ならダークモードに設定するコードを書きましょう。

React.useEffect(() => {
    if (localStorage.getItem("darkMode") === "on") {
      setDarkMode(true);
    } else if (localStorage.getItem("darkMode") === "off") {
      setDarkMode(false);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        setDarkMode(true);
    } else {
      setDarkMode(false);
    }
}, []);

上から順に見ていきましょう。

if (localStorage.getItem("darkMode") === "on") {
    setDarkMode(true);
}  else if (localStorage.getItem("darkMode") === "off") {
    setDarkMode(false);
}

ここでは、ローカルストレージの "darkmode" というキーの値が "on" だった場合、darkMode ステートに true をセットします、"off" だった場合はdarkMode ステートを "off" に。そのまんまですね。

それでは次の条件分岐を見てみましょう。

else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    setDarkMode(true);
}

window.matchMedia('(prefers-color-scheme: dark)').matches というコードは、ユーザーがダークテーマを求めているなら true になります。prefers-color-scheme というメディア特性を用いて、ユーザーが OS にどちらのテーマを設定しているのかを検出することができます。これで、ダークテーマを好む人には最初からダークテーマが、そうでないならライトテーマが表示されます。

else {
    setDarkMode(false);
}

その他の場合は初期値と同じライトテーマを適用します。

以上がダークモードの実装になります。注意点として、Next.js で LocalStorageWindow オブジェクトを扱う処理は useEffect 内で行うようにしてください。

Typescript 化

普段は Typescript を書くので、Typescript バージョンも置いておきますね。

MyThemeProvider.tsx
import React from 'react'
import { CssBaseline, ThemeOptions } from '@material-ui/core'
import { ThemeProvider } from '@material-ui/styles'
import { createMuiTheme } from '@material-ui/core/styles'

type Props = {
  children: React.ReactNode,
}

export default function MyThemeProvider({ children }: Props) {
  const [darkMode, setDarkMode] = React.useState<boolean>(false);
  
  const handleDarkModeOn = (): void => {
    localStorage.setItem("darkMode", "on");
    setDarkMode(true);
  };

  const handleDarkModeOff = (): void => {
    localStorage.setItem("darkMode", "off");
    setDarkMode(false);
  };

  React.useEffect(() => {
    if (localStorage.getItem("darkMode") === "on") {
      setDarkMode(true);
    } else if (localStorage.getItem("darkMode") === "off") {
      setDarkMode(false);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        setDarkMode(true);
    } else {
      setDarkMode(false);
    }
  }, []);

  const theme: ThemeOptions = createMuiTheme({
    palette: {
      type: darkMode ? "dark" : "light",
    },
  });
  
  return (
    <ThemeProvider theme={theme}>
        <CssBaseline />
        {darkMode ? (
        <button onClick={handleDarkModeOff}>Change to LightMode</button>
        ) : ( 
        <button onClick={handleDarkModeOn}>Change to DarkMode</button>
      )}
        {children}
      </ThemeProvider>
  )
}

参考:

Qiita との比較

圧倒的に Zenn の方が快適に執筆・投稿することができました。Zenn は 全体的に動きがぬるぬるです。プレビューが右側に表示されないのが不満だという意見を見たことがあるのですが、私の場合は Typora で記事を書いてそれをコピペし、一部付け加えたり修正したりしています。ある程度見え方のイメージはついているので、常時プレビューが見えてなくても問題ありません。何より、Ctrl + S で保存できるのが本当に嬉しい!癖で、何か書くとすぐに Ctrl + S を押してしまいます。Qiita で書いている際は、Ctrl + S を押してしまうたびに HTML ファイルの保存の確認がポップアップして少し困りました。
次は Zenn の Github 連携も試してみようと思います。

Discussion