Next.jsにおけるダークモード対応へのアプローチ
個人サイトをNext.jsで作成してダークモード対応をした時のことをまとめました。
Next.jsでは https://github.com/pacocoursey/next-themes といったライブラリを利用すれば、比較的簡単にダークモード対応は可能かと思いますが、今回はライブラリの使用なしで実装しました。
※ App Dir
を使用しています。
color-schemeを定義
ダークモードの色を設定する前に color-scheme
の定義をして、レンダリングの配色を指定します。
light
と dark
の両方のモードをサポートするように root
に設定します。
デフォルトは normal
となっておりブラウザの既定の配色でレンダリングすることになっています。
color-scheme css
:root {
color-scheme: light dark;
}
参考
- https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme
- https://web.dev/patterns/theming/color-schemes#css
テーマ切り替えを実装
テーマの変更を可能にする
テーマの切り替えを可能にするための処理を追加します。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
を利用してテーマを保存して変更する仕組みを追加します。
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: ~~~
のような警告が出た場合はルートの layout
の html
に suppressHydrationWarning を追加すると無視することができます。
これはSSRの場合サーバーとクライアント側で異なる内容をレンダリングしてしまうことで発生するエラーになります。 suppressHydrationWarning
を追加することで、単一レベルの深さの警告を抑制することができます。
OSのテーマ更新を検知
ページを開いているときにシステムのテーマを変更した場合にページテーマが変わるようにします。
これは Window: matchMedia() を使用することで実現可能です。 tailwindcssの実装例を参考にしているのですが、今回は system
のページ内でシステムのテーマを選択可能にしているため、一部条件を変更して実装しています。
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 に追加します。
// 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
で行なっていた画面の初期表示時のテーマ切り替えは必要ないため削除します。
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も用意していきます。
'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の一例
~~~
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