僕がネイティブな CSS 変数にわくわくする理由

フィリップ・ウォールトン

鈴木丈

、CSS 変数 (より正確には CSS カスタム・プロパティ) が、Chrome Canary の「試験運用版のウェブ プラットフォームの機能」フラグ1を有効にすることで利用できるようになりました。

Chrome のエンジニアであるアディ・オスマーニがそのリリースについてはじめてツイートしたとき、多くの否定的で、敵対的で、懐疑的な声が寄せられました。その量は驚くべきものでした。少なくとも、CSS 変数にわくわくしいてる僕にとっては。

これらの反応をざっと見渡したところ、苦情の 99% は次の 2 つの点についてのものだとわかりました。

もちろん僕もあの構文は好きじゃありません。それは認めます。でも、あれがいい加減に決定されたものではないってことは理解しておくべきです。CSS ワーキング・グループのメンバーは、CSS の文法と互換性があり、かつ将来の機能追加とも競合しない構文を選ぶ必要があって、そのために長いこと協議しました。

次に CSS 変数と Sass 変数の競合についてですが、僕はこれこそもっとも誤解されているところだと思います。ネイティブな CSS 変数は、CSS プリプロセッサーがすでに実現していることをただ真似しようとしているのではありません。実際、その設計についての初期の議論をちょっと読み返してみればわかるはずです。ネイティブな CSS 変数に対するモチベーションの大部分は、プリプロセッサーに「できない」ことを可能にしようとすることに向けられているのです!

CSS プリプロセッサーは素晴らしいツールですが、変数は静的で、構文スコープです。一方、ネイティブな CSS 変数は完全に異なる種類の変数です。動的で、DOM スコープです。それどころか、そもそもこれを「変数」と呼ぶのは混乱の元だと思います。これは実のところ CSS プロパティで、変数とはまったく異なる機能をもたらし、まったく異なる問題を解決するものです。

僕はこの記事で、プリプロセッサーの変数にはできないけど CSS カスタム・プロパティにはできることについて考察します。また、カスタム・プロパティが可能にする新たなデザイン・パターンのデモもいくつか紹介します。そして、将来僕たちはプリプロセッサー変数とカスタム・プロパティそれぞれの長所を活かして両方を使うようになる、と僕は考えているんですが、その理由について論じます。

注意: この記事は CSS カスタム・プロパティの入門記事ではありません。もし CSS カスタム・プロパティについて聞いたことがないか、どんなものなのかよく知らないなら、まず最初に予習しておくことをすすめます。

プリプロセッサー変数の限界

先に進む前にはっきり言っておきたいんですが、僕は本当に CSS プリプロセッサーが大好きだし、自分のすべてのプロジェクトで使ってもいます。プリプロセッサーにはじつに素晴らしいことができて、ときにそれは魔法のようにも思えます。最終的には生の CSS を吐き出すだけだとしてもね。

とは言え、すべてのツール同様、CSS プリプロセッサーにも限界があります。そしてその限界は、プリプロセッサーの強力な印象とは裏腹なもので、とくに新しく触れる人にとっては意外かもしれません。

プリプロセッサーの変数は動的ではない

おそらく Sass をはじめて学ぶ人がもっとも多く感じる驚きは、メディア・クエリの中では変数を定義できなかったり @extend が使えなかったりすることではないでしょうか。この記事は変数についてのものなので、ここでは前者に着目してみましょう。

$gutter: 1em;

@media (min-width: 30em) {
  $gutter: 2em;
}

.Container {
  padding: $gutter;
}

これをコンパイルすると、次のような結果になります。

.Container {
  padding: 1em;
}

見てのとおり、メディア・クエリのブロックはなかったことになり、変数への代入は無視されてしまいます。

Sass で条件に応じた変数を宣言することは理論的には可能かもしれませんが、それにはすべての組み合わせを列挙する必要があって、とても骨が折れます。最終的な CSS のサイズも指数関数的に増加することになるでしょう。

と言うのも、変数は @media ルールにマッチするかどうかによって更新できないので、メディア・クエリごとにユニークな変数を定義して、それぞれのバリエーションを別々に書くしかないのです。この点についてはまたのちほど触れます。

プリプロセッサーの変数はカスケードできない

変数を利用していると、いつもスコープについての疑問が頭をもたげてきます。いったいこの変数のスコープはグローバルなのか? ファイルやモジュール? それともブロック?

