CSS Pseudoclasses That Replace JavaScript Event Listeners
CSS Deep-Dive · Updated 2026

CSS States vs JavaScript Events:
When Pseudoclasses Replace Event Listeners

CSS is quietly accumulating pseudoclasses that handle interactions previously requiring JavaScript. A tour of the modern CSS state model and how it compares to the JavaScript EventTarget API.

Oleg Maximov July 5, 2026 11 min read

Introduction: The Shifting Line

CSS is listening to us. No, not like that. Rather, CSS is accumulating more and more pseudoclasses that let us respond to user interactions without writing a single line of JavaScript. The line between what CSS handles declaratively and what requires imperative JavaScript is shifting — and has been for years.

Pseudoclasses track states, not events. There's an important distinction: an event fires at a moment in time, while a state persists over a duration. :hover captures the entire period between pointerenter and pointerleave. Yet for most practical purposes, a CSS pseudoclass feels like an event listener — and increasingly, CSS is handling logic that we used to reach for JavaScript to manage.

In this guide, I'll walk through every CSS pseudoclass that acts like an event listener: the classic hover/active/focus states, the newer relational selectors like :focus-within and :has(), form validation states, media element states, and the experimental event-trigger proposal that would give CSS genuine event listening capabilities.

Classic Interaction Pseudoclasses

:hover and :active

The most well-known CSS interaction states. :hover activates when a pointing device enters the element's hit area, and deactivates when it leaves. This maps to the pointerenter and pointerleave JavaScript events.

:active matches while the element is being pressed — between pointerdown and pointerup/pointercancel. Both are pure state trackers in CSS:

/* CSS — declarative state tracking */
button:hover {
  background: #e0f2fe;
  transform: scale(1.02);
  transition: transform 0.15s ease;
}

button:active {
  transform: scale(0.98);
}

Compare with the JavaScript equivalent:

// JavaScript — imperative event tracking
button.addEventListener('pointerenter', () => {
  button.style.background = '#e0f2fe';
  button.style.transform = 'scale(1.02)';
});
button.addEventListener('pointerleave', () => {
  button.style.background = '';
  button.style.transform = '';
});

The CSS version is shorter, declarative, and benefits from hardware-accelerated compositing. The JS version gives you more control — you can conditionally apply effects, trigger side effects, or coordinate multiple elements — but for simple hover effects, CSS is objectively better.

:focus and :focus-visible

:focus matches when an element receives focus (via keyboard navigation, click, or element.focus()). It's the CSS analog of the JavaScript focus and blur events.

:focus-visible is more interesting. The browser applies it when :focus would apply, but additionally uses heuristics to determine whether a visible focus indicator should be shown. This is the browser's way of distinguishing keyboard navigation from mouse clicks — a distinction that's surprisingly hard to make in JavaScript:

/* CSS — automatic keyboard vs mouse detection */
button:focus-visible {
  outline: 2px solid #0f766e;
  outline-offset: 2px;
}

button:focus:not(:focus-visible) {
  outline: none; /* Mouse click — hide focus ring */
}

To replicate this in JavaScript, you'd need to query the pseudoclass itself via element.matches(':focus-visible'). CSS handles it natively.

:focus-within and :has()

This is where CSS truly starts behaving like an event system. :focus-within applies to a parent element when any of its children has focus. It's essentially CSS-level event propagation for the focus event.

:has() is even more powerful — it's a relational selector that matches if a descendant matches a given selector. These two selectors do the same thing:

/* Both highlight the form when a child input is focused */
form:focus-within {
  border-color: #0f766e;
  box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.2);
}

form:has(:focus) {
  border-color: #0f766e;
  box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.2);
}

The :has() selector goes far beyond focus handling — it can detect any descendant state, making it a genuine CSS-level conditional. For example, .card:has(.badge) adjusts card layout when it contains a badge element, or form:has(:invalid) shows a form error banner without any JavaScript.

Form State Pseudoclasses

:checked

The :checked pseudoclass matches checkboxes, radio buttons, and <option> elements in a <select> when they are selected. The JavaScript equivalent is the change event:

/* CSS */
.toggle:checked + .toggle-label {
  background: #0f766e;
}

.toggle:checked ~ .menu {
  display: block;
}
// JavaScript
checkbox.addEventListener('change', (event) => {
  if (event.target.checked) {
    menu.style.display = 'block';
  } else {
    menu.style.display = 'none';
  }
});

The CSS :checked + adjacent/sibling combinators pattern is the foundation of CSS-only toggle components — dropdowns, accordions, tab panels, and dark mode switches that work without any JavaScript.

:valid, :invalid, :user-valid, :user-invalid

HTML form validation comes with built-in CSS state tracking. The :valid and :invalid pseudoclasses apply based on the element's ValidityState object — the same validation that the browser uses for native form validation UI.

However, :valid and :invalid apply immediately when the page loads. This means a required empty field shows as :invalid before the user has even touched it. The :user-valid and :user-invalid pseudoclasses fix this — they only trigger after the user has interacted with the field and moved focus away:

