Next.js(with CSS Modules)アプリにダークモードを実装する
はじめに
この記事では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のルート要素html
にdata-theme
属性を追加します。
そのためCSS Modulesのスタイリングは以下のように行います。
CSS変数を使用する場合は以下のようにグローバルCSSstyles/globals.css
でそれぞれの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
を返します。
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
コンポーネントを定義します。
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
属性に設定します。
(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.tsx
のMyApp
コンポーネントの子要素に追加します。
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>
タグを追加します。
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
に以下のように追記します。
:root[data-theme="light"] {
+ color-scheme: light;
...
}
:root[data-theme="dark"] {
+ color-scheme: dark;
...
}
Discussion
ありがとうございます!!めちゃくちゃ参考にさせていただきました!