Using CSS animations as state machines to remember focus and hover states with CSS only
Recently, I was searching for a way to style an element depending on whether it was ever focused.
I wanted to do this without JavaScript, just with CSS, because I was going to use it in a demo about the new focusgroup web platform feature. Focusgroup handles a lot of keyboard navigation logic for you, for free, with only an HTML attribute. So adding a bunch of JavaScript code to track past focus states to that demo felt like a step back.
I kept thinking about this problem, but couldn't find a solution. See, storing a click state is easy. You can use a checkbox element and then apply styles based on whether its :checked pseudo-class matches. You can also hide the checkbox itself and only show its corresponding <label> element if you prefer. Heck, you can even put your checkbox anywhere you want and then use the :has() pseudo-class to style other elements based on that checkbox's state.
But, I couldn't find anything similar for tracking if an element had been focused. And then, it hit me: what if I used a CSS animation for this?
Using CSS animations as state machines
CSS animations are basically state machines. They can change the value of any property over time. The trick is advancing the time to the right point, in order to reach the state you want to remember, and then keep it there.
Fortunately, CSS animations come with two very useful properties:
animation-play-state, which we can use to pause the animation in its initial state.animation-fill-mode: forwards, which we can use to keep the animation in its end state after it finishes.
Let's use these properties to set up our animation:
.remember-focus {
animation-name: remember-focus;
animation-duration: 0.000001s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-play-state: paused;
}
Or, using the shorthand syntax:
.remember-focus {
animation: remember-focus 0.000001s linear forwards paused;
}
The .remember-focus class sets an animation on the element, keeping it paused for now, but filling forwards so it retains the end state once it runs.
Notice the weirdly short animation duration of 0.000001s. That's because we want the animation to reach its end state immediately after starting. The duration needs to be short enough to be effectively instant to the user.
Whenever we're ready to change the state, all we have to do is play the animation. Let's say we want to do this when the element receives the user focus:
.remember-focus:focus {
animation-play-state: running;
}
Now all we need to do is define the animation itself, via the @keyframes rule. Let's use background colors for now, to make things simple:
@keyframes remember-focus {
from {
background: red;
}
to {
background: blue;
}
}
And there we have it. By default, the element on which the .remember-focus class is applied will have a red background. And then, when it receives focus, the animation will run and immediately change the background color to blue. Because of animation-fill-mode: forwards, the element will stay blue even after it loses focus.
The cool thing is that that state of the animation is associated to its element, so even if multiple elements have the .remember-focus class, each one will remember its own focus state independently.
Demos
Here's a live demo of the code we've just seen. Click or tab to focus the box: the color changes. The color stays even if click somewhere else or tab to another element:
This technique works with the :hover pseudo-class as well. Just change the selector to .remember-focus:hover and the animation will run on hover instead of focus.
Here's a demo that uses hover instead of focus, and has multiple elements with the same class. Try hovering over the boxes below to see them change color:
Animating other properties
Of course changing colors is just a simple thing you can do with this. But, CS animations are capable of changing any property, including custom properties, and even properties that can't be animated.
For example, you could use this technique to add a checkmark icon next to an element that was focused by animating the content property of a pseudo-element, even if content is not animatable.
Try it yourself: click the first box below, and then use tab to navigate to the next boxes. You'll see a checkmark appear next to the boxes you've already focused:
Using style container queries as an if statement
Let's conclude by refactoring the code a little bit so it's easier to reuse in multiple places.
First, let's use the technique to swap the value of a custom property called --was-focused:
.track-focus {
--was-focused: false;
animation: track-focus 0.000001s linear forwards paused;
}
.track-focus:focus-within {
animation-play-state: running;
}
@keyframes track-focus {
to { --was-focused: true; }
}
Now, we can use this by applying the .track-focus class to any element we want to track the focus state of. For example, a couple of form labels with inputs in them:
<form class="my-form">
<label class="track-focus" for="name">
Your name
<input type="text" id="name">
</label>
<label class="track-focus" for="email">
Your email
<input type="text" id="email">
</label>
</form>
Then, to actually use the --was-focused property, we can use a container style query. This way, we can conditionally apply styles to the element itself or to any of its descendants, depending on whether it was ever focused:
@container style(--was-focused: true) {
input {
background: lightgreen;
}
}
And here is the result:
That's it. Let me know if you find a cool use case for this technique, or if you have other ideas to improve it.