/* Show errors only after user interaction */
input:user-invalid {
  border-color: #dc2626;
  background: #fef2f2;
}

input:user-valid {
  border-color: #16a34a;
  background: #f0fdf4;
}

/* Still show success for auto-filled values */
input:autofill {
  border-color: #16a34a;
}

The :autofill pseudoclass is another CSS-only win. There's no clean JavaScript event for detecting when the browser autofills a form field. The :autofill pseudoclass handles it natively.

Behind the scenes, HTML form validation gives CSS all the state information it needs. The JavaScript approach requires either calling checkValidity() inside event handlers or inspecting the ValidityState object:

// JavaScript form validation
form.addEventListener('submit', (e) => {
  if (!form.checkValidity()) {
    e.preventDefault();
    showErrors();
  }
});

For basic validation styling, CSS does it without any JS at all. For non-default behavior (custom error messages, async validation, server-side checks), you still need JavaScript.

Media Element Pseudoclasses

One of the newest additions to CSS — and a clear demonstration of CSS adopting event-like capabilities — is the set of media element pseudoclasses for <audio> and <video> elements.

These are part of Interop 2026 and are gaining browser support. Firefox has already shipped them; Chrome is actively working on implementation.

CSS Pseudoclass JavaScript Event Equivalent Description
:buffering waiting Media is buffering data
:muted volumechange + .muted Audio output is muted
:paused pause Playback is paused
:playing playing (not play) Playback is actively running
:seeking seeking User is seeking to a new position
:stalled stalled Media data is unexpectedly unavailable
:volume-locked No direct equivalent Volume control is locked by OS or device

Example: show a buffering spinner only when the video is actively loading:

video:buffering + .spinner {
  display: block;
}

video:playing + .spinner {
  display: none;
}

The :volume-locked pseudoclass is particularly noteworthy — there is no straightforward JavaScript event to detect whether volume is locked at the OS level. The closest JavaScript approach requires creating a test element and attempting to change its volume, which is fragile and has side effects. CSS handles this as a native state query.

Dialog, Popover, and Fullscreen States

:popover-open, :open, :modal

Native HTML elements like <dialog>, popovers (the popover attribute), and <details> all have built-in state pseudoclasses that eliminate the need for JavaScript state management:

/* Style a popover when it's open */
[popover]:popover-open {
  opacity: 1;
  transform: scale(1);
}

/* Style an open 
summary */ details:open summary { font-weight: 600; } /* Style a modal dialog's backdrop */ dialog:modal::backdrop { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); }

In JavaScript, tracking these states requires listening to the toggle event and checking the open or modal property:

// JavaScript equivalent
dialog.addEventListener('toggle', () => {
  if (dialog.open) {
    backdrop.style.opacity = '1';
  } else {
    backdrop.style.opacity = '0';
  }
});

:fullscreen

The :fullscreen pseudoclass matches an element currently displayed in fullscreen mode. Its JavaScript equivalent is the fullscreenchange event combined with a conditional check on document.fullscreenElement:

/* CSS */
#player:fullscreen {
  background: #000;
  padding: 0;
}
// JavaScript
document.addEventListener('fullscreenchange', () => {
  if (document.fullscreenElement) {
    player.style.background = '#000';
  } else {
    player.style.background = '';
  }
});

URL-Based States

:target

