🎉

Next.js(with CSS Modules)アプリにダークモードを実装する

2022/01/13に公開
1

はじめに

この記事ではCSS Modulesでスタイリングを行なっているNext.jsアプリにダークモードを実装する方法について書きます。この記事のコードはTypeScriptで書いており、状態管理はRecoilで行っています。

この記事が他の人の参考になれば幸いです。
また、この記事の内容に間違った記載がありましたら、指摘してもらえるとありがたいです。

環境

名前 バージョン
macOS Monterey 12.0.1
Node.js 16.13.0
React 17.0.2
Next.js 12.0.7
Recoil 0.5.2

実装内容

この記事では以下の要件を満たすようなダークモードを実装します。

  • Recoilでテーマを管理し、ボタンのクリックで簡単にテーマを切り替えられる。
  • デフォルトではprefers-color-schemeからユーザがOSなどで設定しているテーマを取得し、適用する。
  • ユーザがテーマを切り替えた場合はそのテーマをlocal storageで記憶し、local storageにテーマが保存されている場合はそのテーマを適用する。
  • フラッシュ(最初にデフォルトのテーマを表示した後に適用するテーマに切り替わること)を起こさず、最初から適用するテーマで表示されるようにする。

実装方法としてはJavaScriptでテーマを扱う状態を持ち、CSSでテーマを識別するためにHTMLのルート要素htmldata-theme属性を追加します。

そのためCSS Modulesのスタイリングは以下のように行います。
CSS変数を使用する場合は以下のようにグローバルCSSstyles/globals.cssでそれぞれのCSS変数を宣言し、適用する箇所に挿入します。

styles/globals.css
:root[data-theme="light"] {
  /* ライトテーマで適用するプロパティ値 */
  --c-primary: #6750a4;
  --c-on-primary: #625b71;
}

:root[data-theme="dark"] {
  /* ダークテーマで適用するプロパティ値 */
  --c-primary: #d0bcff;
  --c-on-primary: #ccc2dc;
}
.container {
  /* CSS 変数の値を挿入する */
  color: var(--c-on-primary);
  background-color: var(--c-primary);
}

また、子孫セレクタを使用して以下のようにスタイリングできます。

/* ライトテーマで適用 */
.container {
  color: red;
}

/* ダークテーマで適用 */
[data-theme="dark"] .container {
  color: darkred;
}

テーマとテーマを管理する関数を定義する

まず、テーマの状態themeStateを定義し、その状態を扱う関数を定義します。
useThemeはReactコンポーネントでテーマを扱うためのカスタムフックで、テーマthemeとテーマを切り替える関数toggleThemeを返します。

lib/theme.ts
import { atom, useRecoilState, useSetRecoilState } from "recoil";

export type Theme = "light" | "dark";

const themeState = atom<Theme>({
  key: "themeState",
  default: "light",
});

export const useSetTheme = () => useSetRecoilState(themeState);

export const useTheme = () => {
  const [theme, setTheme] = useRecoilState(themeState);

  const toggleTheme = () => {
    const newTheme = theme === "light" ? "dark" : "light";
    setTheme(newTheme);
    window.localStorage.setItem("theme", newTheme);
    const root = window.document.documentElement;
    root.setAttribute("data-theme", newTheme);
  };

  return { theme, toggleTheme };
};

Themeコンポーネントを追加する

最初にページを読み込む時にhtml要素にdata-theme属性を設定し、JavaScriptで読み込む必要があります。
そのためにTheme.tsxに以下のようにThemeProviderコンポーネントを定義します。

Theme.tsx
import { useEffect } from "react";
import { Theme, useSetTheme } from "@/lib/theme";

type Props = {
  children: JSX.Element;
};

const ThemeProvider = ({ children }: Props): JSX.Element => {
  const setTheme = useSetTheme();

  useEffect(() => {
    const root = window.document.documentElement;
    const initialColorValue = root.getAttribute("data-theme");
    setTheme(initialColorValue as Theme);
  }, [setTheme]);

  return (
    <>
      <script
        dangerouslySetInnerHTML={{
          __html: `!function(){let e;const t=window.localStorage.getItem("theme");if(null!==t)e=t;else{e=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",e)}();`,
        }}
      />
      {children}
    </>
  );
};

export default ThemeProvider;

ThemeProviderコンポーネントのuseEffectはマウント後にdata-themeの値を読み込み、テーマをセットします。
ThemeProviderコンポーネントの<script>タグはフラッシュを防ぐために使用します。
<script>タグに記述したJavaScriptスクリプトの実行後にbody要素のコンテンツのマウントされるので最初から正しいテーマを適用できます。
圧縮していない<script>タグのJavaScriptスクリプトは以下のようになります。
ローカルストレージにテーマがあったら適用し、なかったらprefers-color-schemeからテーマを取得し、html要素のdata-theme属性に設定します。

圧縮していない<script>タグのスクリプト
(function () {
  let theme;
  const storageTheme = window.localStorage.getItem("theme");
  if (storageTheme !== null) {
    theme = storageTheme;
  } else {
    const mql = window.matchMedia("(prefers-color-scheme: dark)");
    theme = mql.matches ? "dark" : "light";
  }

  const root = document.documentElement;
  root.setAttribute("data-theme", theme);
})();

作成したThemeコンポーネントをpages/_app.tsxMyAppコンポーネントの子要素に追加します。

pages/_app.tsx
import type { AppProps } from "next/app";
import { RecoilRoot } from "recoil";

import Theme from "@/components/functional/Theme";
import "@/styles/globals.css";

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
  return (
    <RecoilRoot>
-     <Component {...pageProps} />
+     <Theme>
+       <Component {...pageProps} />
+     </Theme>
    </RecoilRoot>
  );
};

export default MyApp;

ブラウザに現在のテーマを伝える

input[type="date"]要素のカレンダーなどブラウザで表示されるUIもダークモードに対応させるためにブラウザに現在のテーマを伝えます。

_document.tsxに以下のように<meta>タグを追加します。

pages/_document.tsx
class MyDocument extends Document {
  ...

  render() {
    return (
      <Html lang="ja">
        <Head>
+         <meta name="color-scheme" content="light dark" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

そして、styles/globals.cssに以下のように追記します。

styles/globals.css
:root[data-theme="light"] {
+ color-scheme: light;
  ...
}

:root[data-theme="dark"] {
+ color-scheme: dark;
  ...
}

参考

GitHubで編集を提案

Discussion

Sou WatanabeSou Watanabe

ありがとうございます!!めちゃくちゃ参考にさせていただきました!