Smooth Hover Effects Without JavaScript: CSS-Only Patterns for Modern UI

A hover effect fails in one of two ways. It either judders — the card lurches a pixel at a time while its neighbours twitch in sympathy — or it feels wrong: technically smooth, but arriving too slowly, leaving too fast, or snapping back the instant your pointer crosses a sub-pixel gap. Both failures come from the same root cause, which is choosing properties and timings without knowing what the browser has to redo on each frame. This page, part of the Hover & Focus State Design section of the CSS-Only Micro-Interactions & Animations area, builds one card hover that is smooth in both senses, entirely in CSS.

Key Takeaways:

  • Confine the animated properties to transform and opacity
  • Fake expensive effects like shadows with a pre-painted pseudo-element
  • Give hover-in and hover-out different durations on purpose
  • Chain :focus-visible into every hover selector so keyboards get parity

Why do this in CSS rather than JavaScript

A JavaScript hover handler has to attach mouseenter and mouseleave listeners, track which element is current, and write style properties from a callback that runs on the main thread — the same thread that is parsing, running your framework, and responding to input. Even a perfect implementation is doing work the engine would otherwise do for free, and an imperfect one reads a layout property mid-animation and forces the browser to recompute geometry synchronously.

The declarative version has none of that surface. transition is a statement about how a property should interpolate whenever it changes, so the engine can plan the animation before it starts and, for the right properties, hand it to a thread that keeps running even while script is busy. There is also a correctness argument: CSS naturally handles the interrupted case. Move your pointer off halfway through and the transition reverses from wherever it currently is, because it interpolates from the computed value, not from a remembered start state. Hand-rolled JavaScript animation gets this wrong constantly.

Reach for script only when hover has to do something rather than look like something — prefetch a resource, open a menu with focus management, sequence several elements against real data. The visual layer belongs in the stylesheet. Where a hover reveals content that must also be reachable by keyboard, the containment rules in accessible CSS-only tooltips apply on top of everything here.

The compositor-first approach to hover transitions

The two cards below use the same visual lift, but only the first one animates on the compositor. Hover each and watch how the second nudges its neighbour.

Live demoCompositor-only hover lift
Hover or Tab to a card. Only transform and opacity animate, so nothing re-lays-out.

The difference is which stage of the rendering pipeline the animated property enters. transform and opacity change how an already-painted layer is placed and blended, so a frame costs nothing but a re-composite. width, height, top, margin and padding change the box the layout engine reasons about, so the engine must recompute geometry — potentially for siblings and ancestors too — and repaint before it can composite anything. That is why the second card's neighbour moves: its geometry genuinely changed.

Compositor path versus layout path on hover Animating transform and opacity stays on the compositor thread, while animating width or top forces a main-thread layout and paint. Two ways a hover frame is produced Compositor path transform / opacity skips layout + paint neighbours never move Main-thread path width / top / margin forces reflow + repaint siblings shift too

Two properties are worth calling out because they look harmless and are not. box-shadow is a paint-stage property: interpolating its blur radius means re-rasterising a soft gradient around the element on every single frame, which is one of the most expensive things you can ask a hover to do. filter is similar — it forces a fresh bitmap per frame before compositing can happen. Both have compositor-friendly substitutes, and the implementation below uses the substitute for shadow.


Complete working implementation

One card, one stylesheet, nothing else. It lifts, brightens its shadow, slides a caption up, and does all of it on transform and opacity only. Copy it into an empty HTML file and it runs.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Smooth CSS hover card</title>
  <style>
    :root {
      /* One place to change the feel of every interactive element. */
      --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
      --dur-in: 180ms;   /* fast arrival  */
      --dur-out: 320ms;  /* relaxed exit  */
      --lift: -6px;
    }

    body {
      font: 16px/1.5 system-ui, sans-serif;
      display: grid;
      place-content: center;
      min-height: 100vh;
      margin: 0;
      background: #eef1f6;
    }

    .card {
      position: relative;          /* containing block for the shadow layer */
      width: 18rem;
      padding: 1.5rem;
      border-radius: 14px;
      background: #fff;
      /* Base shadow: painted once, never animated. */
      box-shadow: 0 1px 3px rgb(15 23 42 / 0.12);
      /* The exit transition lives on the base rule, so it applies when the
         hover rule stops matching. */
      transition: transform var(--dur-out) var(--ease-out-quart);
    }

    /* The hover shadow is a separate painted layer sitting behind the card.
       It is rasterised once at full strength, then only faded in and out —
       so no frame ever repaints a blur. */
    .card::before {
      content: "";
      position: absolute;
      inset: 0;
      z-index: -1;
      border-radius: inherit;
      box-shadow: 0 18px 40px -12px rgb(15 23 42 / 0.45);
      opacity: 0;
      transition: opacity var(--dur-out) var(--ease-out-quart);
    }

    .card__caption {
      margin: 0.75rem 0 0;
      color: #475569;
      /* Starts nudged down and transparent; both are composited properties. */
      opacity: 0;
      transform: translateY(6px);
      transition:
        opacity var(--dur-out) var(--ease-out-quart),
        transform var(--dur-out) var(--ease-out-quart);
    }

    /* Hover and keyboard focus share ONE declaration block. They cannot
       drift apart later because there is only one place to edit. */
    .card:hover,
    .card:focus-visible {
      transform: translateY(var(--lift));
      transition-duration: var(--dur-in);   /* overrides the slower exit */
    }

    .card:hover::before,
    .card:focus-visible::before {
      opacity: 1;
      transition-duration: var(--dur-in);
    }

    .card:hover .card__caption,
    .card:focus-visible .card__caption {
      opacity: 1;
      transform: translateY(0);
      /* Land the caption slightly after the lift starts, so the two reads
         as one gesture rather than two simultaneous events. */
      transition-duration: var(--dur-in);
      transition-delay: 60ms;
    }

    .card:focus-visible {
      outline: 3px solid #2d5bff;
      outline-offset: 3px;
    }

    /* Respect the OS-level motion preference: keep the end states, drop the
       interpolation. The card still changes; it just no longer travels. */
    @media (prefers-reduced-motion: reduce) {
      .card,
      .card::before,
      .card__caption {
        transition-duration: 1ms;
        transition-delay: 0ms;
      }
      .card:hover,
      .card:focus-visible { transform: none; }
    }
  </style>
