乗りこなせ! モダンフロントエンド

新しい擬似クラス:has()⁠:is()⁠:where()を使いこなそう [CSS Modern Features no.1]

本連載について

はじめまして! サイボウズ フロントエンドエキスパートチームの麦島です。

本連載では、Webフロントエンドに関してもう一歩踏み込んだ知識について、サイボウズ フロントエンドエキスパートチームのメンバーによって不定期で解説記事を掲載していきます。モダンな仕様の紹介・普段使っているライブラリのコア部分で何が行われているのかの解説・ハンズオンなど、さまざまな内容でお届けする予定です。

CSSの進化

本連載での最初のコンテンツは「CSS Modern Features」です。

CSSの表現力は年々新しい仕様の策定や実装とともに進化しています。従来であれば複雑なCSS定義が必要であったものが簡潔に表現できたり、JavaScriptを必要とした実装をCSSだけで表現できるケースも増えています。また、広く採用されてきたclass属性を用いた命名ルールやファイル分割といった設計手法を根本から覆すポテンシャルを持つ機能も利用可能となっています。

その中でも特にインパクトが大きい機能をいくつかピックアップして紹介します。2024年現在でモダンブラウザの最新版であれば利用可能な機能から、数年内に新たに使える見込みの高い機能まで幅広く取り上げていく予定です。

新しいCSS擬似クラス

第一回となる今回のテーマは「CSS 擬似クラス」です。擬似クラスは、CSSセレクタにおいて、要素が特定の条件であることを示すために使用されます。従来から広く利用されているものとしては、ホバー状態を表す:hoverや、フォーカス状態を表す:focus、特定の条件を除外する:not()などが挙げられます。

今回は、最近新しく利用可能となった擬似クラスの中でも特に押さえておくべきものを紹介します[1]

:has()擬似クラス/子孫要素に応じたスタイル適用

:has()は、特定要素に対して指定した相対的な要素が存在するかを表現できる CSS 擬似クラスです。:has()を用いることで、今までは難しかった「子孫要素の状態に合わせた親要素へのスタイル適用」に近いことを実現できます。

/*
  img要素を子孫に持つ
  a要素のカラーを変更
*/
a:has(img) {
  color: red;
}

:has()の利用例/セクション分割された入力フォームのスタイリング

:has()について利用例を確認してみましょう。⁠チェックボックスがチェックされている場合に、該当するラベルのスタイルを変える」という仕様の実現を例に考えます。画面は次のようなイメージです。

アクティブなセクションにスタイルを適用する入力フォーム

この例では3つのチェックボックスが存在しており、それぞれがlabel要素でラップされています。

<label> <input type="checkbox" /> HTML </label>
<label> <input type="checkbox" /> JavaScript </label>
<label> <input type="checkbox" /> CSS </label>

たとえば「JavaScript」のチェックボックスがチェックされている場合には、⁠JavaScript」を含むlabel要素のスタイルを切り替えます。

:has()を用いないJavaScriptによる実装

CSSではチェック状態は:checked擬似クラスで判断できます。しかし:has()を用いない場合、CSSのみで親に該当する要素に対してスタイルを適用する方法はありません。従来こういったケースでは、JavaScriptでチェックボックスの状態変化を監視し、CSSと組み合わせて対処されていました。

// チェック時にラベルのスタイルを変更するJavaScript
document.querySelectorAll("input").forEach((el) => {
  el.addEventListener("change", () => {
    // チェック状態に応じてラベルにスタイル用クラスを付与
    el.checked
      ? el.closest("label").classList.add("checked")
      : el.closest("label").classList.remove("checked");
  });
});
/* 通常時のラベルのスタイル */
label {
  background-color: #d3d3d3;
  border-style: solid;
  border-color: transparent;
}
/* チェック時のラベルのスタイル */
label.checked {
  background-color: #ffffff;
  border-color: #0000ff;
}

:has()を用いたCSSのみでの実装

