· 3 min read
Hover, press and focus: one component, three dialects
Interaction states aren't keyframes — they last as long as the interaction does. What that changes, the hover bug almost everyone writes, and how the same three states come out in CSS, Motion and GSAP.
A timeline animation has a start and an end. An interaction state doesn't — it lasts exactly as long as the pointer is there, and then it goes back. That difference shows up in the code more than you'd expect.
Here's the same button in all three, scaling slightly on hover, pressing in, and showing a ring on keyboard focus.
CSS
.button {
transform: scale(1);
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.button:hover {
transform: scale(1.03);
}
.button:active {
transform: scale(0.97);
}
.button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}Two details in there are worth more than they look.
The transition is on the base selector, not inside :hover. Put it in the hover rule and the element animates in and snaps back out, because the moment the pointer leaves, the rule providing the transition stops applying. This is the single most common hand-written hover bug and it's almost invisible until someone moves the mouse away slowly.
:focus-visible, not :focus. :focus fires on mouse clicks too, so you get a focus ring on every click — which is why so many people used to delete focus styles entirely and break keyboard navigation. :focus-visible is the browser telling you it thinks a ring is warranted, and it gets that right.
Motion
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
whileFocus={{ scale: 1.03 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
Save
</motion.button>This is the one that reads closest to what you mean. whileHover is literally "while hovered", the rest state is the component's ordinary props, and there's nothing to remember about where the transition goes.
It also does something CSS can't: press mid-hover and it redirects from wherever the element currently is, carrying its velocity. In CSS, :active applying on top of :hover restarts the transition from the current value with a fresh curve, which reads as a small hitch on a fast click.
GSAP
const el = document.querySelector(".button");
const onEnter = () => gsap.to(el, { scale: 1.03, duration: 0.18, ease: "power2.out" });
const onLeave = () => gsap.to(el, { scale: 1, duration: 0.18, ease: "power2.out" });
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);GSAP has no concept of a state, so you wire the events yourself. That's more code, and it buys you the ability to do anything you want on either edge — stagger children on enter, fire something else on leave.
If you're in React, remember the cleanup:
useEffect(() => {
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
};
}, []);Skipping the removal stacks a new pair of listeners on every re-render. It won't look broken; it'll just get slower and slower in a way that's hard to attribute later.
Where each one stops
| hover | press | focus | drag | |
|---|---|---|---|---|
| CSS | :hover | :active | :focus-visible | no pseudo-class exists |
| Motion | whileHover | whileTap | whileFocus | whileDrag |
| GSAP | your listeners | your listeners | your listeners | your listeners |
Drag is the row that matters. There's no CSS pseudo-class for "currently being dragged", so a drag state is the one interaction that can't be expressed in CSS at all. If your design has one, that decides your stack for you.
Two things to get right regardless
Return to the value you came from, not to zero. A button that already sits at scale(0.9) should return to 0.9 on pointer-leave, not to 1. Hand-written hovers guess zero constantly, and then every hover snaps the element to a size it never had.
Touch has no hover. A hover state on a phone either does nothing or sticks after a tap, depending on the browser. If the hover is the only thing communicating that something is interactive, it isn't communicating it to half your users.
Doing it once
verve treats hover, press, focus and drag as states rather than keyframes, because they don't run on the playhead's clock — they last as long as the interaction does. You set them on the element, and the export writes whichever spelling the dialect uses: pseudo-classes for CSS, while* props for Motion, listeners with cleanup for GSAP.
Which also means the transition-on-the-base-selector thing and the :focus-visible thing are decisions made once, in the generator, rather than remembered every time.