🌙

Next.jsにおけるダークモード対応へのアプローチ

2024/01/01に公開

個人サイトをNext.jsで作成してダークモード対応をした時のことをまとめました。

Next.jsでは https://github.com/pacocoursey/next-themes といったライブラリを利用すれば、比較的簡単にダークモード対応は可能かと思いますが、今回はライブラリの使用なしで実装しました。

App Dir を使用しています。

color-schemeを定義

ダークモードの色を設定する前に color-scheme の定義をして、レンダリングの配色を指定します。
lightdark の両方のモードをサポートするように root に設定します。
デフォルトは normal となっておりブラウザの既定の配色でレンダリングすることになっています。

color-scheme css
:root {
  color-scheme: light dark;
}

参考

テーマ切り替えを実装

テーマの変更を可能にする

テーマの切り替えを可能にするための処理を追加します。UIと同じファイルに記載しても問題ないですが、hooksで別ファイルに切り出して実装しています。

現状のcssファイルのみでの設定のみだと切り替え時のテーマ変更には対応できていないため、変更時に html 要素に data-theme を追加もしくは変更するようにしています。(data-theme 出なくても問題はありません)
また data-theme によってテーマを変更するため css ファイルにもテーマ別のスタイルを加える必要があります。

color-scheme css
:root {
  color-scheme: light dark;
}

:root[data-theme='dark'] {
  color-scheme: dark;
  
  --text: #fffffff;
  --background: #000000
}

:root[data-theme='light'] {
  color-scheme: light;
  
  --text: #000000;
  --background: #fffffff
}

さらにページ離脱後にもテーマが引き継がれるようにするため、 LocalStorage を利用してテーマを保存して変更する仕組みを追加します。

useTheme.ts
 export const useTheme = () => {
  const [theme, setTheme] = useState<'light' | 'dark' | 'system' | null>(null)
  
  useEffect(() => {
    const initTheme = window.localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
    const root = window.document.documentElement
		
    root.setAttribute('data-theme', initTheme || "system")
    setTheme(initTheme || 'system')
  }, [])

  const handleChangeTheme = useCallback((value: 'light' | 'dark' | 'system') => {
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    const newTheme = value === 'system' ? (isDark ? 'dark' : 'light') : value
    const root = window.document.documentElement

    window.localStorage.setItem('theme', value)
    root.setAttribute('data-theme', newTheme)
    setTheme(value)
  }, [])

  return { theme, handleChangeTheme }
}

切り替えボタンを設置

テーマを切り替えるためのトグルの作成をします。
スイッチボタンで light dark の切り替えをするだけのUIを見ることが多いと思いますが、今回はsystemテーマを選択肢に追加するため radio で実装をします。

テーマ切り替えUI
const {theme, handleChangeTheme } = useTheme()

const onChangeTheme = (e: React.ChangeEvent<HTMLInputElement> & { currentTarget: { value: 'light' | 'dark' | 'system' } }) => {
  handleChangeTheme(e.currentTarget.value)
}

<form>
  <fieldset className={s.field}>
    <label htmlFor="system">
      <input
        id="system"
	checked={theme === 'system'}
	name="appearance"
	type="radio"
	value="system"
      />
      <span>System</span>
    </label>
    <label htmlFor="light">
      <input
	id="light"
	checked={theme === 'light'}
	name="appearance"
	type="radio"
	value="light"
      />
      <span>Light</span>
    </label>
    <label htmlFor="dark">
      <input
	id="dark"
	checked={theme === 'dark'}
	name="appearance"
	type="radio"
	value="dark"
      />
      <span>Dark</span>
    </label>
  </fieldset>
</form>

もし Extra attributes from the server: ~~~ のような警告が出た場合はルートの layouthtml に suppressHydrationWarning を追加すると無視することができます。

これはSSRの場合サーバーとクライアント側で異なる内容をレンダリングしてしまうことで発生するエラーになります。 suppressHydrationWarning を追加することで、単一レベルの深さの警告を抑制することができます。