それでは、:has()の例を見てみましょう。先ほどのケースで:has()を用いると、CSSのみで期待するスタイルを実現できます。

/* 通常時のラベルのスタイル */
label {
  background-color: #d3d3d3;
  border-style: solid;
  border-color: transparent;
}
/* 子孫要素がチェックされているときのラベルのスタイル */
label:has(:checked) {
  background-color: #ffffff;
  border-color: #0000ff;
}

これはlabel要素の中でも「配下に:checked擬似クラスを持つinput要素を含む」という条件を満たす場合に適用されます。子孫要素の状態に応じてスタイルを適用できることで、CSSのみで表現可能な範囲が広がります。

:has()の利用例/レイアウトのスイッチ

もう一つ:has()の利用例として、条件に応じたレイアウトのスタイル切り替えを考えてみましょう。

次のような条件での実装を想定します。

  • ページにはサイドメニューが存在することがある
  • サイドメニューが存在する場合、横並びの2カラムレイアウトとする
  • サイドメニューが存在しない場合、1カラム表示で全体のpaddingを広げる
  • レイアウトにはgridを利用する

サイドメニューが存在する場合のHTMLは次のような内容です。

<div class="layout">
  <!-- サイドメニュー -->
  <nav>
    <h2>Navigation</h2>
    <ul>
      <li><a href="/html">HTML</a></li>
      <li><a href="/js">JavaScript</a></li>
      <li><a href="/css">CSS</a></li>
    </ul>
  </nav>

  <!-- メインコンテンツ -->
  <main>
    <h1>Main Contents</h1>
    <p>This is description</p>
  </main>
</div>

このようなケースでも:has()を用いることで、サイドメニューの有無に応じたスタイルの切り替えが容易に行えます。CSSは次のような内容です。

.layout {
  display: grid;
  grid-template-columns: 1fr;
  padding: 8px;
}

/* サイドメニューが存在する場合 */
.layout:has(nav) {
  grid-template-columns: 200px 1fr;
  padding: 16px;
}

サイドメニューの有無に応じて新たにクラスを付与することなく、:has()を用いてCSSのみでレイアウトの切り替えが実現できました。

:is()擬似クラス/複数セレクタの一括指定

:is()は、複数のセレクタのうちいずれかがマッチする要素を選択できる擬似クラスです。これにより、従来よりも簡潔かつ重複の少ないCSSを記述できます。

/* 
  foo、bar、bazのいずれかのクラスを持つ
  要素の子孫のa要素のカラーを変更
*/
:is(.foo, .bar, .baz) a {
  color: red;
}

:is()の利用例/ネストした複数条件の要素へのスタイリング

次のような仕様を満たすCSSを例に考えます。

  • ヘッダとフッタの直下に存在するリンクとテキストは、文字サイズを小さくする
  • 特定のページにのみ適用し、該当ページのbody要素には.page-aまたは.page-bクラスが付与されている

HTMLは次のような内容です。

<body class="page-a">
  <div class="wrapper">
    <header>
      <span>Header | </span>
      <a href="#">Top</a>
      <a href="#">Articles</a>
      <a href="#">Search</a>
    </header>
    <main>
      <span>Main | </span>
      <a href="#">Article1</a>
      <a href="#">Article2</a>
      <a href="#">Article3</a>
    </main>
    <footer>
      <span>Footer | </span>
      <a href="#">Help</a>
      <a href="#">Contact</a>
    </footer>
  </div>
</body>

期待する見た目は次のとおりです。

ヘッダとフッタだけスタイルを変える

:is()を用いない場合のCSS定義

:is()を用いない場合、次のようなCSS定義が必要です。

/* ヘッダとフッタ配下に同じスタイルを適用するCSS */
.page-a header > a,
.page-a header > span,
.page-a footer > a,
.page-a footer > span,
.page-b header > a,
.page-b header > span,
.page-b footer > a,
.page-b footer > span {
  font-size: 12px;
}

