CSSは私たちの操作をリッスンしています。いいえ、そういうことではありません。むしろ、CSSはJavaScriptイベントに対応するための擬似クラスをますます増やしており、JavaScript自体で対応する必要がなくなっています。ただし、擬似クラスは状態を追跡するものでありイベントではないものの、CSSの文脈ではイベントリスナーのように感じられることがあります(実際にはCSSではあまり関係ありません)。

とはいえ、CSSとは一体何なのでしょうか? たとえば、Animation Triggers仕様にはevent-triggerの提案があり、基本的にイベントをリッスンしてアニメーションをトリガーします。個人的には、この構文はそれ以上のことができると思います(invoker commandsのCSS版のようなもの)。

しかし今日の現実にとどまるために、まずはJavaScriptのイベントリスナーのようなCSS擬似クラスをいくつか紹介し、その後でevent-triggerについても同様に、現在未対応のこの機能が(私の考えでは)どのように動作するかを示します。

「イベントリスニング」擬似クラス

:hover:active

:hover状態は、pointerenterイベントが発生してからpointerleaveイベントが発生するまでの間を捉えます。これは、擬似クラスがイベントではなく状態であることを示す好例です。

:activeは、マウス・指・スタイラスで現在押されているターゲット(リンクやボタンなど)と一致し、pointerdownおよびpointerup/pointercancelに似ています。

ちなみに、pointer-events: noneCSS宣言を使用すると、選択した要素でポインターイベントが発生しなくなります!

:focus:focus-visible

:focus擬似クラスは、focusおよびblur(フォーカス解除)のJavaScriptイベントに似ていますが、:focus-visibleはもう少し複雑です。:focus-visible:focusが発生したときにトリガーされますが、さらにブラウザはさまざまなヒューリスティックを使用して、フォーカスインジケーターを表示するかどうかを判断します。ユーザーはキーボードで操作しているか? 要素はフォームコントロールか? これはCSSが提供する価値を改めて実感させられます。実際、JavaScriptでこれを扱う最善の方法は、CSS擬似クラスをクエリすることです:

element.addEventListener("focus", (event) => {
  if (event.target.matches(":focus-visible")) {
    /* 何かを実行 */
  }
});

