Nuxt(SSG) + Vuetifyのサイトで動的にカラーモードを切り替える仕組みを付けた

..

昨年4月、QiitaをCMSとする技術ブログ nawamemo.dev をリリースしました。

当初からカラーモードの切り替えを行えるようにしており、先日からそれに加えてちょこちょことした工夫も加えてみたので、それら全体の実装についてまとめます。

ダーク・ライトそれぞれの色指定を追加

nuxt.config.js
vuetify: {
  customVariables: ['~/assets/variables.scss'],
  treeShake: true,
  theme: {
    options: {
      customProperties: true
    },
    dark: true,
    themes: {
      dark: {
        font: '#F5F5F5',
        background: `#322446`,
        primary: `#6C8CB3`,
        accent: `#353B51`,
        secondary: `#9D7D66`,
        info: `#56A597`,
        warning: `#B6B480`,
        error: `#AB7878`,
        success: `#539164`
      },
      light: {
        font: '#473838',
        background: `#EEEEEE`,
        primary: `#7399C5`,
        accent: `#5C5A5A`,
        secondary: `#EBDABF`,
        info: `#BDE4DB`,
        warning: `#EBEAC3`,
        error: `#EACDC3`,
        success: `#C0ECCA`
      }
    }
  },
  defaultAssets: {
    font: false
  }
},

設定はこのようになっており、文字色と背景色というプロパティを追加しています。デザイナーではないのでそこまでガチってはいないですが、Figmaで背景色を決めたあと、それに合うように各マテリアルカラーの色味を調整して設定しました。
スクリーンショット 2023-02-10 14.15.36.png

右側にある四角は、コードの背景色です。今はもう変えてしまっていますが…

カラーモードの切り替えスイッチ

ヘッダー右にあるトグルスイッチを使うと、カラーモードが切り替わります。

スクリーンショット 2023-02-10 14.19.12.png
スクリーンショット 2023-02-10 14.29.02.png
ライトモードは「nawamemo」というタイトルであることから紙っぽさをイメージして、白っぽいグレーを背景色に、黒っぽいグレーを文字色にして少し曖昧な感じにしました。Notionのランディングページみたいになっちゃいましたかね。

デフォルトはダークモードになっているので、切り替えるとライトモード、戻すとダークモードになります。

このスイッチの実装ですが、まずUIの実装はこんな感じ。

<template>
  <div id="switch" class="d-flex align-center">
    <div id="frame" 
			:class="{ 'd-flex frame--on': dark }" 
			@click="$emit('toggleTheme')"
		>
      <div />
    </div>
  </div>
</template>

<script>
export default {
  props: {
    dark: Boolean
  }
}
</script>

<style lang="scss" scoped>
  #switch {
    height: 100%;

    #frame {
      width: 60px;
      height: 30px;
      border: 1px solid var(--v-font-base);
      border-radius: 50px;

      div {
        height: 100%;
        width: 50%;
        background: var(--v-font-base);
        border-radius: 50%;
        border: 4px solid var(--v-background-base);
      }
    }

    .frame--on {
      justify-content: flex-end;
    }
  }
</style>

呼び出し側であるdefault.vueではこのように使います。

default.vue
<template>
  <v-app :style="{background: background}">
    <LayoutsNavigationDrawer :mode="mode" v-model="drawer" />

    <LayoutsHeaderBody :mode="mode">
      <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <LayoutsHeaderTitle />
      <LayoutsHeaderToggleThemeSwitch :dark="this.$vuetify.theme.dark" @toggleTheme="toggleTheme" />
    </LayoutsHeaderBody>

    <v-main>
      <Nuxt />
    </v-main>

    <LayoutsFooter :mode="mode" />

  </v-app>
</template>

<script>
export default {
  data () {
    return {
      drawer: false,
    }
  },
  computed: {
    mode() {
      return (this.$vuetify.theme.dark === true) ? "dark": "light";
    },
    background() {
      return this.$vuetify.theme.themes[this.mode].background
    }
  },
  methods: {
    toggleTheme(event) {
      this.$vuetify.theme.dark = (this.$vuetify.theme.dark = !this.$vuetify.theme.dark);
    }
  }
}
</script>

propsには、$vuetifyオブジェクトから設定値を渡しています。スイッチから$emitされたイベントはその設定値を逆の値に書き換えるという動作を行い、それによって色指定がごっそりダークかライトのいずれかに切り替わります。

最親であるv-appにスタイル属性で背景色を渡しているため、この操作によってサイト全体の背景色が変わるようになっています。

