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.
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.
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 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.
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.
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.
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.
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.
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';
}
});
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 = '';
}
});
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 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.
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:
:user-valid):focus-within, :has())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.
Building a web application with modern CSS? I can help you architect clean, maintainable code that leverages the best of CSS and JavaScript.