CSS の :has() 擬似クラスは、すでに多くのブラウザではサポートされていますが、これまでは Firefox が非対応のため、実案件で使用するには限定的でした。

しかし、2023 年 12 月 19 日リリース予定の Firefox 121 で、この :has() 擬似クラスがサポートされる予定となっており12、ようやく主要ブラウザでのサポートが出揃います。

この記事では、CSS の表現力をさらに進化させる :has() について、10 個のサンプルを取り上げながら、その仕組みや使い方を説明します。

:has() 擬似クラスの基本

見出し「:has() 擬似クラスの基本」

:has() は W3C の仕様では「Selectors Level 4」に属しています3。この仕様自体は Working Draft の状態ですが、:not():is():where() のようにすでに広く使われているセレクタも含まれています。

:has() が登場する以前の結合子を使用したセレクタでは、子孫要素や兄弟要素に対してスタイルを適用できましたが、特定の要素からさかのぼって祖先要素や前方の要素に適用することはできませんでした。

:has() は、引数として渡したセレクタの条件に一致する要素に適用されるため、祖先要素や前方の要素にスタイルを当てることができるようになります。

`子孫要素に <img>` を持つ `<figure>` にスタイルを適用する例の図
包含する要素を条件にスタイルを適用できる
直後に `<h2>` を持つ `<p>` にスタイルを適用する例の図
隣接する要素を条件にスタイルを適用できる

さらには、祖先要素までさかのぼって、別の子孫要素にスタイルを指定したり、:checked などのインタラクティブな擬似クラスを使用することで、CSS の可能性が一気に広がります。

子孫要素に `<input>` を持つ `<form>` の子孫要素の `<p>` にスタイルを適用する例の図
離れた要素を条件にスタイルを適用できる
子孫要素に `:checked` を持つ `<form>` の子孫要素の `<div>` にスタイルを適用する例の図
インタラクティブな擬似クラスに応じて、離れた要素にスタイルを適用できる

これらの点を踏まえたうえで、実例を見ていきましょう。

本記事で紹介するサンプルは、あくまで :has() の可能性を追求するためのものであり、実用的ではない例も含みます。特にインタラクティブな機能を含むサンプルでは、アクセシビリティの確保が不十分なものも含まれていますのでご注意ください。

まずは、包含する子孫要素に応じて、祖先要素にスタイルを指定する例を見ていきます。以下は子孫要素に含まれるカテゴリごとに配色を変更するコンポーネントの例です。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

ウェブ標準

Lorem ipsum dolor sit amet.

アクセシビリティ

Consectetur adipisicing elit. sed do eiusmod tempor.

パフォーマンス

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

アクセシビリティ

Ut enim ad minim veniam, quis nostrud exercitation ullamco.

ウェブ標準

Laboris nisi ut aliquip ex ea commodo consequat.

パフォーマンス

Duis aute irure dolor in reprehenderit in voluptate velit esse.

このようにカテゴリごとに配色を変更したいとき、:has() を使用することで、含まれる子孫要素を条件に祖先要素までさかのぼってスタイルを指定することが可能です。

カテゴリごとに配色を変更する例
<div class="card-list">
  <div class="card">
    <p class="card-category --coding">
      ウェブ標準
    </p>
    <p class="card-desc">Lorem ipsum dolor sit amet.</p>
  </div>
  <div class="card">
    <p class="card-category --a11y">
      アクセシビリティ
    </p>
    <p class="card-desc">Consectetur adipisicing elit. sed do eiusmod tempor.</p>
  </div>
  <div class="card">
    <p class="card-category --perf">
      パフォーマンス
    </p>
    <p class="card-desc">Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
  </div>
  <!-- ... -->
</div>