定義に重複が多いですね。実際のプロダクトコードでは、さらにネストが深くなることもありえます。また、重複が多いことに起因し、修正時のコストも大きくなりやすいです。たとえば、⁠新たにpage-cにも同じスタイルを適用したい」といった変更が必要になると、丸々コピーして似たような定義を増やす必要があるでしょう。

:is()を用いた場合のCSS定義

先程のケースにおいて:is()を用いると、同等のCSSを次のように表現できます。

:is(.page-a, .page-b) :is(header, footer) > :is(a, span) {
  font-size: 12px;
}

簡潔に表現できており、重複も少ないことがわかります。対応ページを増やしたくなっても、:is()に指定するセレクタの書き換えだけで済みます。こういった課題に対して、従来ではPostCSSやSass(Syntactically Awesome Style Sheets)などのCSSプリプロセッサで対応するケースもありましたが、:is()を用いると、純粋なCSSのみで実装可能になります。

:is()の利用例/複数の擬似クラスに対するスタイル適用

他にも、複数の擬似クラスに対してまとめてスタイルを適用する際にも:is()が役立ちます。

たとえば、⁠ボタンとして振る舞うbutton要素とa要素に対してホバー時とフォーカス時に同じスタイルを適用したい」といった場合、:is()を用いると次のように表現できます。

:is(a, button) {
  border-width: 1px;
  border-style: solid;
  border-color: gray;
}

:is(a, button):is(:hover, :focus) {
  border-color: red;
}

「複数の状況で同じスタイルを適用したい」というシチュエーションが発生した場合には:is()で効率よく記述できないか検討してみるとよいでしょう。

:where()擬似クラス/詳細度を持たない一括指定

:is()と似た擬似クラスとして、:where()があります。:where():is()と基本的な部分は同じで、指定された複数セレクタにマッチする要素を選択できます。

/* 
  foo、bar、bazのいずれかのクラスを持つ
  要素の子孫のa要素のカラーを変更
*/
:where(.foo, .bar, .baz) a {
  color: red;
}

これだけだと:is()と同じように見えます。しかし、唯一かつ大きな差異として、:where()に記述したCSSは常に詳細度が0となるという特徴を持ちます。どういった際に恩恵を受けられるのか、例を見てみましょう。

:where()の利用例/共通CSSの定義と上書き

CSS実装において、広範囲で共通となる定義を用意し、全ページで読み込むケースはよく見られます。先述の:is()の例と同様のHTMLに対して、次のようにヘッダとフッタ配下のリンクのフォントサイズを指定する共通CSSが定義されているとします。

/* 共通CSS */
header a,
footer a {
  font-size: 12px;
}

問題点⁠詳細度で上回れないと上書きできない

この共通CSSをロードした状態で、⁠特定ページでは画面に含まれるリンクをすべて大きく表示したい」という実装が必要になったと仮定し、次のようなCSSをページ固有で定義してみます。

/* ページ固有のCSS */
a {
  font-size: 20px;
}

しかしこれはうまくいきません。次の図のように、ヘッダとフッタ配下のリンクについては文字サイズが小さいまま表示されてしまいます。

ページ固有CSSが期待どおり適用できない例

原因を理解するために、CSS詳細度の基本を一度おさらいしておきましょう。

CSS詳細度

1つの要素に対してCSSはいくつも定義できますが、カスケードと呼ばれるルールに従い、セレクタや定義順などさまざまな条件によってスタイルが適用される優先順位は変化します。その優先順位を判断するためにブラウザで用いられるアルゴリズムのひとつがCSS詳細度です。

CSS詳細度では、セレクタを「ID」⁠クラス/属性/擬似クラス」⁠要素/擬似要素」の順に分類しそれぞれをカウントした値を左から並べたものが重みとして扱われ、より重みの大きいセレクタが優先されます。重みは表現上、0-1-20,1,2といった形で記述されることがあります。

