【Vue.js】ダークモード切り替えの実装|ユーザーのテーマに合わせた表示対応

【Vue.js】ダークモード切り替えの実装|ユーザーのテーマに合わせた表示対応 Vue.js

ダークモードは、ボタンで切り替えるだけなら数行で作れます。ただし、そのまま公開すると読み込みの瞬間に白い画面が光る、OSの設定を変えても追従しない、といった細かな不満が出ます。この記事では、Vue 3で「ライト」「ダーク」「OSに合わせる」の3択を持ち、ちらつきも抑えた実装を解説します。

方針は4つです。①色をCSS変数にして、html要素のdarkクラスで値を切り替える、②状態はcomposableに集約する、③選択をlocalStorageに保存する、④Vueが起動する前に、保存済みのテーマを適用してちらつきを防ぐ。

スポンサーリンク

CSSをテーマごとに用意する

色を直接書かずにCSS変数にしておくと、テーマの追加や色の調整が1か所で済みます。:root.darkのときだけ、変数の値を差し替えます。

CSS:テーマごとの色を変数にする
/* 色は変数にして、テーマごとに値だけ差し替える */
:root {
  --bg: #ffffff;
  --text: #1f2937;
  --border: #d1d5db;
}

:root.dark {
  --bg: #111827;
  --text: #f3f4f6;
  --border: #374151;
}

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

状態をcomposableにまとめる

初期化と切り替えを別々のコンポーネントに書くと、状態がずれやすくなります。テーマの状態、保存、OS設定の監視を1つのファイルに集めます。

JavaScript:useTheme.js
// composables/useTheme.js
import { ref, computed, watch, watchEffect } from 'vue'

const KEY = 'theme'
const MODES = ['light', 'dark', 'system']

function readStored() {
  try {
    const v = localStorage.getItem(KEY)
    return MODES.includes(v) ? v : 'system'
  } catch {
    return 'system' // プライベートモードなどでlocalStorageが使えない場合
  }
}

const mode = ref(readStored())

// OSの設定を監視する(OS側で切り替わったら追従する)
const mql = window.matchMedia('(prefers-color-scheme: dark)')
const systemDark = ref(mql.matches)
mql.addEventListener('change', (e) => {
  systemDark.value = e.matches
})

const isDark = computed(
  () => mode.value === 'dark' || (mode.value === 'system' && systemDark.value)
)

// html要素に反映する(他のクラスは消さない)
watchEffect(() => {
  const root = document.documentElement
  root.classList.toggle('dark', isDark.value)
  root.style.colorScheme = isDark.value ? 'dark' : 'light'
})

// 選択を保存する
watch(mode, (v) => {
  try {
    localStorage.setItem(KEY, v)
  } catch {}
})

export function useTheme() {
  return { mode, isDark }
}

ポイントは次のとおりです。

  • modeがsystemのときは、matchMediaの結果に従います。OSの設定が変わると、changeイベントで自動的に追従します。
  • html要素にdarkクラスを付けるとともに、color-schemeも設定しています。これで、スクロールバーやフォーム部品も、ダーク用の見た目になります。
  • localStorageは、プライベートモードなどで例外になることがあるため、try/catchで囲んでいます。
  • この書き方は、モジュールの読み込み時にwindowを使います。Nuxtなどのサーバーサイドで動かす場合は、クライアント側でだけ実行するようにしてください。

composableの考え方はComposable関数でロジックを共通化する方法で詳しく解説しています。

切り替えUIを作る

3択なので、トグルボタンではなくラジオボタンにしています。どのコンポーネントからでも、useTheme()を呼ぶだけで同じ状態を扱えます。

Vue:ThemeSwitcher.vue
<script setup>
import { useTheme } from '@/composables/useTheme'

const { mode } = useTheme()
const options = [
  { value: 'light', label: 'ライト' },
  { value: 'dark', label: 'ダーク' },
  { value: 'system', label: 'OSに合わせる' }
]
</script>

