CSS Pseudo-Classes That Behave Like Event Listeners

CSS increasingly offers state-based selectors that mirror what JavaScript developers traditionally handled with event listeners. Pseudo-classes track states, not events, but the distinction blurs when a pseudo-class like :hover captures the exact window between pointerenter and pointerleave, or when :active corresponds to pointerdown through pointerup/pointercancel. Note that pointer-events: none prevents these pointer events from firing on the selected element altogether.

Focus and Interaction States

The :focus pseudo-class is analogous to the focus and blur JavaScript events. :focus-visible is more nuanced: it matches when :focus does, but only when the browser's heuristics determine a focus indicator should be displayed—for example, when the user is navigating by keyboard or interacting with a form control. In many cases, the most reliable way to detect this state in JavaScript is to query the CSS pseudo-class:

element.addEventListener("focus", (event) => {
  if (event.target.matches(":focus-visible")) {
    /* Do something */
  }
});

For relationship-based logic, :focus-within matches when any descendant has focus. The more powerful :has() accepts any selector and matches based on the existence of a relationship between the selected element and its descendants. These two selectors are functionally equivalent:

form:focus-within {
  /* Style the form when something within has focus */
}

form:has(:focus) {
  /* Style the form when something within has focus */
}

The :checked pseudo-class maps cleanly to the change event in JavaScript for checkbox and radio inputs. A typical JavaScript listener for this state might look like:

checkbox.addEventListener("change", (event) => {
  if (event.target.checked) {
    /* Checked */
  } else {
    /* Not checked */
  }
});

Form Validation States

CSS provides symmetry for form validation that JavaScript lacks. While there is no valid event in JavaScript (only invalid), the :valid and :invalid pseudo-classes cover both sides. On the JavaScript side, developers typically call checkValidity(), which does fire the invalid event when it returns false, often within listeners for input, change, blur, or submit:

form.addEventListener("submit", () => {
  if (form.checkValidity()) {
    /* All form controls are valid */
  } else {
    /* A form control is invalid (the invalid event fires) */
  }
});

Alternatively, the ValidityState object provides detailed reasons for validity without triggering the invalid event, mirroring what the HTML form validation engine checks internally:

input.addEventListener("input", () => {
  if (input.validity.valid) {
    /* Input is valid */
  } else {
    /* Input is invalid (the invalid event doesn’t fire) */
  }
});

A critical distinction exists between :valid/:invalid and :user-valid/:user-invalid. The former apply immediately based on the value; the latter wait until the user has interacted with the control and then unfocused, which aligns more closely with the change event (except for checkboxes, radio buttons, dropdowns, color pickers, and range sliders) than with the input event.

There is no clean JavaScript event for detecting browser autofill, but the :autofill pseudo-class handles this state.

Media Element Pseudo-Classes

A newer set of pseudo-classes for <audio> and <video> elements is still unsupported in Chrome and only recently arrived in Firefox, but they are part of the Interop 2026 roadmap. These cover playback and volume states directly:

Pseudo-classJavaScript event equivalent
:bufferingwaiting
:mutedvolumechange (but see below)
:pausedpause
:playingplaying (not play)
:seekingseeking
:stalledstalled
:volume-lockedN/A, see below

Detecting mute via JavaScript requires the volumechange event:

audio.addEventListener("volumechange", () => {
  if (audio.muted) {
    // Muted
  } else {
    // Not muted
  }
});

For the volume-locked state, JavaScript must attempt to change the volume and detect failure. A common workaround creates a fresh element so the real one doesn't fire volumechange:

// Create video
const video = document.createElement("video");

// Change volume
video.volume = 0.5;

if (video.volume !== 0.5) {
  // Volume locked
} else {
  // Volume not locked
}

CSS can instead use the :volume-locked pseudo-class directly.

Open and Modal States

There is no direct JavaScript event for popovers, <dialog> elements, or <details> opening and closing. The typical approach listens for the toggle event and then checks the resulting state:

element.addEventListener("toggle", () => {
  if (element.open) {
    /* Popover/dialog/details open */
  } else {
    /* Popover/dialog/details not open */
  }
});

CSS offers the corresponding pseudo-classes natively:

  • :popover-open for popovers
  • :open for <dialog> and <details> elements
  • :modal for modal <dialog>s and fullscreen elements

The :fullscreen pseudo-class is similar to listening for the fullscreenchange event, with the conditional state baked in:

document.addEventListener("fullscreenchange", () => {
  if (document.fullscreenElement) {
    /* fullscreenElement is fullscreen */
  } else {
    /* Nothing is fullscreen (fullscreenElement is null) */
  }
});

When a URL hash matches an element's ID, that element matches :target. In JavaScript, this requires listening for hashchange and then querying the DOM:

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

  if (target) {
    /* Matching element found */
  } else {
    /* Matching element not found */
  }
});

Event Triggers for CSS Animations

Looking beyond today's supported features, the Animation Triggers spec includes a proposal for event-trigger, which would let CSS listen for actual events and run animations in response. It is not yet supported in any browser, so behavior may shift in subsequent drafts.

The proposed syntax pairs a custom identifier with an event source. The event-trigger-name accepts a simple dashed ident:

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

The event-trigger-source defines the event listener and accepts keywords including activate, interest, click, touch, dblclick, and keypress(<string>).

button {
  event-trigger-source: click;
}

The interest keyword likely maps to the upcoming Interest Invoker API for hover-triggered popovers, while activate may be element-dependent—for a <details> element, activation could mean being opened. The spec is expected to expand the list of supported events.

These event triggers would control animations that, by default, run immediately. The animation-trigger property, set alongside animation, references the dashed ident so the animation only runs when the event fires. This also enables an event on one element to trigger an animation on a different element:

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

div {
  animation: fade-in 300ms both;
}
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

button {    
  /* On click, trigger --event animation */
  event-trigger: --event click;
}

div {
  /* When --event fires, play animation forwards */
  animation-trigger: --event play-forwards;

  /* Animation */
  animation: fade-in 300ms both;
}

Stateless events like clicks require a single action since a click cannot be undone. For stateful events where the state can toggle—such as interest—the syntax separates two events with a / and pairs each with an animation action:

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

button {    
  /* interest (entry) / interest (exit) */
  event-trigger: --event interest / interest;
}

div {
  /* Play forward with interest, backward when losing it */
  animation-trigger: --event play-forwards play-backwards;

  /* Animation */
  animation: fade-in 300ms both;
}

Acceptable animation actions include:

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

Because animation-trigger is a reset-only sub-property of animation, multiple different animations can be tied to distinct triggers on the same element:

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

The spec even contemplates future event bubbling. Whether this feature evolves to the point of invoking JavaScript methods, as the Invoker Commands API does for HTML, remains to be seen.