https://github.com/vercel/next.js/discussions/22388

OSのテーマ更新を検知

ページを開いているときにシステムのテーマを変更した場合にページテーマが変わるようにします。
これは Window: matchMedia() を使用することで実現可能です。 tailwindcssの実装例を参考にしているのですが、今回は system のページ内でシステムのテーマを選択可能にしているため、一部条件を変更して実装しています。

useTheme.ts
export const useTheme = () => {
  const [theme, setTheme] = useState<'light' | 'dark' | 'system' | null>(null)
  
+   const handleMediaQuery = useCallback((e: MediaQueryListEvent | MediaQueryList) => {
+      if (window.localStorage.theme === 'system') {
+         if (e.matches) {
+        document.documentElement.setAttribute('data-theme', 'dark')
+      } else {
+        document.documentElement.setAttribute('data-theme', 'light')
+         }
+    }
+  }, [])

+  useEffect(() => {
+    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleMediaQuery)
+ 
+    return () => {
+      window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', handleMediaQuery)
+    }
+  }, [handleMediaQuery])

  useEffect(() => {
    const initTheme = window.localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
    const root = window.document.documentElement
		
    root.setAttribute('data-theme', initTheme || "system")
    setTheme(initTheme || 'system')
  }, [])

  const handleChangeTheme = useCallback((value: 'light' | 'dark' | 'system') => {
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    const newTheme = value === 'system' ? (isDark ? 'dark' : 'light') : value
    const root = window.document.documentElement

    window.localStorage.setItem('theme', value)
    root.setAttribute('data-theme', newTheme)
    setTheme(value)
  }, [])

  return { theme, handleChangeTheme }
}

画面更新時のちらつきを防止

ここまでで画面の任意の部分にテーマ切り替えUIを置いてページのテーマを切り替えるようにすることはできているかと思いますが、画面更新時に一瞬テーマ切り替えのチラつきが見えてしまいます。
例えば、OSのテーマを system にして画面のテーマを light の状態で画面更新すると light に切り替わる前に dark テーマが表示されます。

原因としては useEffect でテーマを検知して切り替える前に画面の表示が行われており、テーマの更新自体がその後になっているためです。

画面のチラつきを防止するためにインラインの script タグをルートの layout.tsx に挿入します。
next-themeの実装を参考にして、今回の実装にあった形に整えています。

layout.tsx
// layout.tsx に追加します。
// head内ではなくbodyに含める必要があります。
<script 
  dangerouslySetInnerHTML={{
    __html: `
      const storageTheme = window.localStorage.getItem('theme')
      const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
      const root = window.document.documentElement

      root.setAttribute('data-theme', storageTheme === 'system' ? (isDark ? 'dark' : 'light') : storageTheme || 'dark')
    `,
   }}
  type="text/javascript"
/>

また、 script で初期テーマの変更を実行しているため、 useTheme で行なっていた画面の初期表示時のテーマ切り替えは必要ないため削除します。

useTheme.ts
export const useTheme = () => {
  const [theme, setTheme] = useState<'light' | 'dark' | 'system' | null>(null)
  
  const handleMediaQuery = useCallback((e: MediaQueryListEvent | MediaQueryList) => {
    if (window.localStorage.theme === 'system') {
      if (e.matches) {
             document.documentElement.setAttribute('data-theme', 'dark')
      } else {
            document.documentElement.setAttribute('data-theme', 'light')
         }
      }
  }, [])

  useEffect(() => {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleMediaQuery)

    return () => {
         window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', handleMediaQuery)
    }
  }, [handleMediaQuery])
  
  useEffect(() => {
    const initTheme = window.localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
-    const root = window.document.documentElement
		
-    root.setAttribute('data-theme', initTheme || "system")
    setTheme(initTheme || 'system')
  }, [])

  const handleChangeTheme = useCallback((value: 'light' | 'dark' | 'system') => {
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    const newTheme = value === 'system' ? (isDark ? 'dark' : 'light') : value
    const root = window.document.documentElement

    window.localStorage.setItem('theme', value)
    root.setAttribute('data-theme', newTheme)
    setTheme(value)
  }, [])

  return { theme, handleChangeTheme }
}