しかし CSS はそもそも HTML をスタイリングするものだということを考えると、変数のスコープとしてもっと便利なものがあることに気づきます。それは DOM 要素です。とは言えプリプロセッサーはブラウザーで実行されるものではなく、マークアップを参照することもできないので、DOM スコープの変数というのは無理な相談です。

たとえば、ユーザーがサイトの設定で文字サイズを大きくしていたら、<html> 要素に user-setting-large-text というクラスを追加したいとしましょう。そのクラスがあるときは、より大きい $font-size 変数の値が適用されてほしいわけです。

$font-size: 1em;

.user-setting-large-text {
  $font-size: 1.5em;
}

body {
  font-size: $font-size;
}

しかし先ほどのメディア・ブロックの例と同様、Sass はこの変数への代入を完全に無視するので、この手のことは不可能なのです。この場合のアウトプットは以下のようになります。

body {
  font-size: 1em;
}

プリプロセッサーの変数は継承できない

継承は技術的にはカスケードの一部ですが、あえて別に取り上げたいと思います。僕はこうできたらいいと幾度となく思ったものの、それはかないませんでした。

ある DOM 要素があり、親要素に適用された色に応じてスタイリングしたいとしましょう。

.alert { background-color: lightyellow; }
.alert.info { background-color: lightblue; }
.alert.error { background-color: orangered; }

.alert button {
  border-color: darken(background-color, 25%);
}

このコードは Ssss として (そして CSS としても) 妥当ではありませんが、なにをやろうとしているかは理解してもらえると思います。

最後の宣言は、<button> 要素の background-color プロパティを親の .alert 要素から継承し、その値に対して Sass の darken 関数を使おうとしています。そしてもし infoerror といったクラスが alert に追加されたら (または背景色が JavaScrpt やユーザー・スタイルシートを通じてセットされたら)、button 要素をそれに追従させたいわけです。

もちろんこの Sass は動きません。なぜならプリプロセッサーは DOM 構造のことを知ることができないので。でもこの手のことができれば便利なのは間違いありません。

ひとつユースケースを挙げると、アクセシビリティを考えるときに、継承した DOM のプロパティに応じて色の関数を実行できたら、とても便利になるでしょう。たとえば、テキストをつねに読みやすく、かつ背景色に対してじゅうぶんなコントラストを保つようにするとか。こういったことが、カスタム・プロパティと、新しく登場する CSS の color 関数の組み合わせで、もうすぐ可能になるのです!

プリプロセッサーの変数は相互運用性がない

これは明らかな欠点というわけではありませんが、重要だと思うので指摘しておきます。たとえば PostCSS を使ってサイトを作っているとき、使いたいサード・パーティのコンポーネントが Sass を使わないと見た目が変更できないとしたら、残念な結果になります。

と言うのも、プリプロセッサーの変数を、異なるツール同士や、CDN でホストされるサード・パーティのスタイルシートと共有するのは不可能なのです (少なくとも簡単ではありません)。

ネイティブな CSS カスタム・プロパティなら、あらゆる CSS プリプロセッサーやプレーンな CSS ファイルといっしょに使うことができます。「逆は必ずしも真ならず」です。

カスタム・プロパティはどう違うか

おそらく皆さんご想像のとおり、上に挙げたいずれの制限も CSS カスタム・プロパティには存在しません。しかしここでより重要なのは、CSS カスタム・プロパティにこれらの制限がないということ自体よりも、「なぜ」そういったことが可能なのかということです。

CSS カスタム・プロパティは、ふつうの CSS プロパティとまるで変わらないように見えるし、そしてまたまったく同じように動作します (ただ明白な違いとして、カスタム・プロパティ自体はスタイルに影響しません)。

ふつうの CSS プロパティ同様、カスタム・プロパティは動的です。ランタイムで変化し、メディア・クエリによって、また DOM へのクラス名の追加によって更新されます。要素にインラインで指定することもできるし、通常どおりセレクターに宣言することもできます。更新や上書きは、通常のすべてのカスケーディング規則によって、また JavaScript によって可能です。そして、おそらくこれがもっとも重要なことですが、カスタム・プロパティは継承するので、DOM 要素に適用されると、その子孫に引き継がれます。

より手みじかに言うとこうです。プリプロセッサーの変数は、構文スコープで、コンパイル後は静的。カスタム・プロパティは、DOM スコープで、live で、動的。

実際の例

プリプロセッサーの変数にはできなくて、カスタム・プロパティにできることはなにか。まだよくわからないという向きのため、いくつかの例を用意しました。