:focus-within(および:has()

JavaScriptは「AがYなら、BにZを実行する」といった処理に優れています。DOMを走査したり、イベントの伝播を利用したりと多くのことができます。その点でCSSは少し制限を感じるかもしれません。しかしCSSは急速に進化しており、スクロール駆動アニメーションなどの新しいif-this-do-that機能が多数あり、今後も増えていくでしょう。HTMLも<details>のような専用コンポーネントで同様の進化を遂げており、それぞれにCSS機能が伴います。

実際には後ほど触れますが、より包括的な意味で、子要素がフォーカスを持っている場合に一致する:focus-withinや、任意の有効なセレクターを受け取り、2つのセレクター間にその関係が存在する場合に一致する:has()があります。

たとえば、次の2つのセレクターはまったく同じ動作をします:

form:focus-within {
  /* 内部のいずれかにフォーカスがある場合にフォームをスタイル */
}

form:has(:focus) {
  /* 内部のいずれかにフォーカスがある場合にフォームをスタイル */
}

:checked

:checkedの動作は明らかです。これに最も近いJavaScriptイベントはchangeで、<input><select><textarea>の値が変更されたときに発生します(この文脈ではinputイベントも非常に似ています)。

チェックをリッスンするには、次のようにします:

checkbox.addEventListener("change", (event) => {
  if (event.target.checked) {
    /* チェック済み */
  } else {
    /* 未チェック */
  }
});

CSS擬似クラスは、2つのJavaScriptイベントの間(例:pointerenterpointerleave)を捉えることが多いですが、そうでない場合は上記のようにロジックを扱います。

隠れたロジック処理の例をさらに見ていきましょう。

:valid/:invalid/:user-valid/:user-invalid/:autofill

ここでは:not()擬似クラス関数は必要ありません。妥当性は:valid:invalidの両方の擬似クラスで確認できますが、JavaScript側ではvalidイベントは存在せず(invalidのみ)、checkValidity()メソッドを呼び出すことになります(falseを返すと実際にinvalidイベントが発生します)。JavaScriptを使用する場合、inputchangeblur(要素のフォーカスを外したときに妥当性を確認)、またはsubmit(フォーム全体を送信するときに妥当性を確認、以下参照)のイベントリスナーのコールバック内で呼び出すのが一般的です。

form.addEventListener("submit", () => {
  if (form.checkValidity()) {
    /* すべてのフォームコントロールが妥当 */
  } else {
    /* フォームコントロールが不正(invalidイベントが発生) */
  }
});

ValidityStateオブジェクトを使用することもできます。これはinvalidイベントを発生させませんが、HTMLフォームバリデーションと同じ方法で、フォームコントロールが妥当か不正か、またその理由を教えてくれます:

input.addEventListener("input", () => {
  if (input.validity.valid) {
    /* 入力が妥当 */
  } else {
    /* 入力が不正(invalidイベントは発生しない) */
  }
});

HTMLフォームバリデーションはフロントエンド全体を処理しますが、デフォルト以外の動作が必要な場合は、checkValidity()またはValidityStateが役立ちます。

擬似クラスはいずれの場合も動作します。実際、あまりにも良く動作しすぎて見落としがちな点として、フォームコントロールは即座に:validまたは:invalidのいずれかをトリガーします。一方、:user-valid:user-invalidは、ユーザーが値を入力してフォーカスを外すまで待機します。これは実際にはchangeイベントの動作(チェックボックス、ラジオボタン、ドロップダウンリスト、カラーピッカー、スライダーの場合を除く)であり、inputイベントとの違いでもあります。

自動入力のJavaScriptイベントは存在せず、JavaScriptで検出するきれいな方法もありませんが、:autofill擬似クラスは存在します。

メディア要素の擬似クラスはまだ新しく、Chromeではまだサポートされておらず、Firefoxに最近追加されたばかりですが、Interop 2026の一部であり、JavaScriptイベントをリッスンせずに<audio><video>要素の状態に基づいてスタイルを設定できるようになります。仕組みはもうお分かりだと思いますので、簡単な一覧を示します:

擬似クラスJavaScriptイベント相当
:bufferingwaiting
:mutedvolumechange(ただし下記参照)
:pausedpause
:playingplayingplayではない)
:seekingseeking
:stalledstalled
:volume-lockedN/A、下記参照

ミュートを検出するにはvolumechangeイベントを使用します:

audio.addEventListener("volumechange", () => {
  if (audio.muted) {
    // ミュート済み
  } else {
    // ミュートされていない
  }
});

ボリュームロックを検出するには、ボリュームの変更を試み、成功したかどうかを確認します。最も良い方法は、新しい要素を作成して本物の要素でvolumechangeをトリガーしないようにすることです:

// ビデオを作成
const video = document.createElement("video");

// ボリュームを変更
video.volume = 0.5;

if (video.volume !== 0.5) {
  // ボリュームロックされている
} else {
  // ボリュームロックされていない
}

(またはCSSを書く場合は、:volume-locked擬似クラスを使用します。)

:popover-open / :open / :modal

予想通り、ポップオーバー、<dialog><details>が開閉されたときのJavaScriptイベントはありませんが、toggleイベントをリッスンして状態を確認できます:

element.addEventListener("toggle", () => {
  if (element.open) {
    /* ポップオーバー/ダイアログ/detailsが開いている */
  } else {
    /* ポップオーバー/ダイアログ/detailsが開いていない */
  }
});

しかしCSSには、これらの擬似クラスが最初から用意されています:

  • :popover-open(ポップオーバー用)
  • :open<dialog>および<details>要素用)
  • :modal(モーダル<dialog>および全画面要素用)

全画面要素について触れておきましょう…

:fullscreen

:fullscreen擬似クラスは、条件が組み込まれたfullscreenchangeJavaScriptイベントと同義です:

document.addEventListener("fullscreenchange", () => {
  if (document.fullscreenElement) {
    /* fullscreenElementが全画面表示中 */
  } else {
    /* 全画面表示中の要素なし(fullscreenElementはnull) */
  }
});

:target

