Next.js と Material-UI でダークモードをいい感じに実装する
こんにちは無職です。ダークモードの実装がメインなので、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
を開きましょう。
見慣れた光景ですね。次に Material-UI を導入します。
npm install @material-ui/core
_app.js
を変更し、_document.js
とMyThemeProvider
コンポーネントを作成しましょう。
_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
は以下。
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
は以下。
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",
},
});
ちゃんと反映されましたね。それでは切り替えられるようにしましょう。
ダークモードの実装
コードは以下のようになります。
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>
)
}
左上に、切り替えボタンが表示されます。
クリックすると切り替わるはずです。それでは解説していきます。
まず、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 で LocalStorage
や Window
オブジェクトを扱う処理は useEffect
内で行うようにしてください。
Typescript 化
普段は Typescript を書くので、Typescript バージョンも置いておきますね。
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>
)
}
参考:
- https://dev.classmethod.jp/articles/react-material-ui-dark-mode/
- https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme!
Qiita との比較
圧倒的に Zenn の方が快適に執筆・投稿することができました。Zenn は 全体的に動きがぬるぬるです。プレビューが右側に表示されないのが不満だという意見を見たことがあるのですが、私の場合は Typora で記事を書いてそれをコピペし、一部付け加えたり修正したりしています。ある程度見え方のイメージはついているので、常時プレビューが見えてなくても問題ありません。何より、Ctrl + S
で保存できるのが本当に嬉しい!癖で、何か書くとすぐに Ctrl + S
を押してしまいます。Qiita で書いている際は、Ctrl + S
を押してしまうたびに HTML ファイルの保存の確認がポップアップして少し困りました。
次は Zenn の Github 連携も試してみようと思います。
Discussion
Recoilフックを使ってダークモードのデモ作ってみました。
簡単ですが、以上です。