ご参考までに言うと、本当はぜひお見せしたい素晴らしい例が山のようにあったんですが、記事が長くなりすぎないよう、そのうち 2 つにしぼったんです。

僕が選んだこれらの例は、理屈から導き出したものではなく、僕がかつて実際に直面した課題です。これらの課題をプリプロセッサーで解決しようとして挫折したことを、僕はありありと覚えています。でもカスタム・プロパティなら、できるんです。

メディア・クエリに応じたレスポンシブなプロパティ

多くのサイトで、“gap” や “gutter” といった変数を使っています。ページの各セクションのデフォルトのパディングをはじめ、アイテム間のデフォルトのスペーシングを定義するためです。そしてたいてい、ガターの大きさは、ブラウザーのウィンドウのサイズに応じて変化させたいものではないでしょうか。大きいスクリーンではアイテム間のスペースを広くゆったりとりたいけど、小さいスクリーンではそんなスペースの余裕はないので、ガターを狭くしないといけない、というように。

しかし、さきほど指摘したように、Sass の変数はメディア・クエリ内で動作しないので、それぞれのバリエーションごとに変数を書く必要があります。

次の例では、まず $gutterSm$gutterMd$gutterLg という変数を定義してから、メディア・クエリのバリエーションごとにルールを宣言しています。

/* ブレイクポイントごとに 3 つの変数を宣言 */

$gutterSm: 1em;
$gutterMd: 2em;
$gutterLg: 3em;

/* $gutterSm を使った小さいスクリーン向け基本スタイル */

.Container {
  margin: 0 auto;
  max-width: 60em;
  padding: $gutterSm;
}
.Grid {
  display: flex;
  margin: -$gutterSm 0 0 -$gutterSm;
}
.Grid-cell {
  flex: 1;
  padding: $gutterSm 0 0 $gutterSm;
}

/* $gutterMd で中くらいのスクリーン向けに上書き */

@media (min-width: 30em) {
  .Container {
    padding: $gutterMd;
  }
  .Grid {
    margin: -$gutterMd 0 0 -$gutterMd;
  }
  .Grid-cell {
    padding: $gutterMd 0 0 $gutterMd;
  }
}

/* $gutterLg で大きいスクリーン向けに上書き */

@media (min-width: 48em) {
  .Container {
    padding: $gutterLg;
  }
  .Grid {
    margin: -$gutterLg 0 0 -$gutterLg;
  }
  .Grid-cell {
    padding: $gutterLg 0 0 $gutterLg;
  }
}

一方、まったく同じことをカスタム・プロパティを使って実現するには、スタイルを定義するのは 1 回だけでいいのです。--gutter という 1 つのプロパティを用意すれば、あとはメディアがクエリにマッチするごとに --gutter の値は更新され、すべてはレスポンシブになります。

/* ブレイクポイントごとに `--gutter` の値を宣言 */

:root { --gutter: 1.5em; }

@media (min-width: 30em) {
  :root { --gutter: 2em; }
}
@media (min-width: 48em) {
  :root { --gutter: 3em; }
}

/*
 * カスタム・プロパティの値は自動的に更新されるので、
 * スタイルの定義は 1 回だけでいい
 */

.Container {
  margin: 0 auto;
  max-width: 60em;
  padding: var(--gutter);
}
.Grid {
  --gutterNegative: calc(-1 * var(--gutter));
  display: flex;
  margin-left: var(--gutterNegative);
  margin-top: var(--gutterNegative);
}
.Grid-cell {
  flex: 1;
  margin-left: var(--gutter);
  margin-top: var(--gutter);
}

構文自体はカスタム・プロパティの方が冗長であるにもかかわらず、同じことを実現するのに必要なコード量は大幅に少なくなります。それに、この例では 3 つのバリエーションしか考えていませんが、もしもっとバリエーションが増えれば、より多くのコードを削減できることになります。

次のデモは、上記のコードを使ったベーシックなサイトのレイアウトで、ビューポートの幅に応じてガターの値が自動的に変化します。カスタム・プロパティをサポートしているブラウザーで実際にチェックしてみてください!

レスポンシブなプロパティのデモ
View the demo on CodePen: editor view / full page

コンテクスチュアル・スタイリング