<template>
  <div role="radiogroup" aria-label="テーマ">
    <label v-for="o in options" :key="o.value">
      <input type="radio" v-model="mode" :value="o.value" />
      {{ o.label }}
    </label>
  </div>
</template>

v-modelの仕組みはv-modelの仕組みを完全理解をご覧ください。

読み込み時のちらつきを防ぐ

テーマをonMountedで適用すると、Vueが起動するまでの間、ライトモードで表示されます。ダークを選んでいる人には、一瞬だけ白い画面が光って見えます。これを防ぐには、index.htmlの<head>に、保存済みのテーマを先に適用する小さなスクリプトを置きます。

HTML:index.html
<!-- index.html の <head> 内。Vueが起動する前に実行する -->
<script>
  (function () {
    try {
      var t = localStorage.getItem('theme')
      var dark = t === 'dark' ||
        ((t === null || t === 'system') &&
          window.matchMedia('(prefers-color-scheme: dark)').matches)
      if (dark) document.documentElement.classList.add('dark')
    } catch (e) {}
  })()
</script>

ここでは、Vueの外で動くため、composableは使えません。判定の中身は、useTheme.jsと同じにしておきます。

やってはいけない書き方

旧来の解説でよく見かけるのが、document.body.className = 'dark'のように、クラスを丸ごと代入する書き方です。bodyに付いている他のクラスまで消えてしまいます。

JavaScript:クラスの付け外し
// NG: body の他のクラスがすべて消える
document.body.className = 'dark'

// OK: darkクラスだけを付け外しする
document.documentElement.classList.toggle('dark', true)

Tailwind CSSを使っている場合

Tailwindのdark:は、標準ではOSの設定に従います。クラスで切り替えるには、設定が必要で、バージョンによって書き方が違います。

Tailwind:クラスでdarkを切り替える設定
/* Tailwind v3:tailwind.config.js */
module.exports = {
  darkMode: 'class'
}

/* Tailwind v4:CSSファイルの中で指定する */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

どちらも、html要素にdarkクラスが付いたときにdark:が有効になります。上のcomposableと、そのまま組み合わせられます。

ダークモードで見落としやすい点

  • 画像やロゴ:白背景を前提にした画像は、ダークで浮いて見えます。透過PNGやSVGのcurrentColorを使うと対応しやすくなります。
  • コントラスト:純粋な黒(#000)と白(#fff)の組み合わせは、まぶしく感じやすくなります。やや柔らかい色にすると読みやすくなります。
  • 外部のウィジェット:埋め込みの地図やコメント欄などは、別途テーマの指定が必要な場合があります。

クラスの出し分けは動的クラスとスタイルを切り替える方法、保存の考え方はローカルストレージを使った状態管理とPinia × LocalStorageで永続的な状態管理を行う方法もあわせてご覧ください。

よくある質問(FAQ)

Qページを開いた瞬間に、一瞬だけ白い画面が出ます。
Aテーマの適用がVueの起動後になっているためです。index.htmlの<head>にインラインスクリプトを置き、描画前にdarkクラスを付けてください。
QOSのダークモードを切り替えても、サイトが追従しません。
A保存済みの値がlightかdarkだと、手動の選択が優先されます。「OSに合わせる」を選んでいる場合だけ、matchMediaのchangeイベントで追従します。
Qスクロールバーやinput要素だけ、ライトのままです。
Acolor-schemeが指定されていないためです。html要素にcolor-scheme: darkを設定すると、ブラウザ標準の部品もダーク用になります。
QCSSだけで、JavaScriptなしに対応できますか?
AOSの設定に合わせるだけなら、@media (prefers-color-scheme: dark)で対応できます。ユーザーが手動で切り替えられるようにするには、クラスなどの状態と、その保存が必要です。

まとめ

ダークモードの実装は、色をCSS変数にして、html要素のdarkクラスで切り替えるのが基本です。そのうえで、「OSに合わせる」を含む3択にし、OS設定の変更への追従、保存、index.htmlでの先行適用を入れると、使い心地が整います。color-schemeの指定も忘れずに行ってください。