meta[theme-color]も変えたい

SafariやAndroid版Chromeなどでは、タブバーをサイトのmetaタグで設定されている色に変えられたりします。(正確には端末の設定がダークモードだと、白系や暖色系のカラーは適用されませんが)
先程の実装に併せて、これも一緒に書き換えてあげることにします。

default.vue
<template>
  <v-app :style="{background: background}">
    <LayoutsNavigationDrawer :mode="mode" v-model="drawer" />

    <LayoutsHeaderBody :mode="mode">
      <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <LayoutsHeaderTitle />
      <LayoutsHeaderToggleThemeSwitch :dark="this.$vuetify.theme.dark" @toggleTheme="toggleTheme" />
    </LayoutsHeaderBody>

    <v-main>
      <Nuxt />
    </v-main>

    <LayoutsFooter :mode="mode" />

  </v-app>
</template>

<script>
export default {
+  head() {
+    return {
+      meta: [
+        { name: 'theme-color', content: this.background }
+      ]
+    }
+  },
  data () {
    return {
      drawer: false,
    }
  },
  computed: {
    mode() {
      return (this.$vuetify.theme.dark === true) ? "dark": "light";
    },
    background() {
      return this.$vuetify.theme.themes[this.mode].background
    }
  },
  methods: {
    toggleTheme(event) {
      this.$vuetify.theme.dark = (this.$vuetify.theme.dark = !this.$vuetify.theme.dark);
+      return this.toggleThemeColor()
    },
+    toggleThemeColor() {
+      const theme_color = document.querySelector('meta[name="theme-color"]')
+      theme_color.setAttribute('content', this.background)
+    }
  }
}
</script>

なおdocument.querySelector('meta[name="theme-color"]')は初期値がないとnullを返すので、今回は設定ファイルにてダークテーマ側の色を指定しました。

nuxt.config.js
head: {
  meta: [
    { name: 'theme-color', content: '#322446' }
  ]
}

::selectionも変えたい

本来あまり意識する必要はないと思うのですが、今回の場合は単色主体のなかなか味気ないデザインであることや、個人的な感覚として、テキストを選択した際にデフォルトの背景色が見えてしまうとサイトのデザインから浮いてしまっててちょっと嫌だったことから、「差し色的な機能」と「デザインとしての調和」を図るため、::selection疑似要素の背景色もいじることにしました。

なおスマホ版ブラウザでは軒並み非対応なので、効果の程は微妙です。まあエンジニアブログならPCで見る人が多いからマシでしょう。

div{
  &.theme--dark {
    --color-selection-rgb: 164, 93, 128;
  }
  &.theme--light {
    --color-selection-rgb: 232, 230, 186;
  }
}
::selection {
  background-color: rgba(var(--color-selection-rgb), 0.99);
}

::-moz-selection { /** firefox用 **/ 
  background-color: rgba(var(--color-selection-rgb), 0.99);
}

Nuxt製のサイトがレンダリングされると、VueインスタンスがアタッチされているDIVに対してtheme--darktheme--lightいずれかのクラス属性が当たります。

<div data-app="true" id="app" class="v-application v-application--is-ltr theme--dark" style="background: rgb(50, 36, 70);">
~~中略~~
</div>

それらクラスに対して、ダークモード・ライトモードそれぞれの色をCSSで記述し、その値を格納したCSS変数をselection疑似要素に対して指定しています。

Webkit系ブラウザ(あるいは今はSafariだけ?)では勝手に不透明度を下げられて背景色が透けてしまい、ブラウザごとに大きな見た目の違いが生じてしまいます。その対策として、RGBで指定した上で透明度0.99で渡す必要がありました。

こうした事情から、selection疑似要素の背景色についてはvuetifyテーマの一部として扱うことができません。Hex表記からRGB表記に変換して、動的にCSSを書き換えて…のようなことをすればあるいは可能かもしれませんけど。

おまけ

今回はダークモードを標準としたためやりませんでしたが、もしさらに親切に「サイト訪問者の設定に合わせる」という仕様にする場合は、おそらくdefault.vuemounted等でVuetifyの設定値を動的に設定してあげるとよいのではないかと思います。

this.$vuetify.theme.dark = (window.matchMedia('(prefers-color-scheme: dark)').matches === true) ?
	true:
	false

window.matchMedia('(prefers-color-scheme: dark)').matchesで端末のカラーモードを参照できるので、それに対応してサイトのカラーモードを決定すればクライアントに合わせることができます。