<style>
.card {
  --accent: hsl(180 100% 22%);

  padding: 16px;
  border-block-start: solid 8px var(--accent);
  position: relative;
  z-index: 1;
  background: #fff;
  color: #042020;
}
.card:has(.--a11y) {
  --accent: hsl(304 100% 22%);
}
.card:has(.--perf) {
  --accent: hsl(228 100% 22%);
}
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background-color: var(--accent);
  opacity: 0.2;
}
.card-category {
  background-color: var(--accent);
  color: #fff;
}
</style>

ここでは、まず CSS 変数 --accent でカテゴリのカラーを定義しています。そして、:has() 擬似クラスを使用して、含まれるカテゴリに応じてこの --accent の値を変更しています。

カテゴリに応じて CSS 変数の値を変更
.card:has(.--a11y) {
  --accent: hsl(304, 100%, 22%);
}
.card:has(.--perf) {
  --accent: hsl(228, 100%, 22%);
}

このように、包含する子孫要素を条件に祖先要素のスタイルを変更することが可能です。

続いて、隣接する要素を条件にスタイルを適用する例です。

<h2> 見出しに上余白を指定しており、その見出しが単体で使われるときと、その上にコピー(<p class="copy">)が配置されるパターンが存在するケースで考えてみます。

以下はライブデモです。ホバーすると余白の領域が表示されます。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。

あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。

コピー

このように、見出し単体でも、その上にコピーが配置されていても上余白のサイズは同じです。該当部分のコードを見てみましょう。

見出し上の余白を指定する例
<section>
  <p>あのイーハトーヴォのすきとおった風...</p>
  <h2>見出し</h2>
</section>
<section>
  <p>あのイーハトーヴォのすきとおった風...</p>
  <p class="copy">コピー</p>
  <h2>見出し</h2>
</section>

<style>
:where(h2, .copy:has(+ h2)) {
  margin-block-start: 40px;
}
.copy + h2 {
  margin-block-start: 0;
}
</style>

ここでは、:where() を使用して 2 つのセレクタを指定していますが、2 番目の .copy:has(+ h2) がポイントで、h2 に隣接する .copy が対象となっています。

つまり、h2 に隣接する直前の copy に対してスタイルを適用しています。

寛容なセレクタリスト

見出し「寛容なセレクタリスト」

CSS のセレクタはカンマ(,)で区切ってリスト化することができます。このセレクタリストはブラウザが対応していないセレクタが含まれていると、ルールセットごと無効になってしまいます。

正しいセレクタリスト
.foo,
.bar,
.baz {
  background-color: green;
}
不正な擬似クラスを含むセレクタリスト
.foo,
.bar:invalid-pseudo,
.baz {
  background-color: green;
}

寛容なセレクタリスト(Forgiving Selector List)を使用すると、ルールセットは有効なままで、ブラウザが対応していないセレクタのみ無視されます。

寛容なセレクタリストを使用する例
:is(
  .foo,
  .bar:invalid-pseudo,
  .baz
) {
  background-color: green;
}

擬似クラスである :is():where():not()、そして :has() は寛容なセレクタリストです。

:has() は現時点では Firefox が非対応なので、セレクタリストで使うのであれば :is():where() のなかで使用すると安全性を高めることができます。

:has() を寛容なセレクタリストのなかで使用する例
:is(
  .foo,
  .bar:has(img),
  .baz
) {
  background-color: green;
}

子孫要素を条件に祖先要素までたどり、そこから別の子孫要素にスタイルを反映することが可能です。以下はボタンを押下すると離れた要素が点灯するサンプルです。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

RECTBEATS のロゴ
RECTGRAPH のロゴ
I/O 3000 のロゴ
MOJI-ROLL のロゴ
LOGO LACRA のロゴ

このように、HTML の構造上は離れた要素であっても、祖先要素を経由してスタイルを変更することが可能になります。以下にコードを抜粋します。