仮に#app .main.content pといったセレクタの場合、詳細度は1-2-1となります(詳細度についてさらに詳しく知りたい方はMDN Web Docsが参考になります⁠⁠。

CSS詳細度における重み付けのイメージ

例に挙げた共通CSSでのヘッダ内リンクへの定義では、セレクタはheader aです。IDやクラスの指定はなく要素を2つ指定しており、これは詳細度で表すと0-0-2です。一方、ページ固有の上書き用CSSのセレクタはaです。要素1つの指定で、これは詳細度で表すと0-0-1です。重みは左から順に比較され、一番右側の21の差により、ページ固有CSSより共通CSSの重みのほうが大きいと判断されます。よって、aのみのセレクタでは共通CSSの定義を詳細度で上回れず、文字サイズは小さいままの表示となってしまいます。

/* 共通CSS */
/* 詳細度: 0-0-2 */
header a,
footer a {
  font-size: 12px;
}

/* ページ固有のCSS */
/* 詳細度: 0-0-1 */
a {
  font-size: 20px;
}

:where()を利用した場合

では、共通CSSを:where()を用いる形に変更してみます。

/* :where()を用いた共通CSS */
:where(header, footer) a {
  font-size: 12px;
}

実際の見た目は次の通りです。

共通CSSを:where()で定義した例

期待通りページ固有のCSSが適用され、文字サイズが大きくなりました。

:where(header, footer)のCSS詳細度は0として扱われます。:where()を利用しない場合の詳細度は0-0-2でしたが、:where()を用いるとaのみの重みで計算されるため0-0-1となります。ページ固有CSSと共通CSSで詳細度が同じとなりますが、詳細度が同じ場合、よりあとで定義しているものが適用されます。ページ固有CSSを共通CSSよりあとに定義するか、ファイルが分割されている場合にはあとで読み込むことで、期待どおり上書きできます。

/* :where()を用いた共通CSS */
/* 詳細度: 0-0-1 */
:where(header, footer) a {
  font-size: 12px;
}

/* ページ固有のCSS */
/* 詳細度: 0-0-1 */
a {
  /*
   * 詳細度が同じため、
   * より後で定義されているこちらが適用される
   */
  font-size: 20px;
}

CSS実装時、詳細度に起因する問題と遭遇することは珍しくありません。詳細度で上回るためだけにclass属性を付与したり、最悪!importantで強制的に適用するケースもありえます。:where()をうまく活用すると、上書きする側では従来より詳細度を意識する必要が減り、より保守性の高い形でCSSを定義できます。

昨今ではライブラリやフレームワークが内部で:where()を活用しているケースも珍しくありません。たとえば、静的サイトジェネレータのAstroではコンポーネントのスタイルに:where()を利用していたり、Panda CSSMUITailwind CSSといったライブラリでもGitHubリポジトリ内のコードを見てみると各所で:where()の利用を確認できます。既存コードの理解を深めるうえでも、:where()の挙動を把握しておくことは大事だと言えるでしょう。

ブラウザでのサポート状況

今回紹介した擬似クラスについて、本記事掲載時点でのメジャーブラウザにおける利用可能バージョンは次の通りです。これ以降のバージョンであれば利用可能です。

機能 Chrome Edge Safari Firefox
:has() 105 105 15.4 121
:is() 88 88 14 78
:where() 88 88 14 78

まとめ

今回は:has():is():where()の3つの擬似要素を紹介しました。

JavaScriptを用いずにCSSのみでスタイリング可能な幅が増えることで、ファイルサイズの軽減や実行速度の改善といったユーザー体験にもつながります。また、SSR(Server-Side Rendering)を導入しているケースなどでは、JavaScriptの実行を待たずにスタイルを適用できることで初期描画時のスタイル適用によるガタツキ(Cumulative Layout Shift)も発生しづらくなる、といった効果もあります。使えるシチュエーションは少なくないため、理解しておいて損はない機能かと思います。ぜひチャンスがあれば試してみてください。

おすすめ記事

記事・ニュース一覧