コンテクスチュアル・スタイリング (要素が DOM 上でどこに位置するかに応じてスタイリングすること) は、CSS の話題のなかでも繰り返し議論されているもののひとつです。信頼の置ける CSS ディベロッパーの多くは、コンテクスチュアル・スタイリングを避けるよう警鐘を鳴らしてます。しかしその一方で、いまでも多くの人が日々利用してもいます。

ハリー・ロバーツは先日、この件についてこんな記事を書いています。

ある UI コンポーネントが、置かれた場所によって見栄えを変えたいのだとすれば、それはそのデザイン・システムが失敗しているということだ。…すべてはお互いに無関心でいられるようにデザインすべきだ。すべては「このコンポーネントは…」というふうにデザインすべきで、「このコンポーネントが何かの中にあるときは…」とすべきではない。

僕もこの件では (そしてこれ以外の多くのことについても) ハリーに賛成です。しかし一方で、これだけ多くの人がこの問題について安易な方法をとっているということは、じつはもっと大きな問題があることを示しているのではないでしょうか。それは、CSS の表現には限界があり、ほとんどの人は現在の「ベスト・プラクティス」に満足していない、ということです。

次に挙げるのは、子孫コンビネーターを使ってコンテクスチュアル・スタイリングに取り組むよくある例です。

/* 通常のボタンのスタイル */
.Button { }

/* ボタンがヘッダーの中にある場合はスタイルが異なる */
.Header .Button { }

このアプローチには多くの問題があります (この問題については僕の CSS Architecture という記事で説明しています)。このパターンがコードとして「臭う」理由のひとつは、ソフトウェア開発における開放/閉鎖原則に反していることです。この例では閉じたコンポーネントに変更を加えてしまっています。

ソフトウェアの単位 (クラス、モジュール、関数など) は、拡張に対して開いていて、修正に対して閉じているべきである。

カスタム・プロパティは、コンポーネント定義のパラダイムを興味深い方法で変えてくれます。カスタム・プロパティによって、僕らははじめて、拡張に対して開かれたコンポーネントを書けるようになるのです。例を挙げましょう。

