Core Philosophy
- A hover transition is a promise. It signals the element can be clicked, so reserve it for things that actually respond to a click: buttons, links, form fields
- Decorating non-interactive elements with hover motion trains users to click things that do nothing. The exception is motion that teaches, for example a card that demonstrates a product behavior on hover
- Hover only exists for fine pointers. Touch and keyboard users never see it, so nothing may depend on it
- The difference between a default hover and a considered one is rarely the property being animated. It is the timing: how fast it enters, how it leaves, and whether the two differ
- See animation-and-motion for press feedback and general easing. This doc covers what happens before the click
Asymmetrical in and out
- Entering and leaving a hover are different moments and deserve different settings. Identical in/out timing is the signature of an unconsidered transition
- The pattern: put the exit timing on the base selector, the entry timing on
:hover. The browser uses whichever rule currently applies
.navlink {
transition: background 350ms ease-out; /* exit: relaxed */
}
.navlink:hover {
background: var(--accent);
transition: background 80ms ease-out; /* entry: eager */
}
- Fast-in, slow-out makes an element feel eager to respond and graceful to release. The reverse, a slow build and a quick snap back, reads as reluctance; use it only when that is the intent
- Easing can be asymmetrical too: a springy
linear()curve on entry, a plain ease on exit - With JS springs, vary the config per direction. A stiff, well-damped spring on
mouseenterand a loose, underdamped one onmouseleavegives a slingshot feel: pulled taut, then released with a wobble
el.addEventListener("pointerenter", () =>
animate(el, { rotate: 8 }, { type: "spring", stiffness: 250, damping: 30 })
);
el.addEventListener("pointerleave", () =>
animate(el, { rotate: 0 }, { type: "spring", stiffness: 250, damping: 6 })
);
- Watch fast pointer traversal. A long, slow exit leaves ghosts of the effect on several elements at once; shorten the exit or mute the resting state until that stops happening
Nav indicators: make the ghosts inert
A row of nav links is where the slow exit bites hardest. Sweep across five links and five accent-coloured underlines are on screen at once, each partway through its revert, and the page now claims five links are active. animation-and-motion gives the general remedy, shorten the exit or mute the resting state. On an indicator, be more specific than that.
- The indicator's resting colour should be a neutral drawn from the surrounding chrome, the same value as the header's border or divider. It snaps to the accent on hover and reverts to the neutral. A trailing indicator then reads as part of the furniture rather than as a competing highlight, which is a different fix from making it fade faster
- Keep a short revert duration as well, so the neutral settles without drawing the eye
.navlink::after {
background: var(--border);
transition: background 200ms ease-out, transform 200ms ease-out;
}
.navlink:hover::after {
background: var(--accent);
transition: background 60ms ease-out, transform 60ms ease-out;
}
- Then handle the state combinatorics. A nav that also marks the current page needs four rules, not two, and the fourth is the one people forget:
| State | What it shows |
|---|---|
| Base | The neutral resting indicator |
| Active | The current page, persistently marked |
| Hover | The accent, on any link |
| Active and hovered | The active treatment, intensified |
- The last rule has to be written deliberately. Left alone, the hover rule either overrides the active marking, so the current page loses its indicator while you point at it, or loses to it, so the current page ignores the pointer. Neither is right: hovering the current page should read as more of what it already is
.navlink.is-active::after { background: var(--accent); }
.navlink.is-active:hover::after { background: var(--accent-strong); }
The boop
- A held hover state suits changes that describe state, like a background tint. For playful motion, holding is the problem: an icon frozen at 15 degrees for as long as the pointer sits there looks broken, like a picture knocked crooked
- The boop is the fix: nudge the element on
mouseenterand let it return on its own, regardless of where the pointer is. The gesture completes even on a drive-by hover, which is exactly how people move their cursors - Boops read as personality because they behave like a reflex, not a state. The interface reacts and recovers, the way a poked thing would
- Keep the displacement small. A rotation that would look fine held becomes violent when it swings out and back in 300ms; halve it
Pure CSS version
- A keyframe animation that runs twice with alternating direction plays forward then exactly backward, landing on the start state. Per the CSS spec,
animation-direction: alternatereverses the animation on each even cycle
@keyframes nudge {
from { transform: rotate(0deg); }
to { transform: rotate(12deg); }
}
.icon-button:hover .icon {
animation: nudge 140ms ease-in-out 2 alternate;
}
- Zero JavaScript, but one real flaw: if the pointer leaves mid-animation,
:hoverstops matching, the animation is removed, and the element snaps home instantly. Fine for a background flourish, disqualifying for the hero interaction
JS version
- Set a class on
mouseenter, clear it with a timeout, and let a CSS transition animate both legs. Because the trigger is the enter event, not the hover state, leaving early cannot cut it short. Clear any previous timer first, or a rapid re-enter leaves a stale callback that removes the class mid-boop
let boopTimer;
button.addEventListener("mouseenter", () => {
icon.classList.add("is-booped");
window.clearTimeout(boopTimer);
boopTimer = window.setTimeout(() => icon.classList.remove("is-booped"), 150);
});
.icon {
transition: transform var(--boop-spring-duration, 1.5s) var(--boop-spring, ease);
}
.is-booped {
transform: rotate(12deg);
}
- The transition duration is the spring's settle time and will be far longer than the timeout. That is correct: the timeout is the boop length, the duration is how long the wobble takes to die
- Boops are not rotation-only. A scale boop on a button, a translate boop on an arrow, a path boop on an SVG line all use the same enter-then-clear structure
Why interrupting a spring so early looks fine
This is the mechanism the whole pattern rests on, and it generalises well past boops, so it is worth holding in your head.
- Reversing a CSS transition mid-flight does not resume the old curve backwards. The browser starts a fresh transition, reusing the same timing function, over a shortened duration. The spec calls the multiplier the reversing shortening factor, and it is derived from how far through the output the transition got, not from how much time elapsed
- That distinction is everything for a spring. A high-stiffness spring covers nearly all of its displacement in the first slice of its duration, then spends the rest oscillating around the target burning off energy. Interrupt it a tenth of the way through the clock and the element is already at, or very near, full displacement
- So the reversal starts from roughly the displaced position and gets a duration proportional to that near-complete progress. Nothing snaps, because there was nothing left to travel
- The design rule: front-load the displacement and let the tail be oscillation. Any interaction that can be cancelled at an arbitrary moment, a boop, a hover, a toggle the user hammers, wants a curve that arrives early and settles late. A curve that spends its duration evenly will visibly jump when cut short. Details of the reversing behaviour, and where the CSS layer stops being enough, are in spring-easing-in-css
Defaults that work
Start here and tune from there. These four values are related, so change one and re-check the others.
| Setting | Starting point | Why |
|---|---|---|
| Displacement | rotate 10 to 20 degrees | Large enough to read at a glance, small enough not to look violent out and back |
| Revert timeout | around 150ms | The boop length: long enough to register, short enough to finish on a drive-by hover |
| Transition duration | around 1500ms | The spring's full settle time, most of it oscillation |
| Stiffness | 250 or higher | High enough that full displacement is reached inside the timeout |
The timeout being roughly a tenth of the duration looks wrong and is not. The timeout ends the boop, the duration governs how long the wobble takes to die.
- Wrap the enter/timeout pair in one helper so every boop in the product shares its timing and can be tuned in one place. Keep the helper generic: a trigger, a function that applies the boop, a function that removes it, and a duration. What gets applied is the caller's business
function attachBoop(triggerNode, applyBoop, removeBoop, boopDuration = 150) {
let timer;
triggerNode.addEventListener("mouseenter", () => {
applyBoop();
window.clearTimeout(timer);
timer = window.setTimeout(removeBoop, boopDuration);
});
}
attachBoop(
button,
() => icon.classList.add("is-booped"),
() => icon.classList.remove("is-booped")
);
- The trigger and the target are separate arguments on purpose. The thing that is hovered is usually bigger than the thing that moves: you hover a button, an icon inside it boops
Button scaling via a pseudo-element
- Scaling a whole button on hover has two rendering problems: the label can blur while the transform is active, and it can visibly snap when the element is promoted to or demoted from a GPU layer at the transition boundary
- It also just feels blunt. The considered version scales a
::beforethat carries the border and background, while the button box and its text never move: the frame swells around a still label
.btn {
position: relative;
isolation: isolate;
border: none;
background: none;
}
.btn::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border: 1px solid var(--border);
border-radius: var(--radius-md);
transition: transform 200ms ease-out;
}
.btn:hover::before {
transform: scale(1.08);
}
- The pseudo-element is absolutely positioned, which lifts it above the button's text. If it ever gains a background fill, it will cover the label.
z-index: -1puts it behind the text, andisolation: isolatekeeps that negative z-index from dropping it behind the page background transform-origindefaults to the element's center, which is what symmetrical growth needs. If an inherited style has moved it, settransform-origin: centerexplicitly, or the ring grows off to one sidescale()grows proportionally, so a wide button gets much wider but barely taller. For equal growth in pixels, animate the box instead:scale()only takes ratios, butwidth/heighttakecalc()
.btn::before {
width: 100%;
height: 100%;
box-sizing: border-box;
transition:
width 200ms ease-out,
height 200ms ease-out,
inset 200ms ease-out;
}
.btn:hover::before {
width: calc(100% + 8px);
height: calc(100% + 8px);
inset: -4px;
}
- The transition list has to change with the properties. Leaving
transition: transformon the base rule while the hover rule animates the box means nothing interpolates and the ring snaps - The negative
insetis what centers the growth. Without it the extra size hangs off the right and bottom; pulling every edge out by half the growth makes it read as a ring expanding evenly - Stretched
scale()values likescale(1.06, 1.2)also equalize growth but deform the painted pixels: corners go elliptical and border thickness diverges between axes. The width/height route avoids that at a small performance cost that rarely shows in a profile at this scale
Nested transforms: secondary motion
- One flat transform moves an object. A parent transform plus a child transform with its own origin and its own timing moves a thing with parts, and parts are what sell physicality: a hat lagging behind the head it sits on, a tail trailing a body
- Child transforms compound with the parent's. A 30 degree parent rotation plus a 30 degree child rotation lands the child at 60 degrees, and
transform-originstays pinned to the child itself no matter how the parent moves, so each part hinges at its own joint
.hat {
transform-origin: 50% 100%; /* hinge where it meets the head */
transition: transform 600ms var(--loose-spring);
}
.head {
transition: transform 350ms var(--tight-spring);
}
.figure:hover .head { transform: rotate(12deg); }
.figure:hover .hat { transform: rotate(18deg); }
- The follow-through illusion comes from the timing gap: give the child lower damping or a longer duration than the parent, so the parent settles while the child still wobbles. The parent reads as dense, the child as light
Two ways to open that gap. Pick one and hold the other variable constant, or you will be tuning four numbers against each other.
- Hold stiffness equal, halve the child's damping. Same stiffness on both, child at roughly half the parent's damping. Both start together and the child keeps wobbling after the parent has stopped. This is the light-versus-dense read, and it is the easier of the two to get right
- Hold damping equal, raise the child's stiffness. Then push the child's rotation well past the parent's, roughly double, to compensate for a stiffer spring travelling less visibly. This gives a sharper, snappier part rather than a floppy one
- For a chain of three or more, step the duration by depth: each level slightly slower than its parent. One hover rule, a whip-like cascade
Delaying a child boop. For the child to look like it is reacting to the parent rather than moving with it, it needs its own boop that starts while the parent is still travelling, roughly two thirds of the way through the parent's displacement. On a 150ms parent boop that is a delay near 100ms, with the child then holding its own 150ms before reverting.
Do not reach for transitionend to remove the hand-tuned constants. Waiting for the parent to settle is precisely wrong: by the time the event fires the parent is at rest, and a child that starts moving then reads as a second, unrelated animation rather than as follow-through. The overlap is the effect. Three scheduled timeouts look messier than one event listener and produce the result you actually want.
- The same structure works on SVG parts: put the transform on a
<g>or<path>and give each its owntransform-origin. The pivot is the one difference. By default it resolves against the SVG's own reference box, not the part's bounds, so a percentage origin lands somewhere in the viewBox. Addtransform-box: fill-boxto each animated part when it should hinge on itself
Reduced motion
- A boop is pure motion; there is no meaning to preserve. Under
prefers-reduced-motion: reduce, do not fire it at all rather than substituting a flash or color blink - In JS, check before scheduling anything so no class is added and no timeout is created
const motionOk = window.matchMedia("(prefers-reduced-motion: no-preference)");
function boop() {
if (!motionOk.matches) return;
// add the class, schedule the timeout
}
-
Read
.matchesat trigger time, not once at setup, so a preference toggled mid-session takes effect. Achangelistener on the same MediaQueryList can also clear a pending boop timer the moment it flips -
Gate CSS-only boops and nested-transform flourishes the same way: wrap the animation rules in
@media (prefers-reduced-motion: no-preference)so the resting state simply persists. The element must remain fully visible and usable either way -
State-carrying hover changes, like a background tint on a link, stay. Reduced motion removes movement, not feedback
Common mistakes
- Hover motion on non-interactive elements, which advertises a click that does nothing
- Identical enter and exit timing, the default that makes every hover feel generic
- A hold-until-leave transform on a playful element, which looks stuck instead of alive
- The CSS-only boop on a primary interaction, where an early pointer exit snaps it home
- Scaling the button element itself, blurring the label mid-transition
- A background on the scaling pseudo-element without
z-index: -1, which erases the label scale()on a wide button when the design wants even growth, or stretched two-valuescale()deforming corners and borders- Growing the pseudo-element's box without the matching negative
inset, so it swells down-right instead of outward - Nested transforms sharing one origin and one duration, which collapses the parts back into a flat object
- Using
transitionendto chain a child boop onto its parent, which waits for the parent to settle and destroys the follow-through - Tuning the parent's and the child's stiffness and damping at the same time, instead of holding one constant
- An accent-coloured resting state on a nav indicator, so a fast sweep leaves several links looking active at once
- No rule for the active-and-hovered nav state, so pointing at the current page either erases its marking or does nothing
- Shipping any of it without a reduced-motion gate