izumin521t0

izum.in

@izumin5210 - Software Engineer

CSS variables によるダークモード対応

タイトルの通り、このサイトをダークモード対応した。 このブログは React と CSS-in-JS ライブラリである Linaria で実装されているが、 結果として JS 側で特別なことをせず、ほとんど CSS だけでダークモード対応を実現している。

実装

実装は大きく3ステップ。

  1. システム設定に合わせた Light / Dark 出し分け
  2. ユーザによる Light / Dark 切り替えサポート
  3. ユーザによる Light / Dark 切り替えの永続化

最初のステップだけだと prefers-color-scheme を見て色を出し分けるだけでいいので単純だが、それ以降はそんなに自明ではない。

やりたいのは「Light 用と Dark 用の2つの色定義セットを用意して、それを何らかの手段で切り替える」ということ。 この切り替え動作をどう実現するかが大きな設計判断の1つとなる。

たとえば styed-components など気の利いたライブラリは Theming 機能を持っている。React Context に現在の theme を保持し、各コンポーネントで値を取り出し CSS に埋め込むという手法。

ただ、今回はスタイルを動的に変化させている箇所はほぼないため、最もシンプルに CSS Variables (Custom Properties) でダークモードを実現することにした。

1. システム設定に合わせた Light / Dark 出し分け

前述の3ステップのうち、最初のステップは Media Query による分岐で CSS Variables の値を出し分けることで実現できる。

:root {
  --text: #000000;
  /* ... */
}

@media (preferes-color-scheme: dark) {
  :root {
    --text: #ffffff
    /* ... */
  }
}

PR: Support dark mode by izumin5210 · Pull Request #224 · izumin5210/portfolio-webapp

2. ユーザによる Light / Dark 切り替えサポート

これを CSS Variables で実現するには :root で定義した値を書き換える手段が必要になる。 今回はすごく単純に、「<body> に特定のクラスが付与されているときに Light / Dark が @media による分岐より優先される」という実装とした。

body.light {
  --text: #000000;
  /* ... */
}

body.dark {
  --text: #ffffff
  /* ... */
}

CSS Variables は Custom Properties というだけに、他のプロパティと同じくカスケードと継承いずれも対象であり、これでうまく動く。 あとは切り替えボタンを配置して、それのクリックイベントで .light / .dark のつけ外しを実装すればいい。

PR: Add button to switch between dark and light modes by izumin5210 · Pull Request #225 · izumin5210/portfolio-webapp

3. ユーザによる Light / Dark 切り替えの永続化

これは単純に localStorage とかに入れておけば良い、と見せかけて1つだけ罠がある。 何も考えずに React アプリの Render もしくは Hydration で localStorage を読み出してしまうと、設定前のテーマが一瞬見えてしまうことになる。 たとえばシステム設定が Light でユーザ設定が Dark のとき、一瞬 Light が表示されてすぐ Dark に変化するという挙動になり、目がチカチカする。 なので、ユーザ設定の読み出し・適用は何よりも優先する必要がある。

この実現はいろいろやり方があるが、render-blocking になる script で値を読み出し <body> にクラス適用してしまうのが手っ取り早い。

<script>
(function() {
  try {
    var theme = localStorage.getItem("theme");
    if (theme != null) {
      document.body.classList.add(theme);
    }
  } catch (_e) { }
})();
</script>

これを <body> の先頭とかにおいておけば、他の要素は描画される時点でユーザ設定が反映されるためチラつきはなくなる。 Hydration 時に React 側の State に反映しておくのを忘れないように。

ほんまにこんなことしてええんか?という気持ちにもなるけど、Overreacted とかもそうなってるので問題ないでしょう(権威を盲信するのは良くないけど)。

PR: Persist theme preference to localStorage by izumin5210 · Pull Request #226 · izumin5210/portfolio-webapp

感想

このサイトの CSS は「機能で劣る CSS-in-JS の実装で、どこまで表現力の高いデザインシステム実装を作れるか」というのを一つのテーマに置いている。

CSS-in-JS ライブラリを評価する観点はいろいろあるが、何かしらがトレードオフになるため全てにおいて完璧なライブラリは今のところ存在しない(A thorough analysis of CSS-in-TS などが参考になる)。 特に機能とパフォーマンスに関しては両立が難しいらしい。例えば styled-components や Emotion などは機能が多い代わりにパフォーマンスが多少犠牲になる(書いててよくわかんなくなってきたけど、ここでいう "機能" は「React 世界から JS 世界へ干渉する際の柔軟さ」くらいのことを言ってる)。

なので、「機能が少ない代わりにパフォーマンスが出るライブラリで、実際どこまで表現できるのか」は技術選定のポイントになりうる。

で、今回のダークモード対応。ほぼ JS を使わず CSS でできるというのはいい話だなと思った。「Theming 機能がほしいから高機能な CSS-in-JS が使いたいです!」と言えない。 まあ CSS Variables は Autocomplete が効きづらいみたいなデメリットもあるので微妙なラインではあるが…。

今回だと JS 側から余白とか色を操作したいケースが無かったので困らなかったが、よりフクザツなアプリでも耐えうるのかは興味ある。

ちなみに、一番時間かかったのはそれっぽい色を決めるところです。