ちらつきが無くならない場合

これでもチラつきが直らない場合は layout.tsx 内で useSearchParamsのようなClient Component hookを使用していることが原因の可能性があるため確認してみるといいかもしれません。もし Client Component hookを使用している場合は、該当の部分を Suspense で囲ったり,
より下層に移すと画面のチラつきも解消されるはずです。

Contextによるstate管理

最後に ContextAPI を使用してどのコンポーネントからもテーマの変更・取得ができるようにします。
テーマの取得が可能なhooksと変更が可能なhooksも用意していきます。

ThemeProvider.tsx
'use client'

import { createContext, memo, ReactNode, useContext } from 'react'

import { useTheme } from '<useThemeのパス>'

type DispatchProps = {
  handleChangeTheme: (theme: 'light' | 'dark' | 'system') => void
}

export const ThemeStateContext = createContext<{ state: 'light' | 'dark' | 'system' | null }>({ state: null })
export const ThemeDispatchContext = createContext<DispatchProps>({ handleChangeTheme: () => {} })

// テーマのちらつき防止のscriptもContext内に移しています。
// 移した場合はルートのlayoutからscriptを削除します。
const _ThemeScript = () => {
  return (
    <script
      dangerouslySetInnerHTML={{
        __html: `
	  const storageTheme = window.localStorage.getItem('theme')
	  const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
	  const root = window.document.documentElement

	  root.setAttribute('data-theme', storageTheme === 'system' ? (isDark ? 'dark' : 'light') : storageTheme || 'dark')
	`,
      }}
      type="text/javascript"
    />
  )
}

const ThemeScript = memo(_ThemeScript, () => true)

export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const { theme, handleChangeTheme } = useTheme()

  return (
    <ThemeStateContext.Provider value={{ state: theme }}>
      <ThemeScript />
      <ThemeDispatchContext.Provider value={{ handleChangeTheme }}>{children} </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  )
}

export function useThemeState() {
  return useContext(ThemeStateContext)
}

export function useThemeDispatch() {
  return useContext(ThemeDispatchContext)
}

作成した Context をルートの layout.tsx に含めれば、どこからでも useThemeState useThemeDispatch を呼び出すことでテーマの取得・変更が可能になります。

layout.tsxの一例
layout.tsx
~~~
return (
  <html>
    <body>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </body>
  </html>
)
~~~
切り替えボタンをuseThemeState useThemeDispatchを使用した形に変更
~~~
const { state } = useThemeState()
const { handleChangeTheme } = useThemeDispatch()

const onChangeTheme = (e: React.ChangeEvent<HTMLInputElement> & { currentTarget: { value: 'light' | 'dark' | 'system' } }) => {
  handleChangeTheme(e.currentTarget.value)
}

<form>
  <fieldset className={s.field}>
    <label htmlFor="system">
      <input
        id="system"
	checked={state === 'system'}
	name="appearance"
	type="radio"
	value="system"
      />
      <span>System</span>
    </label>
    <label htmlFor="light">
      <input
	id="light"
	checked={state === 'light'}
	name="appearance"
	type="radio"
	value="light"
      />
      <span>Light</span>
    </label>
    <label htmlFor="dark">
      <input
	id="dark"
	checked={state === 'dark'}
	name="appearance"
	type="radio"
	value="dark"
      />
      <span>Dark</span>
    </label>
  </fieldset>
</form>
~~~

まとめ

実装内容自体は難しくないのですが、意外と考慮するポイントも多く勉強になる部分もかなりありました。
ライブラリを使えばすぐに実装可能ですが、時には自分で実装するのもいいですね。

Discussion