</head>
<body>
  <article class="card" tabindex="0">
    <h3>Compositor-safe card</h3>
    <p>Hover me, or Tab to me — the effect is identical.</p>
    <p class="card__caption">Nothing here touches layout.</p>
  </article>
</body>
</html>

The technique that makes it work

The load-bearing trick is the ::before shadow layer. A box-shadow transition is expensive because the browser must regenerate the blurred alpha mask for every intermediate radius, and blur is not cheap. Painting that shadow once onto a pseudo-element at its final strength, then transitioning only that element's opacity between 0 and 1, moves the entire effect to the compositor: the blurred bitmap already exists, and each frame just blends it at a different alpha. Visually the two approaches are almost indistinguishable, because a shadow growing and a shadow fading both read as "this thing rose". The z-index: -1 puts the layer behind the card's own background so the blur never darkens the card face, and border-radius: inherit keeps the silhouette correct if the corner radius ever changes.

The second, smaller trick is where each duration is declared. Putting --dur-out on the base rule and overriding with transition-duration: var(--dur-in) inside the hover rule works because a transition uses the duration that is in effect for the state being transitioned to. Entering hover reads the hover rule's fast duration; leaving hover falls back to the base rule's slower one. You get asymmetric timing without a single extra selector.


Variation: an image zoom that cannot leak

The same rules extend to a media card where the image scales inside a fixed frame. The frame clips, the image transforms, and the layout box never changes size:

.media {
  overflow: hidden;          /* the clip: the frame's box is fixed */
  border-radius: 14px;
  /* Forces the browser to clip the scaled child against a rounded corner
     correctly on every engine. */
  isolation: isolate;
}

.media img {
  display: block;
  width: 100%;
  transform: scale(1);
  transition: transform 420ms cubic-bezier(0.25, 1, 0.5, 1);
}

.card:hover .media img,
.card:focus-visible .media img {
  transform: scale(1.06);
}

@media (prefers-reduced-motion: reduce) {
  .media img { transition-duration: 1ms; }
  .card:hover .media img { transform: none; }
}

Note that the hover target is the whole card, not the image. Hovering the image directly would make the effect stutter, because scaling the image moves its own edges under the pointer and can repeatedly re-trigger and cancel the hover state at the boundary. Anchoring the trigger to a parent whose geometry never moves is the general fix for that class of flicker.

On a device with no hover at all, none of these rules ever match — and a card whose only affordance is a hover reveal becomes a card with no affordance. Gating them by input capability with @media (hover: hover) is covered in pointer and hover media queries, which is the layer you should wrap around anything here that hides functionality.

Browser support

Everything in the implementation is long-settled. transition, 2D transform and CSS custom properties have been supported across every current engine for many years; prefers-reduced-motion is available in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+. No @supports guard is needed anywhere: an engine that does not understand a custom property drops the declaration and falls back to the browser default, and one that ignores prefers-reduced-motion simply keeps the animation, which is the pre-existing behaviour rather than a regression.

Common issues and direct fixes

IssueCauseFix
Hover state sticks after a tap on mobileTouch browsers emulate a lingering hover on the last-tapped elementGate hover rules behind @media (hover: hover) and use :active for tap feedback
Effect flickers along one edgeThe animated element is also the hover target and its own edge crosses the pointerMove :hover to a parent whose box does not move
Text looks blurry while scaledThe layer is rasterised at its pre-scale resolution and stretchedPrefer scaling images over text, or scale a wrapper and counter-scale the text
Transition ignored on first interactionThe property had no computed start value, often from display: noneGive the element an explicit initial value in the base rule

FAQ

Why does my hover lift nudge the elements around it? Because you are animating a property that participates in layout, such as height, margin or top. Those change the box geometry, so neighbours move with it. Animate transform instead, which offsets the painted result without changing the box the layout engine reasons about.

How do I animate a box-shadow without the paint cost? Do not animate the shadow itself. Paint the final shadow once onto an absolutely positioned pseudo-element at opacity zero, then transition only that pseudo-element's opacity. The blur is rasterised a single time and every frame afterwards is a cheap composite.

Should hover-in and hover-out use the same duration? Usually not. A hover-in that lands in about 150 to 200 milliseconds feels responsive, while the same speed on the way out feels abrupt. Declaring the slower duration on the base rule and the faster one on the hover rule gives you a quick entry and a relaxed exit.

Do I need to repeat every hover rule for keyboard users? You need the visual result, not the rule. Chain the selectors, writing .card:hover, .card:focus-visible as one selector list so both modalities share a single declaration block and cannot drift apart as the component changes.

Related articles

More pages in the same section.