CSS: フロントエンドアーキテクチャに「アフォーダンス」層も必要な理由(翻訳)
あるとき、私はファイルアップロード用のフォームを構築していました。ユーザーがドキュメントをアップロードするためだけの、何の変哲もないフォームです。
このフォームでファイルオープンをトリガーするボタンは、そのページの他のボタンとスタイルを揃えたいと思いました。ボタンの微妙なシャドウやマウスホバー効果やスペーシングを同じにしたかったのです。
このときは、Tailwind Labsが提供している有料のコンポーネントであるCatalystを使っていたので、<Button>コンポーネントにはCatalystのスタイルが組み込まれていました。
しかし、ファイルオープンをトリガーするボタンでは、そのスタイルを利用できなかったのです。
ファイルオープンダイアログをフォームで表示するには、クリック可能な<label>要素が必要です。こうすることで、ブラウザネイティブのUIを妨げずにファイルオープン用の要素にスタイルを与えられるようになります。しかしCatalystでは、<Button>コンポーネントを<button>要素としてレンダリングするか、<Link>コンポーネントを使うしかなく、それらのスタイルを<label>要素に適用する方法が存在しません。
コンポーネントライブラリによっては、一種の脱出ハッチを提供しているものもあります(asChildプロパティやrenderプロパティによって背後の要素を差し替えるなど)。しかしそうしたプロパティが渡すのはスタイルだけではなく、コンポーネントの「振る舞い」も渡します。両方必要ならそれでよいのですが、コンポーネントで見た目だけを差し替えたい場合、つまり要素の見た目を「クリッカブルのように」変更して、しかも<label>なら<label>のネイティブなセマンティクスに影響しないようにしようとすると、行き詰まってしまいます。
これはCatalystのバグではありませんが、抽象化としてのコンポーネントの構造的な限界です。コンポーネントは、純粋にスタイルだけを受け渡しするには向いていません。一度このことに気づくと、あちこちで目に付くようになります。
🔗 異なる3つのものに同じ名前が付いている
「ボタン」という名前で考えてみましょう。フロントエンド開発では、「ボタン」という名前が実際には3つの異なるものを指しています。
- 1:
<button>要素
HTMLネイティブのセマンティクスと振る舞い -
2:
Buttonコンポーネント
ライブラリによって構造や振る舞い(場合によってはスタイル)をカプセル化したもの -
3:
buttonの表示パターン
角に丸みがあり、ボタン名の周りにパディングがあり、無地の背景、マウスホバーで表示が変わることで、クリッカブルであるように「見せる」
つまり<label>要素を<button>要素のような外観にしたい場合、要素やコンポーネントでは実現できないということです。
同じことがテキスト入力フィールドについても言えます。
テキスト入力用の要素もあれば、テキスト入力用コンポーネントも、表示パターンもあります。表示パターンは、そこに文字を入力してもよさそうな外観を設定します。こうしたパターンは、<textarea>要素や<select>要素のみならず、まったく異なるHTML要素上で構築されたカスタムのオートコンプリートなどで必要になる可能性すらあります。
デザイン理論においては、こうした表示パターンは「アフォーダンス(affordance)」という名前で呼ばれています。アフォーダンスとは、要素がどのように操作可能かを視覚的に伝えるシグナルです。なお、この用語はDon Normanの『The Design of Everyday Things』という書籍が由来で、たとえばドアノブの形は「これは押すか引くかどちらかである」ことを示していると説明されています。
ユーザーインターフェイスにおけるアフォーダンスは、ボタンをクリックしてよさそうに見せ、テキストフィールドをそこに文字を入力してよさそうに見せ、リンクをクリックしてよさそうに見せる役割を果たします。
私は、今日の標準的なフロントエンドアーキテクチャに、アフォーダンスを第4の概念レイヤとして追加する必要があると考えています。
| レイヤ | 何を表すか | 例 |
|---|---|---|
| トークン | デザインのためのアトミックな値 | --color-indigo-600、--spacing-4 |
| ユーティリティ層 | 単一目的を表すCSSクラス | p-4、text-red-500 |
| アフォーダンス層 | 要素と独立な表示パターン | .ui-button、.ui-input、.ui-card |
| コンポーネント層 | 構造と振る舞いをカプセル化する | <Button>、<Dialog> |
現代のライブラリは、ほとんどの場合アフォーダンスをコンポーネントに埋め込んでいます。私がファイルアップロードで苦しんだのはまさにこの部分であり、皆さんも同じような苦しみを味わったのではないでしょうか。
アフォーダンス層を分離して適切な名前を与えることで、必要な柔軟性を取り戻せるようになります。
🔗 不要な苦しみ
私はユーティリティクラスが大好きですし、一緒に仕事をしているどのチームでもTailwindを推しています。
しかしアフォーダンス層がなければ、チームはすべてのスタイルをユーティリティクラスだけで作らなくてはならなくなり、同じ操作ができそうなあらゆる要素に対して、まったく同じユーティリティクラスを何度も書くはめになります。
<label class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
Upload file
</label>
上では14個のユーティリティクラスを指定していますが、「<a>をボタン風のスタイルにしたもの」「<summary>要素」「<div role="button">要素」など、同じように扱う必要のある他のすべての要素についても、同じ14個のユーティリティクラスがコピーされることになります。
これによって、具体的に3つの問題が発生します。
- 1: 信頼できる単一の情報源が存在しない(「どれを信じればいいの」状態)
デザインが変更されたとき(例:blue-600がindigo-600に変更される、ボーダーのradiusがrounded-mdからrounded-lgに変更される)、コードベース全体であらゆるインスタンスを調べて回るはめになります。検索置換で対応できるのは、ユーティリティクラスの順序やフォーマットが同じ場合に限られますが、現実はそうではありません。 -
2: 抽象化がコンポーネントAPIに漏洩する
アフォーダンス層がないと、コンポーネントにスタイルをプロパティとして渡すか、さもなければコンポーネントで任意のclassNameを受け取れるようにすることになりますが、どんなふうにマージされるか予測できなくなります。本来複雑さをカプセル化すべきコンポーネントの境界をスタイルが越境してしまうため、境界でスタイルが衝突します。 -
3: 時間とともに一貫性が損なわれていく
開発者Aはhover:bg-indigo-500と書き、開発者Bがhover:bg-indigo-600と書き、開発者Cはこのフォーカススタイルのことをころっと忘れていたとします。1つ1つは「間違っていない」のですが、製品の一貫性が徐々にツギハギ化してしまいます。何しろ、「クリックされるものは"こう"見えるべきである」という抽象化がないのですから。
ユーティリティクラスだけでスタイルを作っていくことの難しさに加えて、純粋に振る舞いを扱うコンポーネントから純粋な表示スタイルを分離しなければならないのではと感じるようになります。
だからこそ、以下のようなヘッドレスライブラリがフロントエンド界隈で燎原の火のごとく一気に広まったのです。振る舞いと表示(スタイル)は分離する必要があることを私たちは思い知りました。
ヘッドレスライブラリは、問題の半分を解決してくれました。見た目を気にすることなく、「アクセシビリティ」「キーボード操作」「フォーカス管理」といった複雑な振る舞いを扱えるコンポーネントを提供してくれます。
しかし、スタイリング問題の残り半分は未解決のままです。明確なレイヤの概念がなければ、ほとんどの開発者はユーティリティクラスをあちこちに直にインラインで書いてしまうでしょう。これらは、「単一情報源の不在」「コンポーネントAPIからスタイルが漏洩する」「一貫性が着々と失われる」といった上述の問題の原因となります。
アフォーダンスは、この残り半分を解決します。
🔗 「とっくに試してみたけどダメだったよ」
早くも反論の声が聞こえてきそうです。「それって、普通のセマンティックなCSSクラス1を作って使うのとどう違うの?それなら既にやってみたんだけどね...」
おっしゃる通り、セマンティックなCSSなら私たちもとっくに試しました。しかし「ダメだった」の部分はもう少し詳しく見ていく価値があります。
.btnのように昔からあるセマンティックなCSSクラスは、その概念に問題があったのではなく、実行方法に問題があったのです。
(Tailwindのような)ユーティリティファーストの世界で、従来のようなセマンティックなCSSクラスを混ぜて使うと、CSS同士の詳細度(specificity)が衝突する可能性があります。
たとえば.btn { background: blue; }というセマンティックなCSSを書くと、その詳細度は0,1,0になります。これをbg-red-500というユーティリティクラスでカスタマイズしようとすると、「これも」同じく詳細度が0,1,0になります2。すると、どっちが勝つかはビルドされるCSSファイル上の順序で変わってしまうのでカオスが発生します。これでは、スタイルを適用するはずが、戦わなくてよかったはずのカスケードと戦うことになります。
かつてBootstrapにおいて詳細度の悪夢で消耗した多くの開発者たちは、Tailwindに出会ったときに「従来のようなセマンティックなクラスは決して書かないこと」という厳格なルールを自らに課しました。「当時は」この教訓にも意味があったかもしれませんが、プラットフォームは進化し続けているのです。
現代のCSSには@layerによるカスケードレイヤがありますが、カスケードレイヤは、まさしくこの問題を解決するために設計された機能なのです。CSSのレイヤを使うと、スタイルのグループの優先順位を、従来の詳細度やCSSソース上の出現順序から独立して明確に定義できます。1つのレイヤ内に出現するスタイルは、セレクタの詳細度にかかわらず、常に「後勝ち」、つまりリストの下にあるものが優先されます。
つまり、これを使うとアフォーダンス用のレイヤをユーティリティ用のレイヤの「下」に配置できます。
@layer affordances, utilities;
@layer affordances {
.ui-button {
display: inline-flex;
align-items: center;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--rounded-md);
background-color: var(--color-indigo-600);
color: var(--color-white);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
box-shadow: var(--shadow-sm);
&:hover {
background-color: var(--color-indigo-500);
}
&:focus-visible {
outline: 2px solid var(--color-indigo-600);
outline-offset: 2px;
}
}
}
これで、utilities層のユーティリティクラスでaffordances層の.ui-buttonを上書きできるようになります。アフォーダンスのセレクタの詳細度がどのようなものであっても影響されません。
Tailwind v4はCSSのカスケードレイヤ機能を内部で使うようになっているので、自然にutilities層が勝つようになっています3。
特に安全度を高めたい場合や、レイヤを使わないCSSで作業している場合は、以下のようにセレクタを:where()擬似クラスで囲むことで詳細度をゼロにできます。
@layer affordances {
:where(.ui-button) {
/* ... */
}
}
これで.ui-buttonを優先度の低いレイヤに配置し、「しかも」詳細度をゼロに下げられます。
<button class="ui-button bg-red-600 hover:bg-red-500">Delete</button>
上はui-buttonではなくbg-red-600が勝ちます。!importantも苦し紛れの回避法も不要ですし、詳細度と戦う必要もありません。アフォーダンスが妥当なデフォルト値を提供してくれるので、ユーティリティクラスで自由にカスタマイズできます。
原注
ui-というプレフィックスは、アフォーダンスクラスと昔ながらのセマンティッククラスを区別するのに用いています。.btnというCSSクラスだけでは、「これはきっとBootstrapの忘れ形見か何かで、詳細度がどうなっているかわかったものではない」と思われても仕方ないでしょう。
.ui-buttonというクラス名にしておけば、「これはユーティリティクラスと組み合わせることを前提に設計された、詳細度の低い表示パターンである」という意図を示せます。
af-でもlook-でも構わないので、チームの規約に合うプレフィックスを選びましょう。新規アプリであればわざわざプレフィックスを付けなくても構いませんが、規約を決めておく方が、これは前世紀のセマンティックなCSSクラスではないことを示せます。
これは2015年への逆戻りではなく、正真正銘の新機能です。2022年にはカスケードレイヤと:where()が多くのブラウザでサポートされるようになりましたが、ようやく私たちはその機能に追いつきつつあります。ツールは揃ったので、いよいよ概念と用語とベストプラクティスを整備する時代が到来しました。
🔗 実践例
冒頭のファイルオープンダイアログのことを覚えていますか?アフォーダンス層を取り入れたことで、以下のようにスタイルを設定できるようになりました。
<label for="document-upload" class="ui-button">
Choose file
</label>
<input type="file" id="document-upload" class="sr-only" />
これだけで完了です。
<label>要素にはui-buttonクラスでボタンのアフォーダンスが与えられています。
<input>要素は画面上には表示されていませんが、引き続きアクセス可能です。<label>要素のネイティブの振る舞いは変わっておらず、クリックすればこれまで通りファイルオープンダイアログが開きます。コンポーネントであれこれ処理する必要もなければCSSフレームワークと戦う必要もなく、苦し紛れの脱出ハッチも不要です。
Catalystが<Button>コンポーネントに加えて.ui-buttonアフォーダンスクラスも提供してくれていれば、こんなに苦労しなくて済んだでしょう。
コンポーネントはこれまで通り一般的なケースにおける振る舞いをカプセル化しますが、<label>要素や<summary>要素や<a>要素でコンポーネントの「見た目だけ」が欲しくなったときに、.ui-buttonを使えばいいんだなということが明確にわかるようになったことでしょう。
「じゃぁプライマリボタンやセカンダリボタン、それに破壊的操作のボタンのスタイルはどうやってバリエーションを作ればいいの?」という質問が聞こえてきそうです。方法は2つあります。
方法1は、バリアント用のアフォーダンスクラスを定義することです。
@layer affordances {
:where(.ui-button-secondary) {
background-color: var(--color-gray-100);
color: var(--color-gray-900);
&:hover { background-color: var(--color-gray-200); }
}
:where(.ui-button-danger) {
background-color: var(--color-red-600);
&:hover { background-color: var(--color-red-500); }
}
}
方法2は、アフォーダンスは基本スタイルにとどめておいて、ユーティリティクラスでスタイルを変えるというものです。
<button class="ui-button bg-red-600 hover:bg-red-500">Delete</button>
私は、一回しか使わないような場合は方法2を、コードベースで繰り返し使う場合は方法1を使うのが好みです。詳細度ゼロで基盤が固められているおかげで、どちらもシームレスに動作します。
🔗 ライブラリ作者にお願い
コンポーネントライブラリのメンテナーの皆さん、コンポーネントと一緒にアフォーダンスも配布することをご検討ください。
皆さんの作った<Button>コンポーネントは価値あるものなので、クリックイベントや読み込みステートの処理、スタイルの無効化や、場合によっては分析などの機能は、そのままでお願いします。
ただし、コンポーネントの見た目「だけ」を扱える.ui-buttonクラスも配布していただきたいのです(クラス名はそちらの命名規約に合わせていただいて構いません)。開発者がそうしたアフォーダンスクラスを任意の要素に適用できるようにしていただきたいのです。@layerや:where()を活用して、アフォーダンスクラスがユーティリティクラスと調和するようにしていただきたいのです。
入力やカードやバッヂなど、そちらのシステムのあらゆる表示パターンについても同じことをお願いしたいのです。コンポーネントは明確な意図を持つ完全な機能が必要なときに使うものです。そしてアフォーダンスは、開発者たちが遭遇する予想もしなかった問題を解決するときに使える脱出ハッチとして位置づけられます。
以下は私が提唱する移行です。
- 表示パターンをコンポーネントの実装の詳細として扱うのをやめよう
- 表示パターンは、第一級のプリミティブとして公開しよう
- 表示パターンに
:where()を適用することで、ユーティリティクラスと組み合わせ可能にしよう - 開発者が表示パターンをどんなHTML要素にも適用できるようにしよう: 理由を問わず、かつ面倒なことを行わずに済むようにしよう
🔗 まとめ: 前に進める道はこちら
<button>要素とButtonコンポーネントと.ui-buttonアフォーダンスクラスは、別物です。現代のフロントエンドアーキテクチャは、これらを別々のものとして扱えるものであるべきです。
私たちは長年かけて関心の分離(separation of concerns)を学んできました。ヘッドレスライブラリのおかげで、表示スタイルを強制しない「振る舞い」としてのコンポーネントを手に入れられました。しかし私たちは、道半ばで挫折していました。私たちはスタイルを振る舞いから切り離すようになったのに、ユーティリティクラスをあちこちに書くうちにまたしても特定の要素にユーティリティクラスのスタイルが癒着するようになってしまいました。
アフォーダンスは、この分離を完成させてくれます。アフォーダンスによって表示パターンをどんな要素でも再利用可能になり、ユーティリティクラスと自由に組み合わせるようになり、インタラクティブな要素が持つべき見た目に関する信頼できる単一の情報源を提供してくれます。
@layerと:where()のおかげで、詳細度の衝突を気にすることなく作業するための技術的な基盤がついに確立されました。あとは、私たちのCSSアーキテクチャに関する考え方を変えるだけです。
関連記事
-
訳注: セマンティックなCSSクラスは、
.btnやbutton-hoverなどのように機能上の意味を持つCSSクラス名を作ることを意味します。要するに従来のCSSで行われてきたことです。対照的に、Tailwindなどで使うユーティリティCSSクラスは、p-3やm-2などの単機能スタイルだけを組み合わせて使うものであり、「これはボタンで使うスタイルである」といった機能上の意味を持ちません。 ↩ -
訳注: Tailwindのユーティリティクラスはすべて単なるクラスでカスケードやセレクタが介在しないので、詳細度は常に
0,1,0になります。 ↩ -
訳注: ここで述べられている「utilities 層が勝つ」という挙動は、CSSの標準機能であるカスケードレイヤ(
@layer)によるものです。一方、@layerの代わりにTailwind v4で導入された@utilityや@variantを使ってアフォーダンスを定義する方法を採用すると、「ツリーシェイクが効く」「IntelliSenseで表示される」といったTailwind固有のメリットを得ることができます。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
layerは語感によって適宜「レイヤ」「層」としています。
念のため、ユーティリティファーストを採用しているCSSシステムはTailwind CSS以外にもあります。