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(--bg-color-inverted);
  color: var(--fg-color-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 {
  --light: initial;
  --dark: ;
  color-scheme: light 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を併用することでlight-dark()も機能させられる。

そして、prefers-color-schemeを使わずに値を固定することで、常にライトモードもしくはダークモードに相当する状態も表現できる。

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

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

:root[data-theme='light'] {
  --light: initial;
  --dark: ;
  color-scheme: light;
}

:root[data-theme='dark'] {
  --light: ;
  --dark: initial;
  color-scheme: dark;
}

button {
  background: var(--light, #aaa) var(--dark, #444);
}

これらの実装はLightning CSSから着想を得た


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

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>

参考資料