僕がネイティブな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%);
}

このコードはSassとして(そして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のバグをつぶしてデモが動くようにしてくれたシェイン・スティーヴンズに感謝します。

脚注

  1. Chromeの「試験運用版のウェブ プラットフォームの機能」フラグを有効にするには、アドレスバーにabout:flagsと入力し、「試験運用版のウェブ プラットフォームの機能」を検索し、「有効にする」をクリックする。
  2. --プロパティの利用については、タブ・アトキンスのこのGithubコメントで(カスタム要素へのスタイリングの話題として)言及されている。さらに、タブはwww-styleメーリングリストの投稿で、--をすぐにでも仕様に取り込むべきと進言している。