Skip to content

Hover & Boop Patterns

A UI principle for coding agents. Also covers boop, hover craft, hover transition, button scale, nudge animation, secondary motion, and 1 more.

Show all 7 aliases

boop, hover craft, hover transition, button scale, nudge animation, secondary motion, follow-through

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 mouseenter and a loose, underdamped one on mouseleave gives 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

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:
StateWhat it shows
BaseThe neutral resting indicator
ActiveThe current page, persistently marked
HoverThe accent, on any link
Active and hoveredThe 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 mouseenter and 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: alternate reverses 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, :hover stops 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.

SettingStarting pointWhy
Displacementrotate 10 to 20 degreesLarge enough to read at a glance, small enough not to look violent out and back
Revert timeoutaround 150msThe boop length: long enough to register, short enough to finish on a drive-by hover
Transition durationaround 1500msThe spring's full settle time, most of it oscillation
Stiffness250 or higherHigh 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 ::before that 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: -1 puts it behind the text, and isolation: isolate keeps that negative z-index from dropping it behind the page background
  • transform-origin defaults to the element's center, which is what symmetrical growth needs. If an inherited style has moved it, set transform-origin: center explicitly, or the ring grows off to one side
  • scale() 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, but width/height take calc()
.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: transform on the base rule while the hover rule animates the box means nothing interpolates and the ring snaps
  • The negative inset is 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 like scale(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-origin stays 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 own transform-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. Add transform-box: fill-box to 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 .matches at trigger time, not once at setup, so a preference toggled mid-session takes effect. A change listener 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-value scale() 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 transitionend to 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "hover-and-boop-patterns" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems