CSS & layout

Pseudo-classes are states, not events

Pseudo-classes are states, not events

You wired up a pointerenter listener on a card so it lifts when the cursor arrives, then a matching pointerleave to put it back. The span between those two events, the whole period the cursor is over the card, already has a name in CSS. It is called :hover. The new CSS-Tricks piece is a useful nudge that most of those listeners shouldn't be there in the first place.

The framing the article reaches for is sharper than the usual "use CSS where you can". Pseudo-classes aren't event handlers the browser ships for free. They're states. A state has a beginning, a duration, and an end. An event is a moment. The pseudo-class captures the period bracketed by two events, not either event in isolation.

The state lives between two events

The clearest mapping is also the most underrated.

:hover matches the period between pointerenter and pointerleave. :active matches while the element is being pressed with a mouse, finger or stylus. That is the window between pointerdown and pointerup (or pointercancel).

You can fight this. Attach four pointer listeners, juggle a boolean per card, toggle a class. Or pick the pseudo-class that already tracks the same boolean and skip the bookkeeping. The cascade resolves the visual state. JavaScript is left for things that actually need a script: a network call or a route change.

Worth keeping next to that: pointer-events: none is the property that stops pointer events firing on the element at all. It is the deliberate opt-out, not a side effect of styling.

A small one-liner the article uses, since we're here:

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

:focus-within does the same job. :has() generalises it: any condition you can write as a selector, you can scope to the ancestor. The "I need a wrapper class for that" reflex shrinks with every release.

:focus-visible is a heuristic, not a quieter :focus

This is where the state framing pays off.

:focus-visible triggers when :focus does. The browser then decides, using heuristics, whether to show the focus indicator at all. Was the user driving with a keyboard? Is the focused element a form control that always wants the ring? The pseudo-class is the spec's way of letting authors hook into that browser-side judgement, not into the raw event.

You cannot reproduce that with addEventListener('focus', …). A focus event tells you focus moved. It does not tell you whether the user would benefit from the ring. The pseudo-class does.

When CSS itself starts listening

The flipside is the proposed event-trigger syntax in the Animation Triggers spec. The idea is to let CSS bind an animation to an event, not to the state the event implies. The article cites the proposed shape:

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

The named event then becomes available as an trigger for an animation:

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

This is the part where the line actually moves. CSS interactivity has been purely declarative state up to now: be in this state, look like this. With event-trigger, CSS gets a way to react to a moment instead of a span. The spec also sketches stateful pairings such as --event interest / interest for entry-and-exit triggers.

It is a proposal. No browser ships it today. Be honest about that on your next PR.

What to try on your next view

Audit one component. Anywhere a pointerenter or pointerleave listener exists purely to add a class, the pseudo-class is sitting right there. Anywhere a focus listener flips a wrapper into a "has-focus" mode, :focus-within or form:has(:focus) does it with no state to track.

The ergonomic win per call site is small. Across a codebase it is the difference between something that needs hydration and something the browser renders correctly on first paint.

The pattern this kills is "JS for interaction, CSS for paint". The honest split is "CSS for states, JS for events that actually need a script". Treat event-trigger as the future case where even that line moves. Keep an eye on the Animation Triggers spec while you wait.

Source: CSS-Tricks (css-tricks.com)

Related
CSS & layout

Opposing scroll columns with one view() timeline and three keyframe sets

CSS-Tricks walks through making adjacent columns drift in opposite directions as the page scrolls, with no JavaScript. The trick is a per-item animation-timeline: view() and animation-range: entry 0% cover 100%, repeated across three keyframe sets.

June 25, 2026
CSS & layout

Why setting perspective on :root won't flip your view transition

Setting perspective on html, :root, or even ::view-transition itself looks like the obvious place to enable a 3D view transition — and produces nothing. The fix is to switch from the property form to the perspective() function, applied inside the snapshot's keyframes.

June 24, 2026
CSS & layout

Naming many view transitions: attr() or match-element?

Two modern ways to give every card in a grid its own view-transition-name — attr() reading a data attribute, or the match-element keyword. They look interchangeable, then diverge the moment a navigation crosses documents or a pseudo-selector needs to address a specific snapshot.

June 24, 2026