ツクログネット

【React】サイトにダークモード機能を実装する

eye catch

ページ全体の構成

ページ全体の構成です。

App.js
import React from 'react';
import ToggleSwitchButton from './components/ToggleSwitchButton'

const App = () => {
	return (
		<div>
			<header className="header">
				<div>LOGO</div>
				<ToggleSwitchButton
					className="toggle-switch-button"
					offColor="#ccc"
					onColor="#383896"
					size="1em"
				/>
			</header>
			<div className="container">
				<div>example</div>
			</div>
		</div>
	);
};

export default App

ON/OFFスイッチはヘッダーに表示します。

ON/OFFスイッチについては【React】ON/OFFスイッチを作るをご覧ください。

全体のCSSはstyle.cssに書きます。

style.css
:root {
  --bg-color: #fff;
  --text-color: #333;
  --header-bg-color: #eee;
}

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

.header {
  background-color: var(--header-bg-color);
}

スイッチでダークモードに切り替える

スイッチONでサイトがダークモードに切り替わるようにします。

Appコンポーネント内に、スイッチをONまたはOFFにすると実行されるhandleChange関数を定義後、それをToggleSwitchButtonコンポーネントにpropsとして渡します。

また、handleChange関数内でinput(type="checkbox")要素を使った処理を行うため、useRefでinput要素の参照checkboxElementをApp.js内に定義し、それをpropsとしてToggleSwitchButtonコンポーネントに渡します。

App.jsを以下のように書き換えます。

App.js
// 省略

const App = () => {
  const checkboxElement = useRef(null)

  const handleChange = useCallback(e => {
		const btn = e.target
		const body = document.body

		if(btn.checked === true) {
			body.classList.add("dark-theme")
		}else{
			body.classList.remove("dark-theme")
		}
	})

	return (
		<div>
			<header className="header">
				<div>LOGO</div>
				<ToggleSwitchButton
					className="toggle-switch-button"
					handleChange={handleChange}
					ref={checkboxElement}
					offColor="#ccc"
					onColor="#383896"
					size="1em"
				/>
			</header>
			<div className="container">
				<div>example</div>
			</div>
		</div>
	);
};

export default App

handleChange関数では、スイッチをONにした(チェックボックスにチェックが入った)ときにbody要素にdark-themeクラスを追加して背景色、文字色、ヘッダーの色をダークモード仕様に変化させます。

    if(btn.checked === true) {
			body.classList.add("dark-theme")
		}

dark-themeクラスはstyle.cssに記述します。

style.css
// 省略

body.dark-theme {
  --bg-color:  #333;
  --text-color:  #ddd;
  --header-bg-color:  #222;
}

反対に、スイッチをOFFにした(チェックボックスからチェックが外れた)ときは、body要素からdark-themeクラスを削除します。

    else{
			body.classList.remove("dark-theme")
		}

これで、スイッチをONにするとサイトがダークモードに、OFFにすると通常モードに切り替わるようになりました。

ページ移動後等もダークモードONが維持されるようにする

今のままではダークモードに切り替えたあとに、ブラウザのリロードでページを更新したり、現在のページから離れてしまうと、次にサイトを訪れた際に通常モードにリセットされてしまいます。

これを解決するために、Cookieを利用してON/OFFの状態をブラウザに保存します。

これにより、ダークモードに切り替えたあとにページ移動しても、ダークモードが維持された状態になります。

それではやっていきます。まず、JSでcookieを簡単に操作できるようにするため、下記のコマンドでjs-cookieをインストールします。

yarn add js-cookie

そして、App.jsを以下のように書き換えます。

App.js
// 省略

const App = () => {
  // 省略

  const handleChange = useCallback(e => {
		// 省略

		if(btn.checked === true) {
	    body.classList.add("dark-theme")
      Cookies.set('darkMode', 'on')
	  }else{
	    body.classList.remove("dark-theme")
      Cookies.remove('darkMode')
    }
 })

  useLayoutEffect(() => {
		const darkModeCookie = Cookies.get('darkMode')
		const body = document.body
		const checkbox = checkboxElement.current

		if(darkModeCookie){
	      body.classList.add('dark-theme');
	      checkbox.checked = true
		}
	}, [])

  // 省略
};