.Button {
  background: var(--Button-backgroundColor, #eee);
  border: 1px solid var(--Button-borderColor, #333);
  color: var(--Button-color, #333);
  /* ... */
}

.Header {
  --Button-backgroundColor: purple;
  --Button-borderColor: transparent;
  --Button-color: white;
}

さきほどの子孫コンビネーターの例との違いはわずかですが、その違いはとても重要なものです。

子孫コンビネーターを使う場合、宣言しているのは「ボタンがヘッダーの中にあったらこのような見え方をする」ということで、ボタンのコンポーネントが自分自身を定義しているのではありません。こういった宣言は (ハリーの言葉を借りれば) 独裁的で、たとえばボタンがヘッダーの中にあるけどそのように見えなくてよい場合など、例外のときに取り消すのが難しくなります。

一方、カスタム・プロパティを使えば、ボタンのコンポーネントはコンテキストを知らなくてよいし、ヘッダーのコンポーネントから完全に分離できます。この場合の宣言はただ単に、「私はこれらのカスタム・プロパティに基づき自分自身をスタイリングします。どんな状況であろうとも」と言っています。そしてヘッダーのコンポーネントはこうです。「私はこれらのプロパティに値を指定しますが、私の子孫がそれを使うかどうかや、どのように使うかは、彼らに委ねます」。

いちばんの違いは、拡張がボタンのコンポーネントによるオプトインであり、例外の場合は容易に取り消せることです。

次のデモは、リンクとボタンがサイトのヘッダーにあるときとコンテンツ・エリアにあるときのコンテクスチュアル・スタイリングの例です。

コンテクスチュアル・スタイリングのデモ
View the demo on CodePen: editor view / full page

例外を設ける

この新たなパラダイムでは例外を設けるのがいかに簡単か、さらに詳しく説明しましょう。.Promo というコンポーネントがヘッダーに追加されたとして、.Promo コンポーネントの中のボタンは、ヘッダー・ボタンではなく、通常のボタンと同様に見せたいとします。

子孫コンビネーターを使うと、まずヘッダー・ボタンのスタイルを大量に書いたあと、それらをプロモ・ボタンのために元に戻さないといけません。この方法はややこしく、間違いを起こしやすいし、コンビネーターが増えるにつれて手に負えないことになっていきます。

/* 通常のボタンのスタイル */
.Button { }

/* ボタンがヘッダーの中にある場合はスタイルが異なる */
.Header .Button { }

/* ボタンがさらにプロモの中にある場合、ヘッダーにおけるスタイルを元に戻す */
.Header .Promo .Button { }

カスタム・プロパティなら、ボタンのプロパティを好きなように更新することも、デフォルトのスタイルにリセットすることも簡単です。そして例外がいくらあっても、つねに同じ方法でスタイルを変えられます。

.Promo {
  --Button-backgroundColor: initial;
  --Button-borderColor: initial;
  --Button-color: initial;
}

React から学んだこと

僕は最初、カスタム・プロパティを使ったコンテクスチュアル・スタイリングについて懐疑的でした。前述のとおり僕は、コンポーネントが親要素から継承した任意のデータに適応するよりも、コンテキストとは無関係にコンポーネント自身のバリエーションを定義する方を好むので。

でもそんな僕が考えを改めたのは、CSS のカスタム・プロパティと React の props を比較したことがきっかけでした。

React の props は CSS カスタム・プロパティ同様に動的で、DOM スコープの変数で、継承が可能で、コンテキストに依存させることができます。React では、データは親コンポーネントから子コンポーネントに受け渡されますが、そのうちどの props を受け取るか、そしてそれをどのように使うかは子コンポーネントが定義します。このアーキテクチャのモデルは「単方向データ・フロー」として知られています。

カスタム・プロパティはまだ新しく、十分にテストされていない領域ですが、React モデルが成功しているのを見て、僕は確信しました。プロパティの継承という仕組みの上に複合的なシステムを構築することは可能だし、しかも DOM スコープ変数は有用なデザイン・パターンなのです。

副作用を最小限に

すべての CSS カスタム・プロパティはデフォルトで継承されます。しかし場合によっては、継承によってコンポーネントが意図しないスタイルになってしまうこともあります。

ひとつ前のセクションで見たとおり、これは個々のプロパティをリセットすることで回避できます。リセットによって、子要素に未知の値が適用されるのを防ぐのです。

.MyComponent {
  --propertyName: initial;
}

まだ仕様に取り入れられてはいませんが、すべてのカスタム・プロパティをリセットできる -- プロパティというものが検討されています2。またもし特定のプロパティだけ継承されるようホワイトリストに加えたいなら、inherit を指定すれば、それらのプロパティは通常どおりに振る舞います。

.MyComponent {
  /* すべてのカスタム・プロパティをリセット */
  --: initial;

  /* 特定のプロパティだけホワイトリスト化 */
  --someProperty: inherit;
  --someOtherProperty: inherit;
}

グローバルな名前の管理

もしここまでの例でカスタム・プロパティの命名に注目していたなら気づいたかもしれませんが、僕は、コンポーネント固有のプロパティには、コンポーネント自体のクラス名を接頭辞につけています。たとえば --Button-backgroundColor とか。

CSS における名前の多くがそうであるように、カスタム・プロパティはグローバルで、チームのほかのディベロッパーが使う名前と衝突する可能性がつねにあります。

この問題を避ける手軽な方法のひとつは、ここで僕がやっているように、命名規則にしたがうことです。

より複雑なプロジェクトでは、CSS Modules のような、グローバルな名前をローカルにするものを検討してもいいかもしれません。CSS Modules は最近、カスタム・プロパティのサポートに 前向きであることを表明しています。

まとめ

この記事が、これまで CSS カスタム・プロパティになじみのなかった人が興味を持つきっかけになったらうれしいです。そしてまた、カスタム・プロパティの必要性に疑問を持っている人が考えを改めるきっかけになったらうれしい。

カスタム・プロパティはダイナミックで強力な機能を CSS にもたらします。そしてその力はもっと広く知られるべきだと強く思います。

カスタム・プロパティは、プリプロセッサーの変数ではどうしても埋められない空白を埋めてくれます。しかしプリプロセッサーの変数は依然として使いやすく、多くの場面で最適な選択肢です。だから僕は、将来は多くのサイトでこの両方を組み合わせて使うことになるだろうと確信しています。カスタム・プロパティで動的な見た目の変更を、そしてプリプロセッサーの変数で静的なテンプレート化を、というように。

カスタム・プロパティとプリプロセッサーの変数は、どちらかを選ばなければいけないようなものではないと思います。両者を競合するものとして争わせたところで、なにも得るものはありません。

原稿をレビューしてくれたアディ・オスマーニマット・ゴーント、そして Chrome のバグをつぶしてデモが動くようにしてくれたシェイン・スティーヴンズに感謝します。