When a URL hash (e.g., #section-3) matches an element's id, that element matches the :target pseudoclass. This enables CSS-only scroll-based navigation highlighting and section reveal animations:

section {
  opacity: 0.3;
  transition: opacity 0.4s ease;
}

section:target {
  opacity: 1;
  scroll-margin-top: 5rem;
}

With JavaScript, you'd listen for the hashchange event and manually toggle a class on the target element. CSS does it declaratively, with smooth transitions handled by the browser's compositor thread.

The Future: event-trigger

The Animation Triggers specification (CSS Level 5) introduces event-trigger — a proposed mechanism that would give CSS genuine event-listening capabilities. It's not supported in any browser yet, but it represents the logical endpoint of the trend this article describes.

The idea is simple: an element declares a named event trigger, another element subscribes to it, and when the event fires, an animation plays. Here's the proposed syntax by the spec:

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

/* Button fires --event on click */
button {
  event-trigger: --event click;
}

/* Div plays fade-in when --event fires */
div {
  animation-trigger: --event play-forwards;
  animation: fade-in 300ms both;
}

The event-trigger proposal supports stateless events (like click — you can't unclick) and stateful events (like interest — you can lose interest). Stateful events use a forward/backward syntax:

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

button {
  event-trigger: --event interest / interest;
}

div {
  animation-trigger: --event play-forwards play-backwards;
  animation: fade-in 300ms both;
}

The interest keyword refers to the upcoming Interest Invoker API. Other event sources include click, dblclick, touch, activate, and keypress().

Acceptable animation actions include: none, play, play-once, play-forwards, play-backwards, pause, reset, and replay.

If the W3C advances this spec (and the spec mentions allowing event bubbling), we could see a world where a child element's click triggers an animation on a completely unrelated parent element — all in CSS, no JavaScript required.

Decision Framework: CSS vs JavaScript for Interactions

With all these new capabilities, when should you reach for CSS and when should you stick with JavaScript? The answer isn't "JavaScript bad, CSS good" — both have their place. Here's a practical decision framework:

👍 Use CSS Pseudoclasses

  • Hover, active, focus visual effects
  • Form validation styling (:user-valid)
  • Parent-child state propagation (:focus-within, :has())
  • CSS-only toggle components (checkbox hack)
  • Media player presentational states
  • Dialog/popover open/close styling
  • URL hash-based section highlighting

👎 Keep JavaScript Event Listeners

  • Side effects (API calls, localStorage, analytics)
  • Complex multi-step interactions (wizards, checkout)
  • Drag-and-drop, custom gestures
  • Keyboard shortcuts and hotkeys
  • Cross-component coordination (state management)
  • Async form validation (server-side checks)
  • Animations with complex timing or sequencing

A good rule of thumb: if the interaction is purely presentational — changing how things look in response to a state change — use CSS. If the interaction needs to do something (fetch data, write to storage, coordinate multiple components), use JavaScript.

CSS is not trying to replace JavaScript entirely. It's displacing the parts of JavaScript that were never great: simple UI state management, visual feedback, and DOM class toggling. For the web's evolution, this is a healthy split. CSS handles the look; JavaScript handles the logic. And as CSS gains more state-aware capabilities, the boundary keeps shifting — which gives developers more options and simpler code.

For a broader look at how CSS is absorbing JavaScript territory, see my article on modern CSS patterns that replace JavaScript, covering CSS if(), style queries, and advanced attr(). Also check out Chrome 150's new CSS features for the latest browser implementations.

FAQ

Can CSS pseudoclasses completely replace JavaScript event listeners?
No. CSS pseudoclasses handle state-based interactions (hover, focus, checked, form validation UI) extremely well, but they cannot replace complex event handling like drag-and-drop, keyboard shortcuts, custom gesture recognition, or multi-step interactions with side effects. CSS is declarative — it describes how things look in a given state. JavaScript is imperative — it orchestrates sequences of actions. Use CSS for presentational interactions and JavaScript for behavioral logic.
What CSS pseudoclasses are analogous to JavaScript events?
Several CSS pseudoclasses map closely to JavaScript events: :hover (pointerenter/pointerleave), :active (pointerdown/pointerup), :focus (focus/blur), :checked (change/input on checkboxes), :valid/:invalid (form validation events), :focus-within (focus propagation to parent), :fullscreen (fullscreenchange), :target (hashchange), and media element pseudoclasses like :playing, :paused, :buffering, :muted, :seeking, :stalled.
What is CSS event-trigger and is it supported?
event-trigger is a proposed CSS feature in the Animation Triggers specification (CSS Level 5). It would allow CSS to listen for JavaScript events (click, interest, keypress, etc.) and trigger animations in response. It is not supported in any browser yet. The syntax uses event-trigger-name and event-trigger-source properties, or the event-trigger shorthand. For example, button { event-trigger: --event click; } div { animation-trigger: --event play-forwards; }.
When should I use CSS pseudoclasses instead of JavaScript?
Use CSS pseudoclasses when: (1) the interaction is purely presentational — hover effects, focus indicators, form validation styling; (2) the interaction involves a parent-child relationship (focus-within, :has()); (3) you're styling native form controls based on state; (4) you need to style media elements based on playback state; (5) you want to avoid JavaScript overhead for simple UI state transitions. Use JavaScript when: you need side effects (API calls, localStorage writes), complex multi-step interactions, drag-and-drop, custom gestures, keyboard shortcuts, or cross-element coordination beyond parent-child.
What does :user-valid and :user-invalid add over :valid and :invalid?
:user-valid and :user-invalid only trigger after the user has actually interacted with the field and moved focus away (blur). Unlike :valid/:invalid which apply immediately on page load (showing every empty required field as invalid), the :user-* variants wait for real user interaction. This prevents the common pattern of showing validation errors before the user has had a chance to fill in the form.
Are media element pseudoclasses (like :playing, :paused) supported in browsers?
Media element pseudoclasses are relatively new but gaining support. Firefox added support recently, and they are part of Interop 2026, meaning Chrome and other browsers are working on implementation. The supported pseudoclasses include :buffering, :muted, :paused, :playing, :seeking, :stalled, and :volume-locked. Check caniuse.com for current browser support status.
What is the difference between :focus-within and :has(:focus)?
Both selectors achieve the same visual result for focus styling: they apply styles to a parent when a child element has focus. form:focus-within applies when any focusable child within the form is focused. form:has(:focus) does the same using the :has() relational selector. The difference is that :focus-within is a dedicated, simpler selector with broader browser support, while :has() is a more general relational selector that can match any ancestor-descendant relationship.
Contact

Let's discuss your project

Building a web application with modern CSS? I can help you architect clean, maintainable code that leverages the best of CSS and JavaScript.