変更点は、まずhandleChange関数です。

スイッチをONにしたときに、Cookie.setでdarkModeという名前のCookieがブラウザに保存します。darkModeには'on'という文字列の値を入れました。

    if(btn.checked === true) {
	    body.classList.add("dark-theme")
      Cookies.set('darkMode', 'on')
	  }

反対に、スイッチをOFFにしたときは、Cookies.removeでdarkModeという名前のCookieをブラウザから削除します。

    else{
	    body.classList.remove("dark-theme")
      Cookies.remove('darkMode')
    }

これで、スイッチがON/OFFになる度にCookieが保存・削除されます。

もう一つの変更点は、useLayoutEffectフックを呼び出しているところです。useLayoutEffectフックは、Appコンポーネントがブラウザに表示される前に実行されます(Appコンポーネント内で呼び出しているため)。

呼び出されると、第1引数の関数を実行します。このとき、第2引数に空の配列を渡すと、Appコンポーネントがブラウザに表示される前に一度だけ第1引数の関数が実行されます。

つまり、ブラウザのリロードでページが更新されたときや、ページ移動したときに呼び出されます。

第1引数の関数では、Cookies.getでCookieを取得後、Cookieの有無を確認してCookieが保存されていれば、checkbox.checked = trueでスイッチをONにしたあとに、body要素にdark-themeクラスを追加してダークモード仕様のスタイルを適用します。

    if(darkModeCookie){
	        body.classList.add('dark-theme');
	        checkbox.checked = true
		}

以上で、ブラウザのリロード後やページ移動後もダークモードON/OFFの状態が維持されるようになりました。

OSのダークモードONに合わせてサイトのダークモードをONにする

スイッチの操作でサイトがダークモードに切り替わるようになりましたが、それ以外に、ユーザーがOSのダークモード(Window10でアプリをダークモードにするには、設定の「個人用設定」→「色」→「既定のアプリモードを選択します」でダークを選択します)をONにしたときに、それに合わせて自動的にサイトがダークモードに切り替わるようにします。

これを可能にするには、CSSの@media (prefers-color-scheme: dark) {...}内にダークモード時に適用したいスタイルを記述します。

それでは、style.cssに以下のCSSを追加します。

style.css
/*
省略
*/

@media (prefers-color-scheme: dark) {
  :root {
      --bg-color:  #333;
      --text-color:  #fff;
      --header-bg-color:  #222;
  }
}

/*
省略
*/

これで終わりではありません。

なぜなら、以下の問題点があるからです。

  • サイトのスイッチはOFFのまま
  • OSのダークモードONに合わせてサイトのダークモードがONになったあとに、サイトのスイッチでOFFにしてもサイトの色が戻らない
  • OSのスイッチでONにしたあとにサイトのスイッチでOFFにして通常モード仕様のスタイルに戻した後に、OSのスイッチをOFF→ONにした場合にサイトの色が変わらない
  • サイトのスイッチでONにしたあとにOSのスイッチをON→OFFにしてもサイトの色が通常モード仕様に戻らない点

サイトのスイッチはOFFのまま

OSのスイッチONでサイトの色はダークモード仕様に切り替わるようになりましたが、サイトのスイッチはOFFのまま動いてくれません。

CSSではスイッチをONの方向へ動かす(チェックを入れる)ことはできないため、JSで動かす必要があります。

その方法はCSSと同様に、JSでprefers-color-scheme: darkでOSのスイッチON/OFFを検知して、ONであればサイトのスイッチをONにします。

App.jsに以下のコードを追加します。

App.js
const App = () => {
    // 省略

    useEffect(() => {
		    const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
		    const darkModeOn = darkModeMediaQuery.matches
		    const body = document.body
		    const checkbox = checkboxElement.current

		    darkModeMediaQuery.addListener((e) => {
		        const darkModeOn = e.matches;
		        if (darkModeOn) {
				        Cookies.set('darkMode', 'on')
		            checkbox.checked = true
		        } else {
				        Cookies.remove('darkMode')
		            checkbox.checked = false
		        }
		    });
	}, [])

  return (
  // 省略
  )
}

