こちらで使用したサンプルコードは下記にありますので詳しく知りたい方はご参照ください。
概要
ダークモードを実装するにあたってさまざまな方法がありますが、今回はNext.jsとUIフレームワークのMUI、状態管理ライブラリのRecoilを使用しローカルストレージでモードを保持し実装する方法について解説したいと思います。
ライブラリのインストール
Recoilのインストール
yarn add recoil
MUIのインストール
yarn add @mui/material @emotion/react @emotion/styled
ディレクトリ構成
.
├── .eslintrc.json
├── .gitignore
├── README.md
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages
│ ├── _app.tsx
│ └── index.tsx
├── public
│ ├── favicon.ico
│ └── vercel.svg
├── styles
│ ├── Home.module.css
│ └── globals.css
├── tsconfig.json
└── yarn.lock
ダークモードの実装
MUIでダークモードを実装する方法
MUIにはデフォルトでlight
モードとdark
モードが用意されており、createTheme
関数でテーマを作成する際、以下のように引数でモードの指定を行うことで切り替えることが可能です。
import { createTheme } from '@mui/material';
function MyApp({ Component, pageProps }: AppProps) {
const theme = createTheme({
palette: {
mode: 'dark',
},
});
return <Component {...pageProps} />
}
先ほど作成したテーマをThemeProvider
のtheme
プロパティに渡すことで反映されます。注意点としてCssBaseline
コンポーネントを配置しデフォルトCSSのリセットを行わないと、パレットモードを切り替えても色が変わらないため必ず配置する必要があります。
import { ThemeProvider } from '@mui/material/styles';
function MyApp({ Component, pageProps }: AppProps) {
// 省略
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
);
}
Recoilを使用せずダークモードを実装
まずはRecoilを使用せずダークモードを実装していきます。パレットモードの状態をuseState
を使用し保持するよう変更します。また、デフォルトのパレットモードはuseMediaQuery
でユーザーのシステム設定から取得するようにします。
import { createTheme, CssBaseline, PaletteMode } from '@mui/material';
import { useState } from 'react';
function MyApp({ Component, pageProps }: AppProps) {
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
const theme = createTheme({
palette: {
mode: paletteMode,
},
});
// 省略
}
パレットモードの状態を保持するよう変更したら、次はパレットモードを切り替えできるようにします。
import { createTheme, CssBaseline, PaletteMode, Switch } from '@mui/material';
import { useState } from 'react';
function MyApp({ Component, pageProps }: AppProps) {
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
const [isDarkMode, setIsDarkMode] = useState(paletteMode === 'dark');
// 省略
const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
setPaletteMode(event.target.checked ? 'dark' : 'light');
setIsDarkMode(event.target.checked);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box p={4}>
Palette Mode :
<Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
{paletteMode}
</Box>
<Component {...pageProps} />
</ThemeProvider>
);
}
これで左上に表示されているスイッチをクリックすることで、ライトモードとダークモードが切り替えできるようになりました。
しかしこのままだとリロードした際にパレットモードがリセットされてしまうので、ローカルストレージにパレットモードを保存するよう変更しましょう。コードが重複していますがすぐに不要になるのでこのまま進めていきます。
import { useEffect, useState } from 'react';
function MyApp({ Component, pageProps }: AppProps) {
const paletteModeStorageKey = 'palette_mode';
// 省略
useEffect(() => {
const paletteMode = localStorage.getItem(paletteModeStorageKey) ?? prefersPaletteMode;
if (['light', 'dark'].includes(paletteMode)) {
setPaletteMode(paletteMode as PaletteMode);
setIsDarkMode(paletteMode === 'dark);
localStorage.setItem(paletteModeStorageKey, paletteMode);
}
});
const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
setPaletteMode(event.target.checked ? 'dark' : 'light');
setIsDarkMode(event.target.checked);
localStorage.setItem(paletteModeStorageKey, paletteMode);
};
// 省略
}
以上でRecoilを使用しない場合のダークモードの実装が完了しました。最終的なコードは以下になります。
最終的なコード
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { ThemeProvider } from '@mui/material/styles';
import { Box, createTheme, CssBaseline, PaletteMode, Switch, useMediaQuery } from '@mui/material';
import { useEffect, useState } from 'react';
const paletteModes = ['light', 'dark'];
const paletteModeStorageKey = 'palette_mode';
function MyApp({ Component, pageProps }: AppProps) {
const paletteModeStorageKey = 'palette_mode';
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
const [isDarkMode, setIsDarkMode] = useState(paletteMode === 'dark');
const theme = createTheme({
palette: {
mode: paletteMode,
},
});
useEffect(() => {
const paletteMode = localStorage.getItem(paletteModeStorageKey) ?? prefersPaletteMode;
if (['light', 'dark'].includes(paletteMode)) {
setPaletteMode(paletteMode as PaletteMode);
setIsDarkMode(paletteMode === 'dark);
localStorage.setItem(paletteModeStorageKey, paletteMode);
}
});
const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
setPaletteMode(event.target.checked ? 'dark' : 'light');
setIsDarkMode(event.target.checked);
localStorage.setItem(paletteModeStorageKey, paletteMode);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box p={4}>
Palette Mode :
<Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
{paletteMode}
</Box>
<Component {...pageProps} />
</ThemeProvider>
);
}
export default MyApp
Recoilを使用するよう変更
それでは先ほどの実装からRecoil
を使用した実装へ変更していきましょう。
まずはプロジェクトディレクトリの直下にstore
ディレクトリを作成します。次にpalette-mode.ts
ファイルをstore
ディレクトリの直下に作成します。
次に先ほど作成したpalette-mode.ts
にAtom
を定義します。この時undefined
をデフォルト値とすることで、未設定の場合はuseMediaQuery
でユーザーのシステム設定からパレットモードを設定できるようにしておきます。
import { atom } from 'recoil';
import { PaletteMode } from '@mui/material';
const paletteModeState = atom<PaletteMode | undefined>({
key: 'PaletteMode',
default: undefined,
});
これだけで、useRecoilState
関数の引数にpaletteModeState
を渡すことで値とセッターが取得できますが、ここではカスタムフックを定義したいと思います。
export type setPaletteModeType = (paletteMode: PaletteMode) => void;
export type usePaletteModeType = () => [PaletteMode, setPaletteModeType]
export const usePaletteMode: usePaletteModeType = () => {
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useRecoilState(paletteModeState);
return [
paletteMode ?? prefersPaletteMode,
(paletteMode: PaletteMode) => setPaletteMode(paletteMode),
];
};
カスタムフックを定義したら、ローカルストレージに値を保持するようにします。 まず、localStorageEffect
関数を定義しそれをpaletteModeState
のeffects
に追加します。
import { atom, AtomEffect, useRecoilState } from 'recoil';
const PALETTE_MODE_STORAGE_KEY = 'palette_mode'
const localStorageEffect: (key: string) => AtomEffect<PaletteMode | undefined> =
(key) =>
({ onSet }) => {
onSet((newValue, _, isReset) => {
if (isReset || newValue === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, newValue);
});
};
const paletteModeState = atom<PaletteMode | undefined>({
key: 'PaletteMode',
default: undefined,
effects: [localStorageEffect(PALETTE_MODE_STORAGE_KEY)],
});
そして、カスタムフックにパレットモードをローカルストレージから取得し有効な値であれば設定する処理を追加します。
export const usePaletteMode: usePaletteModeType = () => {
// 省略
useEffect(() => {
const paletteMode = localStorage.getItem(PALETTE_MODE_STORAGE_KEY);
if (paletteMode !== null && ['light', 'dark'].includes(paletteMode)) {
setPaletteMode(paletteMode as PaletteMode)
}
});
// 省略
};
以上でRecoil
を使用した実装が完了したのでこれを使用するよう変更しましょう。使用するためにはRecoilRoot
で囲われたコンポーネントで使用する必要があるので、まずRecoilRoot
で囲います。
import { RecoilRoot } from 'recoil';
function MyApp({ Component, pageProps }: AppProps) {
// 省略
return (
<RecoilRoot>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box p={4}>
Palette Mode :
<Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
{paletteMode}
</Box>
<Component {...pageProps} />
</ThemeProvider>
</RecoilRoot>
);
}
次に_app.tsx
はRecoilRoot
に囲われていないためusePaletteMode
が使用できないので、ThemeProvider
以下をコンポーネント化します(<Component {...pageProps} />
は除く)。
まず、プロジェクトディレクトリ直下にcomponetnts
ディレクトリを作成し、その直下にTheme.tsx
ファイルを作成します。
ファイルを作成したらTheme
コンポーネントを定義していきます。_app.tsx
のロジック部分とThemeProvider
以下を全て移植します。この時Theme
コンポーネントの引数としてReactNode
を受け取るようにします。
import { Box, createTheme, CssBaseline, Switch, ThemeProvider } from "@mui/material";
import { ReactNode, useEffect, useState } from "react";
import { usePaletteMode } from "../store/palette-mode";
export const Theme = ({ children }: { children: ReactNode }) => {
const paletteModeStorageKey = 'palette_mode';
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
const [isDarkMode, setIsDarkMode] = useState(paletteMode === 'dark');
const theme = createTheme({
palette: {
mode: paletteMode,
},
});
useEffect(() => {
const paletteMode = localStorage.getItem(paletteModeStorageKey) ?? prefersPaletteMode;
if (['light', 'dark'].includes(paletteMode)) {
setPaletteMode(paletteMode as PaletteMode);
setIsDarkMode(paletteMode === 'dark);
localStorage.setItem(paletteModeStorageKey, paletteMode);
}
});
const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
setPaletteMode(event.target.checked ? 'dark' : 'light');
setIsDarkMode(event.target.checked);
localStorage.setItem(paletteModeStorageKey, paletteMode);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box p={4}>
Palette Mode :
<Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
{paletteMode}
</Box>
{children}
</ThemeProvider>
)
}
移植したらuseEffect
を削除し、以下のコードをusePaletteMode
を使用するよう変更します。
// Before
const paletteModeStorageKey = 'palette_mode';
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
// After
const [paletteMode, setPaletteMode] = usePaletteMode();
次にサーバー側とクライアント側でパレットモードが異なる可能性があるので、Theme
コンポーネントのuseState(paletteMode === 'dark')
の後に以下を追加しクライアント側でダークモードか否かを判定し再設定するようにします。
useEffect(() => setIsDarkMode(paletteMode === 'dark'));
最後に_app.tsx
でThemeProvider
以下を先ほど作成したTheme
コンポーネントに置き換えます。
import { Theme } from '../components/Theme';
function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Theme>
<Component {...pageProps} />
</Theme>
</RecoilRoot>
);
}
以上で完成になります。サイトにアクセスし左上に表示されているスイッチをクリックすることで、ライトモードとダークモードが切り替えできることを確認してみましょう。
さいごに
今回は、ダークモードをNext.js・MUI・Recoil を使用してローカルストレージにモードを保持する方法について紹介しました。
最近ではダークモードが実装されているサイトが増えてきたように思います。この記事がダークモードを実装する際の参考になりましたら幸いです。
新しいメンバーを募集しています
Ohashi / Engineer
主にLaravelなどのバックエンドを中心にサーバー周りも担当しています。 目標は腕周り40cm 越え。