URLのハッシュ(例:#contact)が要素のID(例:<div id="contact">)と一致すると、その要素は:target擬似クラスと一致します。JavaScriptを使用する場合、hashchangeイベントをリッスンし、一致する要素が見つかったかどうかを確認する必要があります:

window.addEventListener("hashchange", () => {
  const target = document.getElementById(window.location.hash.substring(1));

  if (target) {
    /* 一致する要素が見つかった */
  } else {
    /* 一致する要素が見つからなかった */
  }
});

結論(ただし本当の意味ではない)

これは「JavaScriptは悪い」という主張ではなく、CSSが簡素化する点と、JavaScriptが提供する精密な制御の両方を評価するものです。選択肢が増えることは決して悪いことではありません。

その点で、event-triggerについても簡単に触れておきたいと思います。

実際のイベントリスナー(event-trigger

Chromeがスクロールトリガーアニメーションを実装した際にevent triggersを知りました。同じモジュールに属していますが、まだどのWebブラウザでもサポートされておらず、誤りがありましたらお詫びします。では詳しく見ていきましょう。

event-trigger-nameはシンプルなdashed identを受け取ります:

button {
  event-trigger-name: --event;
}

event-trigger-sourceは本質的にイベントリスナーになります。

以下のキーワードを受け取ります:

  • activate
  • interest
  • click
  • touch
  • dblclick
  • keypress(<string>)
button {
  event-trigger-source: click;
}

interestキーワードは近日公開予定のInterest Invoker APIを指すと思われます。一方、activateキーワードは要素によって異なる可能性があります。たとえば<details>の場合、アクティベーションは開いたときを意味するかもしれませんが、はっきりしていません。仕様の今後のドラフトで詳細が明らかになり、さらなるイベントも追加されるでしょう。

いずれにせよ、これらのイベントはアニメーションをトリガーします。まず@keyframesアニメーションを作成し、アニメーションを適用する要素にアタッチしますが、イベントによってトリガーされるまでアニメーションは実行されません(通常は即座に実行されます)。

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

div {
  animation: fade-in 300ms both;
}

次に、イベントが発生したときにアニメーションがトリガーされるようにします。animationと一緒にanimation-triggerを設定し、dashed ident(--event)を参照します。これにより、ある要素のイベントが別の要素のアニメーションをトリガーできるという利点もあります。以下はevent-triggerショートハンドを使用した簡単な例です:

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

button {    
  /* クリック時に--eventアニメーションをトリガー */
  event-trigger: --event click;
}

div {
  /* --eventが発生したらアニメーションを前方再生 */
  animation-trigger: --event play-forwards;

  /* アニメーション */
  animation: fade-in 300ms both;
}

これはステートレスなイベントトリガーと呼ばれます。考えてみてください — クリックを「未クリック」にすることはできませんよね? しかし興味を失うことはできるので、以下はステートフルなイベントトリガーアニメーションの例です(/で区切られた2つのイベントと、それぞれの状態に対する2つのアニメーションアクションの構文に注目):

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

button {    
  /* interest(入る) / interest(出る) */
  event-trigger: --event interest / interest;
}

div {
  /* 興味があるときは前方再生、失ったときは後方再生 */
  animation-trigger: --event play-forwards play-backwards;

  /* アニメーション */
  animation: fade-in 300ms both;
}

使用可能なアニメーションアクションは以下のとおりです:

  • none
  • play
  • play-once
  • play-forwards
  • play-backwards
  • pause
  • reset
  • replay

動作しないイベントとアニメーションアクションの組み合わせは多数ありますが、それらは使用する意味がないため簡単に避けられます。ただし、animation-triggeranimationのreset-onlyサブプロパティであるため、複数の異なるアニメーションをトリガーできます。以下は大まかな例です:

animation-name: animationA, animationB;
animation-trigger: --eventA play, --eventB replay;

W3Cがこの機能をどのように進めるか(仕様ではイベントのバブリングを許可する可能性が言及されています)によって可能性は無限に広がりますが、HTMLがInvoker Commands APIでできるように、event triggersでJavaScriptメソッドを呼び出せたらいいなと思います。

どう思いますか? 正しい方向への一歩でしょうか、それともCSSは自分の領域に留まるべきでしょうか?