JSでprefers-color-scheme: darkを使って検知するには、window.matchMedia('(prefers-color-scheme: dark)')を使います。

checkbox.checked = trueにすると、ToggleSwitchButtonコンポーネントのCSSで定義した

& input {
		/*省略*/

		&:checked + label {
			/*省略*/

			&::before {
				left: 2em;
			}
		}
	}

の部分が適用されてスイッチがONの方向へ移動します。

また、この状態でページを離れると、次に訪れたときに状態がリセットされてしまうため、ここでもCookieを保存します。

if (darkModeOn) {
  Cookies.set('darkMode', 'on')
  checkbox.checked = true
}

OSのスイッチをOFFにした場合はサイトのスイッチをOFFにしてCookieを削除します。

else {
  Cookies.remove('darkMode')
  checkbox.checked = false
}

これでOSのダークモードONに合わせてサイトのダークモードがONに切り替わるようになりました。

OSのスイッチONに合わせてサイトのスイッチがONになったあとにサイトのスイッチでOFFにしてもサイトの色が戻らない

サイトのスイッチのON/OFFでは、CSSの@media (prefers-color-scheme: dark) {...}は適用・解除されないからです。

適用・解除されるのはOSのスイッチでON/OFFにしたときだけです。

そういった理由から、今のままではOSのスイッチでOFFにしない限り、サイトを通常モード仕様のスタイルには戻せないのです。

この問題を解決するには、サイトのスイッチがOFFになったタイミングでbody要素に通常モード仕様のスタイルを持つクラスを追加します。

それではまず、style.cssにそのクラスを定義します。クラス名はlight-themeとします。

style.css
/*
省略
*/

body.light-theme {
  --bg-color: #fff;
  --text-color: #333;
  --header-bg-color: #eee;
}

そして、サイトのスイッチをOFFにしたタイミングでbody要素にlight-themeクラスを追加します。

handleChange関数を以下のように書き換えます。

App.js
const handleChange = useCallback(e => {
		const btn = e.target
		const body = document.body

		if(btn.checked === true) {
			// 省略
		}else{
			body.classList.remove("dark-theme")
			body.classList.add("light-theme")
			Cookies.remove('darkMode')
		}
	})

しかし、これではまだ不十分です。ここからサイトのスイッチでONにしたときにhandleChange関数によってdark-themeが追加されますが、先ほどのlight-themeクラスも追加されているため、light-themeクラスのスタイルが優先されてしまい、ダークモード仕様に変化してくれません。

なので、サイトのスイッチでONにしたタイミングでbody要素からlight-themeクラスを削除します。

handleChange関数を以下のように書き換えます。

App.js
const handleChange = useCallback(e => {
		const btn = e.target
		const body = document.body

		if(btn.checked === true) {
			if(btn.checked === true) {
			  body.classList.remove("light-theme")
			  body.classList.add("dark-theme")

			  Cookies.set('darkMode', 'on')
		  }else{
			  // 省略
		  }
    }
	})

これで、OSのスイッチでONにしたあとにサイトのスイッチでOFFにしたときに通常モード仕様のスタイルに戻るようになりました。

サイトのスイッチでOFFにしたあとにOSのスイッチをOFF→ONにしてもサイトの色がダークモード仕様に変わらない

OSのスイッチでONにしたあとにサイトのスイッチでOFFにした場合に通常モード仕様のスタイルに戻るようにはなりましたが、その後にOSのスイッチをOFF→ONにした場合にサイトの色がダークモード仕様に切り替わらないという問題が起こります。これはサイトのスイッチでOFFにしたときにlight-themeクラスが追加されたため、CSSで指定した@media (prefers-color-scheme: dark) {...}のスタイルが効かないためです。

なので、OSのスイッチがOFF→ONになったタイミングでbody要素からlight-themeクラスを削除します。

App.jsの以下の箇所を書き換えます。

App.js
const App = () => {
    // 省略

useEffect(() => {
		// 省略

		darkModeMediaQuery.addListener((e) => {
		    const darkModeOn = e.matches;
		    if (darkModeOn) {
		        body.classList.remove('light-theme');
		        checkbox.checked = true
		    } else {
		       // 省略
		    }
		});
	}, [])

  return (
    // 省略
  )
}

