The Shifting Line Between CSS States and JavaScript Events | CSS-Tricks
CSS is listening to us. No, not like that. Rather, CSS is accumulating more and more pseudo-classes to help us respond to JavaScript events so that we don’t have to do so with JavaScript itself. But while pseudo-classes track states, not events, they sure can feel like event listeners sometimes (not that it really matters in the context of CSS).
Then again, what is CSS these days? For example, there’s a proposal for event-trigger in the Animation Triggers spec, which would basically listen for events and trigger animations. If you ask me though, the syntax is capable of a lot more than that (think: invoker commands but for CSS).
But to stay in today’s reality, I’ll walk you through the different CSS pseudo-classes out there that are kind of like event listeners, before doing the same for event-trigger, where I’ll show you how (I think) this currently unsupported feature would work.
“Event listening” pseudo-classes
:hover and :active
The :hover state captures the moment from when the pointerenter event fires to when the pointerleave event fires, which perfectly illustrates why pseudo-classes are states, not events.
:active matches the target (e.g., a link or button) that’s currently being pressed with a mouse, finger, or stylus, which makes it akin to pointerdown and pointerup/pointercancel.
By the way, the pointer-events: none CSS declaration prevents pointer events from firing on the selected element!
:focus and :focus-visible
The :focus pseudo-class is akin to the focus and blur (unfocus) JavaScript events, but :focus-visible is a bit more complex. :focus-visible triggers when :focus does, but in addition, the browser uses a variety of heuristics to determine whether or not a focus indicator should be shown. Is the user operating with a keyboard? Is the element a form control? This really makes me appreciate what CSS offers. In fact, the best way to handle this using JavaScript is to query the CSS pseudo-class:
element.addEventListener("focus", (event) => {<br>if (event.target.matches(":focus-visible")) {<br>/* Do something */<br>});
:focus-within (and :has())
JavaScript excels at the “if A is Y, then do Z to B” kind of stuff. We can traverse the DOM, leverage event propagation, and much more. In that regard, CSS can feel a bit limited. However, CSS is evolving quickly. It has many new if-this-do-that features such as scroll-driven animations, and it’ll have more in the future. HTML is doing the same with dedicated components such as , which all have accompanying CSS features.
I’ll mention some of that later, actually. In a more holistic sense, what we have is :focus-within, which matches if a child has focus, and :has(), which accepts any valid selector and matches if such a relationship exists between the two selectors.
For example, these two selectors do the exact same thing:
form:focus-within {<br>/* Style the form when something within has focus */
form:has(:focus) {<br>/* Style the form when something within has focus */
:checked
It’s fairly obvious what :checked does. The JavaScript event that’s most synonymous with it is change, which fires when the value of an , , or changes (although, in this context, the input event is quite similar).
To listen for a check, we’d do something like this:
checkbox.addEventListener("change", (event) => {<br>if (event.target.checked) {<br>/* Checked */<br>} else {<br>/* Not checked */<br>});
CSS pseudo-classes often capture the moment between two JavaScript events (e.g., pointerenter and pointerleave), but when they’re not doing that, they’re handling logic instead, as above.
Let’s look at some more examples of hidden logic handling.
:valid/:invalid/:user-valid/:user-invalid/:autofill
We don’t need the :not() pseudo-class function here, as validity can be checked using both the :valid and :invalid pseudo-classes, but on the JavaScript side of things, there’s no valid event (only invalid). That being said, if using JavaScript, you’ll likely want to call the checkValidity() method (which actually fires the invalid event if it returns false) within the callback of the event listener for input, change, blur (to check validity when unfocusing from an element), or submit (to check validity of the entire form when submitting it, as below).
form.addEventListener("submit", () => {<br>if (form.checkValidity()) {<br>/* All form controls are valid */<br>} else {<br>/* A form control is invalid (the invalid event fires) */<br>});
We can also do this with the ValidityState object, which doesn’t fire the invalid event, but does tell us why a form control is valid or invalid in the same way that HTML form validation does:
input.addEventListener("input", () => {<br>if (input.validity.valid) {<br>/* Input is valid */<br>} else {<br>/* Input is invalid (the invalid event doesn’t fire) */<br>});
The thing about HTML form validation is that it takes care of the entire front end, but if there’s a non-default behavior that you need,...