離れた要素にスタイルを指定するの例
<div class="projects">
  <div class="projects-list">
    <!-- ... -->
    <div class="projects-item">
      <img src="..." alt="RECTGRAPH のロゴ" id="rectgraph">
    </div>
    <div class="projects-item">
      <img src="..." alt="I/O 3000 のロゴ" id="io3000">
    </div>
    <!-- ... -->
  </div>
  <div class="projects-controls">
    <!-- ... -->
    <button type="button" aria-controls="rectgraph" class="projects-btn">
      <span class="projects-btn-inner">RECTGRAPH</span>
    </button>
    <button type="button" aria-controls="io3000" class="projects-btn">
      <span class="projects-btn-inner">I/O 3000</span>
    </button>
    <!-- ... -->
  </div>
</div>

<style>
.projects-item img {
  opacity: 0.2;
  transition: opacity 0.2s ease;
}
.projects:has([aria-controls="rectbeats"]:active) #rectbeats,
.projects:has([aria-controls="rectgraph"]:active) #rectgraph,
.projects:has([aria-controls="io3000"]:active) #io3000,
.projects:has([aria-controls="moji-roll"]:active) #moji-roll,
.projects:has([aria-controls="logo-lacra"]:active) #logo-lacra {
  opacity: 1;
}
</style>

ここでさらに、最後のセレクタリストの :has() に注目します。

.projects:has([aria-controls="rectgraph"]:active) #rectgraph {
  opacity: 1;
}

このセレクタを翻訳すると「押下された [aria-controls="rectgraph"].projects が持っているときに、子孫要素の #rectgraphopacity: 1 を指定する」と解釈できます。

このテクニックの難点としては、上記のコードのように要素の数だけセレクタリストが膨れ上がるという点です。コードを書く上で非効率ですし、メンテナンス性も悪くなります。

例4: フォームの簡易検証

見出し「例4: フォームの簡易検証」

フォームの簡易検証の例を取り上げます。

以下は「名前」と「メールアドレス」の 2 つの項目を含むフォームですが、どちらのフィールドも必須(requied)で、後者には type="email" を指定しているため、メールアドレスの形式で入力する必要があります。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

入力した内容の検証結果に応じてスタイルが変わるのが確認できると思いますが、:has() を使用した以下のコードで実現しています。

フォームの簡易検証の例
<form class="form">
  <div class="form-item">
    <label for="example-name">名前</label>
    <input type="text" id="example-name" required>
  </div>
  <div class="form-item">
    <label for="example-email">メールアドレス</label>
    <input type="email" id="example-email" required>
  </div>
</form>

<style>
/* valid */
.form-item:has(:user-valid) {
  background-color: #e9f3ef;
}
.form-item:has(:user-valid)::after {
  content: '✅';
}
/* invalid */
.form-item:has(:user-invalid) {
  background-color: #fce8e8;
}
.form-item:has(:user-invalid)::after {
  content: '❌';
}
</style>

このように、入力フィールドの親要素である .form-item に対して :has() を使用して、ユーザの入力内容の正否に応じたスタイルを出し分けています。

ちなみに、ここで擬似クラスの :valid:invalid を使うと、入力する前の値が空の状態でも :invalid になってしまうので、ユーザが入力してから評価される :user-valid:user-invalid を使用しています。

詳細は、以下のミツエーリンクスさんの記事をご参照ください。

2024/03/07 追記

:user-valid:user-invalid は自動補完で入力すると発火しないため、こちらに対応するには明示的に autocomplete="off" を指定する必要があります。

ただ、ブラウザによっては autocomplete="off" でも自動補完の機能を完全に無効化できないので、結果的に JS の力を借りたほうがシンプルになるかもしれません。

ダークモードの切り替えボタンです。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

Lorem ipsum dolor sit amet consectetur adipisicing elit. Ducimus quaerat quae, ullam beatae voluptates sint expedita maiores molestiae asperiores cupiditate? Odio inventore exercitationem nemo dignissimos! Hic dicta voluptates aspernatur vitae.

:has() 擬似クラスを使用することで、チェックボックスのオン・オフに応じて、ライトモード・ダークモードに切り替えることができます。