これでこの問題は解決しました。

サイトのスイッチでONにしたあとにOSのスイッチをON→OFFにしてもサイトの色が通常モード仕様に戻らない点

OSのスイッチがON→OFFになったタイミングでbody要素からdark-themeクラスを削除します。

App.jsの以下の箇所を書き換えます。

App.js
const App = () => {
    // 省略

useEffect(() => {
		// 省略

		darkModeMediaQuery.addListener((e) => {
		    const darkModeOn = e.matches;
		    if (darkModeOn) {
		        // 省略
		    } else {
		       body.classList.remove('dark-theme');
				    Cookies.remove('darkMode')
		        checkbox.checked = false
		    }
		});
	}, [])

  return (
    // 省略
  )
}

これでこの問題は解決しました。

カスタムフックを作成する

サイトにダークモード機能を実装することはできましたが、Appコンポーネント内にhandleChange関数やuseEffectフック等を書いたことで、Appコンポーネントが肥大化してしまいました。

こんなんではコードの見通しが悪いため、Appコンポーネントからロジックを切り離し、それをカスタムフックとして関数でまとめたあとにそれをAppコンポーネント内で呼び出すようにします。

カスタムフックとは、簡単にいうとコンポーネントのロジックをほかのコンポーネントでも再利用できるように関数にしたものです。また今回のように、ただコンポーネントを見やすくするためだけに使われることもあります。

それでは、useDarkModeButton.jsを作成し、そこにuseDarkModeButtonという名前のカスタムフックを定義します。

useDarkModeButton.js
import { 
	useCallback,
	useRef,
	useEffect,
	useLayoutEffect,
} from 'react'

const useDarkModeButton = () => {
	const checkboxElement = useRef(null)

	const handleChange = useCallback(e => {
		const btn = e.target
		const body = document.body

		if(btn.checked === true) {
			body.classList.remove("light-theme")
			body.classList.add("dark-theme")

			Cookies.set('darkMode', 'on')
		}else{
			body.classList.remove("dark-theme")
			body.classList.add("light-theme")

			Cookies.remove('darkMode')
		}
	})

	useLayoutEffect(() => {
		const darkModeCookie = Cookies.get('darkMode')
		const body = document.body
		const checkbox = checkboxElement.current

		if(darkModeCookie){
			//body.classList.remove('light-theme');
	        body.classList.add('dark-theme');
	        checkbox.checked = true
		}else{
			//body.classList.remove('dark-theme');
	        //body.classList.add('light-theme');
	        //checkbox.checked = false
		}
	}, [])

	useEffect(() => {
		const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
		const darkModeOn = darkModeMediaQuery.matches
		const body = document.body
		const checkbox = checkboxElement.current

		darkModeMediaQuery.addListener((e) => {
		    const darkModeOn = e.matches;
		    if (darkModeOn) {
		        body.classList.remove('light-theme');
		        //body.classList.add('dark-theme');
				Cookies.set('darkMode', 'on')
		        checkbox.checked = true
		    } else {
		        body.classList.remove('dark-theme');
		        //body.classList.add('light-theme');
				Cookies.remove('darkMode')
		        checkbox.checked = false

		    }
		});
	}, [])

	return [
		checkboxElement,
		handleChange	
	]
}

export default useDarkModeButton

そして、Appコンポーネント内でuseDarkModeButtonフックを呼び出します。

App.js
const App = () => {
   const [
     checkboxElement,
    handleChangeDarkMode
  ] = useDarkModeButton()

  return (
    <div>
      <header className="header">
        <div>LOGO</div>
        <ToggleSwitchButton
          className="toggle-switch-button"
          handleChange={handleChangeDarkMode}
          ref={checkboxElement}
          offColor="#ccc"
          onColor="#383896"
          size="1em"
        />
      </header>
      <div className="container">
        <div>example</div>
      </div>
    </div>
  );
};

これでAppコンポーネントの見通しが良くなりました。

以上です。

Demo

下記で実際の動作を確認できます。