Next.js - ダークモードの切り替えボタンを実装する方法

1. はじめに

以前このブログのWebサイトをダークモードに対応させる方法という記事で紹介したコードをNext.jsで使えるようにしてみました。

2. Next.jsで"window is not defined"を回避する方法

ダークモードの実装でwindow.matchMediaなどを使用したいときにそのまま書くとエラーになってしまいます。それを回避する方法が何種類かあるので忘れないように書いておこうと思います。詳しい内容はコチラの記事がわかりやすいと思います。

typeof window

if (typeof window !== "undefined") {
  //...
}

useEffect

import { useEffect } from "react";

useEffect(() => {
  //....
}, [])

dynamic import

import dynamic from "next/dynamic";

const XXXX = dynamic(() => {
    return import("../components/FileName");
  },
  { ssr: false }
);

<XXXX />

3. ダークモードの実装

はじめにtheme.tsxというファイルを作成します。(ファイル名はTypeScriptですがTypeScript化してないのでjsでもok)。上記の「"window is not defined"を回避する方法」は、ここではif (typeof window !== "undefined") {...}を使用しています。下記のざっくりした内容は、ThemeProvider<html>タグにカスタムデータ属性の追加とlocalStorageにテーマを保存するようにしてtoggleThemeでその切替をするようにしています。

//theme.tsx
import { createContext, useContext } from 'react'

const toggleTheme = () => {
  if (typeof window !== 'undefined') {
    let root = document.documentElement
    let mql = window.matchMedia('(prefers-color-scheme: dark)')
    let storage = localStorage
    let getTheme = storage.getItem('theme')

    if (getTheme === 'dark') {
      root.setAttribute('data-theme', 'light')
      storage.setItem('theme', 'light')
    } else if (getTheme === 'light') {
      root.setAttribute('data-theme', 'dark')
      storage.setItem('theme', 'dark')
    } else if (mql.matches) {
      root.setAttribute('data-theme', 'light')
      storage.setItem('theme', 'light')
    } else {
      root.setAttribute('data-theme', 'dark')
      storage.setItem('theme', 'dark')
    }
  }
}

const ThemeContext = createContext(() => {})

export const ThemeProvider = ({ children }) => {
  if (typeof window !== 'undefined') {
    let root = document.documentElement
    let mql = window.matchMedia('(prefers-color-scheme: dark)')
    let storage = localStorage
    let getTheme = storage.getItem('theme')

    const mediaTheme = (mql) => {
      if (mql.matches) {
        root.setAttribute('data-theme', 'dark')
        storage.setItem('theme', 'dark')
      } else {
        root.setAttribute('data-theme', 'light')
        storage.setItem('theme', 'light')
      }
    }
    mql.addListener(mediaTheme)

    if (getTheme === 'dark') {
      root.setAttribute('data-theme', 'dark')
      storage.setItem('theme', 'dark')
    } else if (getTheme === 'light') {
      root.setAttribute('data-theme', 'light')
      storage.setItem('theme', 'light')
    }
  }
  return (
    <ThemeContext.Provider value={toggleTheme}>
      {children}
    </ThemeContext.Provider>
  )
}

export const useToggleTheme = () => useContext(ThemeContext)

上記で作成したtheme.tsxThemeProvider_app.tsxに追加します。

//_app.js
import '../styles/globals.scss' //cssでもok
import { ThemeProvider } from '../components/theme'

import { AppProps } from 'next/app'

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
  )
}
export default App

CSSでダークモードを実装する場合、通常は@media (prefers-color-scheme: dark) {...}のように書きますが、ここではカスタムデータ属性で切り替えを行っているので書かなくて大丈夫です。

/*globals.scss*/
[data-theme='dark'] {
  --bg-color: #333;
  --text-color: #fff;
}

body {
  background-color: var(--bg-color);
}
h1 {
  color: var(--text-color);
}

最後に切り替えボタンを出力したい場所に下記のようにボタンを追加します。

//index.tsx
import { useToggleTheme } from '../components/theme'

const Home = () => {
  const toggle = useToggleTheme()
  return (
    <div>
      <h1>Theme</h1>
      <button onClick={toggle}>Toggle</button>
    </div>
  )
}
export default Home

data-* - HTML: HyperText Markup Language | MDN
フック API リファレンス – React