CSS の :has()
擬似クラスは、すでに多くのブラウザではサポートされていますが、これまでは Firefox が非対応のため、実案件で使用するには限定的でした。
しかし、2023 年 12 月 19 日リリース予定の Firefox 121 で、この :has()
擬似クラスがサポートされる予定となっており12、ようやく主要ブラウザでのサポートが出揃います。
この記事では、CSS の表現力をさらに進化させる :has()
について、10 個のサンプルを取り上げながら、その仕組みや使い方を説明します。
:has()
は W3C の仕様では「Selectors Level 4」に属しています3。この仕様自体は Working Draft の状態ですが、:not()
、:is()
、:where()
のようにすでに広く使われているセレクタも含まれています。
:has()
が登場する以前の結合子を使用したセレクタでは、子孫要素や兄弟要素に対してスタイルを適用できましたが、特定の要素からさかのぼって祖先要素や前方の要素に適用することはできませんでした。
:has()
は、引数として渡したセレクタの条件に一致する要素に適用されるため、祖先要素や前方の要素にスタイルを当てることができるようになります。
さらには、祖先要素までさかのぼって、別の子孫要素にスタイルを指定したり、:checked
などのインタラクティブな擬似クラスを使用することで、CSS の可能性が一気に広がります。
これらの点を踏まえたうえで、実例を見ていきましょう。
本記事で紹介するサンプルは、あくまで :has()
の可能性を追求するためのものであり、実用的ではない例も含みます。特にインタラクティブな機能を含むサンプルでは、アクセシビリティの確保が不十分なものも含まれていますのでご注意ください。
まずは、包含する子孫要素に応じて、祖先要素にスタイルを指定する例を見ていきます。以下は子孫要素に含まれるカテゴリごとに配色を変更するコンポーネントの例です。
このようにカテゴリごとに配色を変更したいとき、:has()
を使用することで、含まれる子孫要素を条件に祖先要素までさかのぼってスタイルを指定することが可能です。
ここでは、まず CSS 変数 --accent
でカテゴリのカラーを定義しています。そして、:has()
擬似クラスを使用して、含まれるカテゴリに応じてこの --accent
の値を変更しています。
このように、包含する子孫要素を条件に祖先要素のスタイルを変更することが可能です。
続いて、隣接する要素を条件にスタイルを適用する例です。
<h2>
見出しに上余白を指定しており、その見出しが単体で使われるときと、その上にコピー(<p class="copy">
)が配置されるパターンが存在するケースで考えてみます。
以下はライブデモです。ホバーすると余白の領域が表示されます。
このように、見出し単体でも、その上にコピーが配置されていても上余白のサイズは同じです。該当部分のコードを見てみましょう。
ここでは、:where()
を使用して 2 つのセレクタを指定していますが、2 番目の .copy:has(+ h2)
がポイントで、h2
に隣接する .copy
が対象となっています。
つまり、h2
に隣接する直前の copy
に対してスタイルを適用しています。
CSS のセレクタはカンマ(,
)で区切ってリスト化することができます。このセレクタリストはブラウザが対応していないセレクタが含まれていると、ルールセットごと無効になってしまいます。
寛容なセレクタリスト(Forgiving Selector List)を使用すると、ルールセットは有効なままで、ブラウザが対応していないセレクタのみ無視されます。
擬似クラスである :is()
、:where()
、:not()
、そして :has()
は寛容なセレクタリストです。
:has()
は現時点では Firefox が非対応なので、セレクタリストで使うのであれば :is()
や :where()
のなかで使用すると安全性を高めることができます。
子孫要素を条件に祖先要素までたどり、そこから別の子孫要素にスタイルを反映することが可能です。以下はボタンを押下すると離れた要素が点灯するサンプルです。
このように、HTML の構造上は離れた要素であっても、祖先要素を経由してスタイルを変更することが可能になります。以下にコードを抜粋します。
ここでさらに、最後のセレクタリストの :has()
に注目します。
.projects:has([aria-controls="rectgraph"]:active) #rectgraph {
opacity: 1;
}
このセレクタを翻訳すると「押下された [aria-controls="rectgraph"]
を .projects
が持っているときに、子孫要素の #rectgraph
に opacity: 1
を指定する」と解釈できます。
このテクニックの難点としては、上記のコードのように要素の数だけセレクタリストが膨れ上がるという点です。コードを書く上で非効率ですし、メンテナンス性も悪くなります。
フォームの簡易検証の例を取り上げます。
以下は「名前」と「メールアドレス」の 2 つの項目を含むフォームですが、どちらのフィールドも必須(requied
)で、後者には type="email"
を指定しているため、メールアドレスの形式で入力する必要があります。
入力した内容の検証結果に応じてスタイルが変わるのが確認できると思いますが、:has()
を使用した以下のコードで実現しています。
このように、入力フィールドの親要素である .form-item
に対して :has()
を使用して、ユーザの入力内容の正否に応じたスタイルを出し分けています。
ちなみに、ここで擬似クラスの :valid
と :invalid
を使うと、入力する前の値が空の状態でも :invalid
になってしまうので、ユーザが入力してから評価される :user-valid
と :user-invalid
を使用しています。
詳細は、以下のミツエーリンクスさんの記事をご参照ください。
2024/03/07 追記
:user-valid
と :user-invalid
は自動補完で入力すると発火しないため、こちらに対応するには明示的に autocomplete="off"
を指定する必要があります。
ただ、ブラウザによっては autocomplete="off"
でも自動補完の機能を完全に無効化できないので、結果的に JS の力を借りたほうがシンプルになるかもしれません。
ダークモードの切り替えボタンです。
:has()
擬似クラスを使用することで、チェックボックスのオン・オフに応じて、ライトモード・ダークモードに切り替えることができます。
モードを切り替えるボタンは input[type="checkbox"]
なので、このチェックボックスが :checked
されたときに、ダークモード用のスタイルに変わるという仕組みです。
なお、ここではサンプル用に .color-mode
に CSS 変数を定義していますが、サイト全体に適用するのであれば、:root
に対して定義することになるでしょう。
さらに、ページ遷移後もダークモードを維持するには、JS 経由で設定を localStorage
や Cookie に保存する必要があります。
セレクトメニューで選択した内容に応じて、表組みの行をフィルタリングする例です。
Live Demo
このデモは、お使いのブラウザでは対応していません 🙇♂️
No. | カテゴリ |
1 | Apple |
2 | Banana |
3 | Apple |
4 | Apple |
5 | Apple |
6 | Banana |
7 | Banana |
8 | Banana |
9 | Apple |
10 | Apple |
ここでも :checked
を利用して、対応する行を非表示にします。
CSS に注目すると、セレクトメニューで選択されている <option>
の値と一致しない行(<tr>
)を非表示にしているのがわかります。
このように CSS のみでフィルタ機能が実現できますが、フィルタ項目の増減に応じて、セレクタリストのメンテナンスが必要になるのが難点です。項目の増減が想定される場合には、JS 経由で CSS を動的に生成するのが現実的かもしれません。
ナビゲーションのリンクにホバーすると、下線のスタイルが追従するスタイルの例です。:hover
と :focus-visible
で追従するようにしています。
以下にコードを抜粋します。
.nav-chaser
が追従する下線で、変数 --distance
が移動距離(translate
)を管理しています。初期値は -1
なので、表示領域の外側に位置しています。
そして、何番目の項目がホバー、またはフォーカスされたかによって、この変数 --distance
の値を変えることで移動距離が変化し、追従しているように見えるという仕組みです。
ちなみに、上記の例ではナビ項目や下線の横幅を 25%
で固定していますが、任意の幅になる場合には、動的に下線の幅を変更するために JS の力が必要になるかもしれません。
なお、任意の幅であっても、将来的には CSS Anchor Positioning によって、JS を使わないで実現可能になると考えられます4。
4 つの選択肢から正解を選ぶクイズコンテンツです。全 3 問で「Next」ボタンを選択すると次の問題に遷移します。最後の問題の「Back」ボタンを選択すると 1 問目に戻り、チェックがすべてリセットされます。
「Next」ボタンは実際には <input type="checkbox">
で実装されており、「Back」ボタンは <button type="reset">
です。
デフォルトは不正解のスタイルを指定しておき、このスタイルはチェックボックスが選択されたとき(:checked
)に反映されます。そして、正解のチェックボックスには data-correct
属性を付与しておき、この場合のスタイルを上書きしています。
「Next」ボタンを選択したときの遷移は、以下の CSS で実現しています。
/* レイアウト */
.quiz {
display: grid;
overflow: hidden;
}
.quiz-section {
grid-area: 1 / 1;
transition: translate 0.4s ease;
}
/* 2 問目以降の問題を表示外(右)に移動 */
.quiz-section + .quiz-section {
translate: 100% 0;
}
/* 「Next」をチェックした問題を表示外(左)に移動 */
.quiz-section:not(#_):has(.quiz-next:checked) {
translate: -100% 0;
}
/* 「Next」をチェックした問題の、次の問題を表示 */
.quiz-section:has(.quiz-next:checked) + .quiz-section {
translate: 0 0;
}
2 問目以降は translate: 100% 0
で外側に配置しておき、「Next」ボタンがチェックされると、この translate
の値が変化し、次の問題がスライドしながら現れる仕組みです。
なお、セレクタで使用している :not(#_)
は CSS の詳細度を上げるためのハックです。
既知の問題として、「Next」ボタンを選択しなくても、キーボードの Tab キーで移動していくと次の問題に移動できてしまい、表示上の崩れが発生してしまいます。
カードをめくって絵柄をそろえる、いわゆる「神経衰弱」です。
カードは <input type="checkbox">
で実装されており、CSS の :has()
と :checked
の組み合わせにより以下の処理をしています。
- カードを選択(
:checked
)すると裏返り、絵柄が表示される
- 絵柄がペアでそろうとチェックマークが付き、そのカードは選択不可になる
- すべてのカードをめくると背景色が変化する
- 「リセット」ボタンを選択すると初期状態に戻る(
<button type="reset">
)
本来であれば、めくった絵柄のペアが違ったときにカードが裏面に戻る機能も欲しいところですが、CSS だけでは実現できないため実装していません。
説明のために一部改変していますが、以下に :has()
に関連するコードを抜粋します。
CSS 変数の --match
でペアがそろった状態を管理しています。value
が同じ要素がチェックされたときに、この CSS 変数の値を変更してマッチしたスタイルを反映する仕組みです。
この部分をさらに詳しく見ると、親要素 .memory
と、その子要素 .memory-item
からなるセレクタだということがわかります。
.memory:not(:has([value="1"]:not(:checked))) .memory-item:has([value="1"]) {
--match: 1;
pointer-events: none;
}
前者は「value="1"
がチェックされていない要素を持っていない」と二重否定なのでわかりづらいですが、裏返すと「value="1"
がチェックされている要素を持っている」となります。
後者は子孫要素自身が「value="1"
がチェックされている要素を持っている」です。
この 2 つの条件を組み合わせて、value
の値が一致するすべての要素が :checked
されているときに、.memory-item
の CSS の変数が切り替わり、スタイルが反映される仕組みです。
なお、この記憶力ゲームは基本的には CSS で実装していますが、初期状態と「リセット」ボタンを選択したときにカードをシャッフルしており、その部分のみ JS を使用しています。
当たりを引かないようにタルにスティックをさしていくゲームです。
スティックをさすごとに 1000 ポイント加算され、当たりを選択するとゲームオーバーです。「RESET」ボタンを選択すると初期状態に戻ります。
仕組みとしてはクイズや記憶力ゲームの応用です。スティックは <input type="checkbox">
でマークアップされており、当たりを選択(:checked
)したときに飛び出す仕掛けが動くように CSS を記述しています。
ここではかなり簡略化していますが、基本的にはこの [data-stick="hit"]
が :checked
されたタイミングで、さまざまな仕掛けが動く仕組みです。
スコアの変化は counter-increment
を使用しており、こちらもスティックの選択(:checked
)をきっかけに加算されます。
なお、こちらも記憶力ゲームと同様で、初期状態と「RESET」ボタンを選択したときに当たりのスティックをシャッフルしており、その部分のみ JS を使用しています。
加えて、チェックした後の選択を無効にするために pointer-events: none
を指定しています。しかし、あくまでマウスのようなポインティングデバイスによる操作だけが対象のため、キーボードでは引き続き操作できるという裏技(?)があります。
これまで JS でなければできなかったことが、:has()
擬似クラスの登場により CSS のみで実現可能になり、その表現力は絶大です。
しかし、いくつかの例で見てきたように、セレクタリストの記述が複雑になったり、メンテナンス性に問題を含んでいたり、場合によっては JS との連携が必要な場面もあります。
:has()
が使えるようになるからといって、手当たり次第に CSS に置き換えられるわけではなく、CSS と JS にはそれぞれに得意な領域があることを認識し、目的や文脈に応じて使い分け、互いに補い合うことが大切です。
:has()
を使ったサンプルを考えるのは楽しく、ついつい長い記事になってしまいましたが、:has()
が秘めている可能性はここで取り上げた例にはとどまらず、まだまだ多くの画期的な使い方が見つかるはずです。
より多くの実装例を見たい場合には、以下の参考文献のリンク先を確認してみてください。
本記事の作成にあたり、以下のウェブページを参考にしました。