ダークモードの例
<div class="color-mode">
  <p class="color-mode-text">
    Lorem ipsum dolor sit...
  </p>
  <input type="checkbox" role="switch" class="color-mode-switch">
</div>

<style>
.color-mode {
  --bg-color   : #fff;
  --text-color : #042020;
}
.color-mode-text {
  background-color: var(--bg-color);
  color: var(--text-color);
}
/* ダークモード */
.color-mode:has(.color-mode-switch:checked) {
  --bg-color   : #042020;
  --text-color : #cfdddd;
}
</style>

モードを切り替えるボタンは 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 を利用して、対応する行を非表示にします。

表組みの行をフィルタリングする例
<div class="filter">
  <div class="filter-header">
    <label for="filter-select" class="filter-label">Filter: </label>
    <select id="filter-select" class="filter-select">
      <option value="all">All</option>
      <option value="apple">Apple</option>
      <option value="banana">Banana</option>
    </select>
  </div>
  <table class="filter-table">
    <thead>
      <tr>
        <th>No.</th>
        <th>カテゴリ</th>
      </tr>
    </thead>
    <tbody>
      <tr class="filter-item --apple">
        <td>1</td>
        <td>Apple</td>
      </tr>
      <tr class="filter-item --banana">
        <td>2</td>
        <td>Banana</td>
      </tr>
      <!-- -->
    </tbody>
  </table>
</div>

<style>
.filter:has([value="apple"]:checked) .filter-item:not(.--apple),
.filter:has([value="banana"]:checked) .filter-item:not(.--banana) {
  display: none;
}
</style>

CSS に注目すると、セレクトメニューで選択されている <option> の値と一致しない行(<tr>)を非表示にしているのがわかります。

このように CSS のみでフィルタ機能が実現できますが、フィルタ項目の増減に応じて、セレクタリストのメンテナンスが必要になるのが難点です。項目の増減が想定される場合には、JS 経由で CSS を動的に生成するのが現実的かもしれません。

例7: 追従するホバー効果

見出し「例7: 追従するホバー効果」

ナビゲーションのリンクにホバーすると、下線のスタイルが追従するスタイルの例です。:hover:focus-visible で追従するようにしています。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

以下にコードを抜粋します。

追従するホバー効果の例
<nav class="nav">
  <ul class="nav-list">
    <li><a href="/">Home</a></li>
    <li><a href="/services/">Services</a></li>
    <li><a href="/projects/">Projects</a></li>
    <li><a href="/contact/">Contact</a></li>
  </ul>
  <span class="nav-chaser"></span>
</nav>

<style>
.nav {
  --columns: 4;
  --distance: -1;

  position: relative;
  overflow: hidden;
}
.nav-list {
  display: grid;
  grid-template-columns: repeat(var(--columns), 1fr);
}
.nav-chaser {
  inline-size: calc(100% / var(--columns));
  block-size: 4px;
  display: block;
  position: absolute;
  inset-block-end: 0;
  background-color: #007373;
  transition: translate 0.4s ease;
  translate: calc(100% * var(--distance)) 0;
  pointer-events: none;
}
.nav:has(li:nth-child(1) > :where(a:hover, a:focus-visible)) {
  --distance: 0;
}
.nav:has(li:nth-child(2) > :where(a:hover, a:focus-visible)) {
  --distance: 1;
}
.nav:has(li:nth-child(3) > :where(a:hover, a:focus-visible)) {
  --distance: 2;
}
.nav:has(li:nth-child(4) > :where(a:hover, a:focus-visible)) {
  --distance: 3;
}
</style>

.nav-chaser が追従する下線で、変数 --distance が移動距離(translate)を管理しています。初期値は -1 なので、表示領域の外側に位置しています。

そして、何番目の項目がホバー、またはフォーカスされたかによって、この変数 --distance の値を変えることで移動距離が変化し、追従しているように見えるという仕組みです。

ちなみに、上記の例ではナビ項目や下線の横幅を 25% で固定していますが、任意の幅になる場合には、動的に下線の幅を変更するために JS の力が必要になるかもしれません。

