Space Toggleハックを使ってcolor-schemeに応じた値の切り替えを実現する

ライトモードとダークモードに対応したCSSを作成するとき、最初に考えられるのはprefers-color-schemeを使って宣言を上書きする方法だろう。

:root {
  color-scheme: light dark;
}

button {
  background-color: black;
  color: white;

  @media (prefers-color-scheme: dark) {
    background-color: white;
    color: black;
  }
}

しかしプロパティの値だけを切り替えたいのであれば、宣言を重複して記述するのは冗長だ。このような場合はlight-dark()を使うと簡潔になる。

button {
  background-color: light-dark(black, white);
  color: light-dark(white, black);
}

これによって、値を変数化して使い回すこともできるようになる。

:root {
  --fg-color-inverted: light-dark(white, black);
  --bg-color-inverted: light-dark(black, white);
}

button {
  background-color: var(--color-bg-inverted);
  color: var(--color-fg-inverted);
}

ただし、light-dark()で切り替えられるのは色だけだ。たとえばfont-weightの値を切り替えたいとしても対応していない。そこで最近、新たなCSS仕様としてcolor-scheme()が追加された。次のようにif()と組み合わせて使う。

#element {
  font-weight: if(color-scheme(dark): 500; else: 300);
}

とはいえ、color-scheme()はまだ実装が存在せず、if()はいくつかあるものの十分ではない。

代替案として、現状ではSpace Toggleハックを使うと同様のことが実現できる。Space Toggleハックとは、--foo: ;のようにカスタムプロパティの値として空白文字が有効であるという仕様を用いて、条件に応じた値の切り替えをする手法だ。たとえば次のようになる。

--toggler: ;
--red-if-toggler: var(--toggler) red;
background: var(--red-if-toggler, green); /* will be red! */
--toggler: initial;
--red-if-toggler: var(--toggler) red;
background: var(--red-if-toggler, green); /* will be green! */

--toggler--red-if-togglerに含めることで、--toggler: ;の場合はredが空白文字とともに出力され、initialの場合は無効なプロパティとして評価されてフォールバック値が優先される仕組みになっている(コード例はpropjockey/css-sweeperより)。値の周囲に空白文字があっても実際の動作には影響せず無視されるというCSSの仕様によって成り立つ。

これを応用して、次のようにprefers-color-schemeによって--togglerに相当する値を切り替えることで、light-dark()と同様の仕組みを実現できる。

:root {
  color-scheme: light dark;
  --light: initial;
  --dark: ;

  @media (prefers-color-scheme: dark) {
    --light: ;
    --dark: initial;
  }
}

#element {
  background-color: var(--color-bg-inverted);
  color: light-dark(white, black);
  font-weight: var(--light, 500) var(--dark, 300);
}

デフォルトでは--lightが無効なので500が採用され、--darkは有効なので単なる空白文字になる。color-scheme: light dark;も併用することで、ブラウザのUIを変更したりlight-dark()を機能させたりできる。

そして、prefers-color-schemeを使わずに値を固定することで、常にライトモードもしくはダークモードに相当する状態も表現できる。ユーティリティクラスにするなら次のように。

.scheme-light {
  color-scheme: light;
  --light: initial;
  --dark: ;
}

.scheme-dark {
  color-scheme: dark;
  --light: ;
  --dark: initial;
}

.scheme-light-dark {
  color-scheme: light dark;
  --light: initial;
  --dark: ;

  @media (prefers-color-scheme: dark) {
    --light: ;
    --dark: initial;
  }
}
<html class="scheme-light-dark">
  <!-- システム設定に応じて切り替わる -->

  <div class="scheme-light">
    <!-- 常にライトスキーム -->
  </div>

  <div class="scheme-dark">
    <!-- 常にダークスキーム -->
  </div>
</html>

これらの実装はReact Spectrumから着想を得た

さらに言えば、カラースキーム以外のテーマを切り替える使い方もできる。たとえば、設定に応じて余白の大きさが変化する設計を考える。

NameBaseCompactComfortable
050214
100428
1506410

data-*属性を使って切り替えるとすれば、次のように実装できる。

:root {
  --is-size-base: initial;
  --is-size-compact: ;
  --is-size-comfortable: ;

  &[data-size='compact'] {
    --is-size-base: ;
    --is-size-compact: initial;
    --is-size-comfortable: ;
  }

  &[data-size='comfortable'] {
    --is-size-base: ;
    --is-size-compact: ;
    --is-size-comfortable: initial;
  }
}

:root {
  --space-050: var(--is-size-base, 2px) var(--is-size-compact, 1px) var(--is-size-comfortable, 4px);
  --space-100: var(--is-size-base, 4px) var(--is-size-compact, 2px) var(--is-size-comfortable, 8px);
  --space-150: var(--is-size-base, 6px) var(--is-size-compact, 4px) var(--is-size-comfortable, 10px);
}
<html data-size="comfortable">
  <!-- ... -->
</div>

参考資料