なお、任意の幅であっても、将来的には CSS Anchor Positioning によって、JS を使わないで実現可能になると考えられます4

4 つの選択肢から正解を選ぶクイズコンテンツです。全 3 問で「Next」ボタンを選択すると次の問題に遷移します。最後の問題の「Back」ボタンを選択すると 1 問目に戻り、チェックがすべてリセットされます。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

Q1 CSS の寛容なセレクタリストに含まれないものは次のうちどれか
Q2 CSS のカラー関数に含まれないものは次のうちどれか
Q3 CSS の container-type プロパティで指定できる値は次のうちどれか

「Next」ボタンは実際には <input type="checkbox"> で実装されており、「Back」ボタンは <button type="reset"> です。

クイズコンテンツの例
<form class="quiz">
  <div class="quiz-section">
    <dl class="quiz-list">
      <dt class="quiz-q">
        <span class="quiz-icon">Q1</span> CSS の寛容なセレクタリストに含まれないものは次のうちどれか
      </dt>
      <dd class="quiz-a">
        <label class="quiz-label">
          <!-- 正解のチェックボックス -->
          <input type="checkbox" class="quiz-checkbox" data-correct>:nth-child()
        </label>
      </dd>
      <dd class="quiz-a">
        <label class="quiz-label">
          <!--  不正解のチェックボックス -->
          <input type="checkbox" class="quiz-checkbox">:where()
        </label>
      </dd>
      <!-- ... -->
    </dl>
    <!-- 「Next」ボタン -->
    <label class="btn">
      Next<input type="checkbox" class="quiz-next">
    </label>
  </div>
  <!-- ... -->
</form>

<style>
.quiz {
  --correct  : #00885b;
  --incorect : #d00;
}
.quiz-label {
  border: solid 1px currentColor;
  background-color: #fff;
}
/* 不正解(デフォルト) */
.quiz-label:has(input:checked) {
  border-color: var(--incorect);
  background-color: color-mix(in srgb, var(--incorect) 10%, transparent);
}
/* 正解 */
.quiz-label:has(input[data-correct]:checked) {
  border-color: var(--correct);
  background-color: color-mix(in srgb, var(--correct) 10%, transparent);
}
</style>

デフォルトは不正解のスタイルを指定しておき、このスタイルはチェックボックスが選択されたとき(: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 だけでは実現できないため実装していません。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

説明のために一部改変していますが、以下に :has() に関連するコードを抜粋します。

記憶力ゲームの例
<form>
  <div class="memory">
    <!-- `value="1"` -->
    <label class="memory-item">
      <input type="checkbox" value="1">
      <img src="..." alt="カード" width="100" height="100" loading="lazy">
      <span class="memory-check"></span>
    </label>
    <!-- `value="2"` -->
    <label class="memory-item">
      <input type="checkbox" value="2">
      <img src="..." alt="カード" width="100" height="100" loading="lazy">
      <span class="memory-check"></span>
    </label>
    <!-- `value="1"` -->
    <label class="memory-item">
      <input type="checkbox" value="1">
      <img src="..." alt="カード" width="100" height="100" loading="lazy">
      <span class="memory-check"></span>
    </label>
    <!-- `value="2"` -->
    <label class="memory-item">
      <input type="checkbox" value="2">
      <img src="..." alt="カード" width="100" height="100" loading="lazy">
      <span class="memory-check"></span>
    </label>
    <!-- ... -->
  </div>
  <button type="reset" class="btn">リセット</button>
</form>

<style>
/* ペアがマッチしたときの変数を用意 */
.memory-item {
  --match: 0;
}
/* チェック済みの `value` が同じ場合に CSS 変数の値を変更 */
.memory:not(:has([value="1"]:not(:checked))) .memory-item:has([value="1"]),
.memory:not(:has([value="2"]:not(:checked))) .memory-item:has([value="2"]),
.memory:not(:has([value="3"]:not(:checked))) .memory-item:has([value="3"]),
.memory:not(:has([value="4"]:not(:checked))) .memory-item:has([value="4"]),
.memory:not(:has([value="5"]:not(:checked))) .memory-item:has([value="5"]),
.memory:not(:has([value="6"]:not(:checked))) .memory-item:has([value="6"]) {
  --match: 1;
  pointer-events: none;
}
/* CSS 変数によってチェックマークが表示される */
.memory-check {
  opacity: var(--match);
}
.memory-check::after {
  content: '✅';
}
/* すべてのカードが選択された状態 */
.memory:not(:has(input:not(:checked))) {
  background-color: #007373;
  transition: background-color 0.4s ease;
}
</style>

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」ボタンを選択すると初期状態に戻ります。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

キャラクター
タル

DOKI DOKI

Download

仕組みとしてはクイズや記憶力ゲームの応用です。スティックは <input type="checkbox"> でマークアップされており、当たりを選択(:checked)したときに飛び出す仕掛けが動くように CSS を記述しています。

運試しゲームの例
<div class="game">
  <div class="game-target">
    <img src="..." alt="キャラクター" width="54" height="100" loading="lazy">
  </div>
  <div class="game-stick">
    <label class="game-stick-label">
      <input type="checkbox" data-stick="true">
    </label>
    <label class="game-stick-label">
      <!-- 当たりのスティック -->
      <input type="checkbox" data-stick="hit">
    </label>
    <label class="game-stick-label">
      <input type="checkbox" data-stick="true">
    </label>
    <!-- ... -->
    <p class="game-score" aria-live="polite"></p>
  </div>
</div>

<style>
.game-target {
  transition: translate 1s steps(10);
}
.game:has([data-stick="hit"]:checked) .game-target {
  translate: 0 -100px;
}
</style>

ここではかなり簡略化していますが、基本的にはこの [data-stick="hit"]:checked されたタイミングで、さまざまな仕掛けが動く仕組みです。

スコアの変化は counter-increment を使用しており、こちらもスティックの選択(:checked)をきっかけに加算されます。

CSS でスコアを加算する仕組み
.game-stick {
  counter-reset: score;
}
[data-stick="true"]:checked {
  counter-increment: score 1000;
}
.game-score::after {
  content: counter(score);
}
/* 初期値 */
.game:not(:has([data-stick="true"]:checked)) .game-score::after {
  content: '0000';
}

なお、こちらも記憶力ゲームと同様で、初期状態と「RESET」ボタンを選択したときに当たりのスティックをシャッフルしており、その部分のみ JS を使用しています。

加えて、チェックした後の選択を無効にするために pointer-events: none を指定しています。しかし、あくまでマウスのようなポインティングデバイスによる操作だけが対象のため、キーボードでは引き続き操作できるという裏技(?)があります。

これまで JS でなければできなかったことが、:has() 擬似クラスの登場により CSS のみで実現可能になり、その表現力は絶大です。

しかし、いくつかの例で見てきたように、セレクタリストの記述が複雑になったり、メンテナンス性に問題を含んでいたり、場合によっては JS との連携が必要な場面もあります。

:has() が使えるようになるからといって、手当たり次第に CSS に置き換えられるわけではなく、CSS と JS にはそれぞれに得意な領域があることを認識し、目的や文脈に応じて使い分け、互いに補い合うことが大切です。

:has() を使ったサンプルを考えるのは楽しく、ついつい長い記事になってしまいましたが、:has() が秘めている可能性はここで取り上げた例にはとどまらず、まだまだ多くの画期的な使い方が見つかるはずです。

より多くの実装例を見たい場合には、以下の参考文献のリンク先を確認してみてください。

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. Firefox Nightly 121.0a1 Release Notes

  2. :has() CSS relational pseudo-class | Can I use…

  3. Selectors Level 4 | W3C

  4. Future CSS: Anchor Positioning | Roman Komarov