Animating box-shadow and Filters Cheaply

Shadows and filters make interfaces feel physical: a card lifts on hover, a button glows when focused, a background blurs behind a dialog. They are also among the most expensive things to animate, because each frame changes pixels the browser must repaint — and blurred pixels are costly to compute. Performance advice often says "only animate transform and opacity", which sounds like a ban on these effects. It is not. With a few techniques, every one of them can be expressed as an opacity or transform change over something painted once. This page shows those techniques and when each is worth the extra markup. It belongs to Performance & GPU Acceleration in the CSS-Only Micro-Interactions & Animations guide.

Why these effects are costly

A box-shadow with a blur radius is rasterised by drawing the shape and blurring it; the cost grows with the blur radius and the area covered. When the shadow's offset, blur or colour changes, the browser repaints it. filter: blur() blurs the element's own pixels, and backdrop-filter blurs whatever lies behind the element — which must be recomputed whenever that background changes. None of these can be animated by simply moving an existing layer, so they fall back to paint on every frame.

Three effects, three per-frame costs Three cards. The first shows a shadow growing, labelled repaint shadow every frame. The second shows content going from sharp to blurred, labelled re-blur pixels every frame. The third shows a frosted panel over scrolling content, labelled re-blur backdrop whenever content behind moves. Why shadows and blurs are paint-bound box-shadow filter: blur() backdrop-filter repaint every frame re-blur every frame re-blur as content moves

The complete implementation

Two effects in one demo: a card that lifts on hover, and a glow on focus. Both use pre-painted layers whose opacity is animated.

Live demoElevation and glow from pre-painted layers
Hover the card to lift it, and hover or tab to the button to show its glow. Both effects fade a shadow that was painted once; nothing repaints per frame.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cheap shadows and glows</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; display: grid; gap: 2rem; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }

  /* Elevation: a resting shadow plus a pre-painted "lifted" shadow. */
  .card {
    position: relative;
    padding: 1.25rem;
    border-radius: 12px;
    background: #fff;
    box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);   /* static, painted once */
    transition: translate 200ms cubic-bezier(0.2, 0, 0, 1);
  }
  .card::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    box-shadow: 0 14px 30px rgb(0 0 0 / 0.2);  /* the lifted shadow */
    pointer-events: none;
    opacity: 0;
    transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1);
  }
  .card:hover { translate: 0 -3px; }
  .card:hover::after { opacity: 1; }

  /* Glow: a soft halo painted once, faded in on hover and focus. */
  .glow-btn {
    position: relative;
    padding: 0.75rem 1.25rem;
    border: 0;
    border-radius: 999px;
    background: #7c3aed;
    color: #fff;
    font: inherit;
  }
  .glow-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    box-shadow: 0 0 18px 6px #a78bfa;   /* rasterised once; never animated */
    pointer-events: none;
    opacity: 0;
    transition: opacity 180ms ease-out;
  }
  .glow-btn:focus-visible { outline: 2px solid #4c1d95; outline-offset: 3px; }
  .glow-btn:focus-visible::before,
  .glow-btn:hover::before { opacity: 0.8; }

  @media (prefers-reduced-motion: reduce) {
    .card:hover { translate: none; }
  }
</style>
</head>
<body>
  <article class="card"><h3>Lifted card</h3><p>Hover me.</p></article>
  <button class="glow-btn" type="button">Glowing button</button>
</body>
</html>

Both pseudo-elements rely on a detail of box-shadow: an outer shadow is only drawn outside its element's border box. The pseudo-elements have no background and cover exactly the card or button, so their shadows appear around the edges and nothing is painted over the content — no z-index juggling is needed. pointer-events: none keeps them from intercepting clicks and hovers. Each shadow is rasterised once, when the page first paints; hovering and focusing only change the pseudo-element's opacity.

The key technique: crossfade between pre-rendered states

Every technique on this page is the same move: render the expensive end state once, then crossfade to it. For shadows, the end state is a second shadow on a pseudo-element. For blur, it can be a pre-blurred duplicate of an image, faded in over the sharp one. For a colour glow, it is a blurred shape whose opacity changes.

Two layers, one opacity A stack of two layers. The bottom layer is the element in its resting state, painted once. The top layer is the expensive effect, such as a large shadow or a blur, also painted once. An opacity slider from 0 to 1 on the top layer is the only thing that changes during the animation. Only the opacity moves effect layer painted once base layer painted once opacity 0 opacity 1

The crossfade is not identical to animating the property. A shadow that animates from small to large grows smoothly; a crossfade blends two shadows, and at the midpoint both are half-visible. For elevation changes of a few pixels, the difference is imperceptible. For dramatic changes, add a small translate or scale on the effect layer so the shadow appears to spread, which is still composite-only.

Grids multiply the cost

A single card repainting its shadow for 200 milliseconds rarely causes a visible problem. The trouble starts in grids. Moving the pointer across a product grid enters and leaves several cards per second; each one starts a shadow transition in, and the one before it starts a transition out. At any moment three or four large blurred shadows may be repainting simultaneously, alongside whatever else the page is doing. That is where frame drops appear, and it is why the pseudo-element approach is worth adopting as the default for any repeated component, even though a one-off hero card would be fine either way.

The pre-painted layers are not free either. Each pseudo-element that animates opacity may be promoted to its own compositor layer while it animates, and each layer costs memory proportional to its area — including the shadow's blur extent. On a grid of fifty cards, that memory is only claimed for the cards currently animating, so the trade is favourable. Declaring will-change: opacity on all fifty pseudo-elements in advance, by contrast, would claim it for all of them at once; leave will-change off and let the browser promote layers on demand. will-change and the Compositor Thread explains when promoting early is worth it.

Blur transitions

Animating filter: blur(0) to blur(8px) on a large image is one of the most expensive animations available: every frame re-blurs every pixel at a new radius. Two cheaper routes exist. The first is the crossfade: stack a pre-blurred copy — a second <img> with a static filter: blur(8px), or a separately exported blurred image — and fade it in. The second, for background dimming behind modals, is to skip blur altogether: a semi-transparent dark overlay faded in with opacity conveys "the background is inactive" almost as well at a fraction of the cost.

backdrop-filter: price it per frame

backdrop-filter: blur() produces frosted-glass panels by blurring whatever is behind the element. It is not animated in the usual sense, but it is recomputed whenever the backdrop changes: during scrolling, under an animated background, or while the panel itself moves. A sticky frosted header over a scrolling page re-blurs a header-sized area on every scroll frame. That is acceptable for a small header on a capable device and noticeable on a large panel or a slow phone. Keep frosted areas small, keep the blur radius modest, and consider a solid, slightly translucent background as the fallback under prefers-reduced-transparency where it is supported. Animating the backdrop-filter value itself — fading a blur in behind a dialog — combines both costs and is best replaced with an opacity-animated overlay. Backdrop Animations covers dialog backdrops specifically.

drop-shadow versus box-shadow

filter: drop-shadow() follows the element's alpha shape — useful for transparent PNGs, SVG icons and clipped shapes — while box-shadow follows the border box. Both are paint-bound, and drop-shadow is often the more expensive of the two because it must trace the alpha channel. When the shape is a rectangle or rounded rectangle, prefer box-shadow. When an irregular shape needs an animated shadow, the pseudo-element technique still applies: a second copy of the shape with a static drop-shadow, faded in.

Browser support

box-shadow, filter and opacity transitions are supported in every current browser. The standalone translate property used in the implementation is supported in Chrome and Edge 104+, Firefox 72+ and Safari 14.1+, and prefers-reduced-motion in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+. backdrop-filter is widely supported in current browsers but has historically needed a -webkit- prefix in Safari; include both forms. Whether any engine can composite filters without repainting varies by version, so measure before relying on it.

FAQ

Why is animating box-shadow slow? A box-shadow change repaints the shadow on every frame, and blurred shadows are expensive to rasterise, especially large ones. On a grid of cards, several shadows repainting at once can exceed the frame budget.

How do I animate a shadow cheaply? Paint the final shadow once on a pseudo-element and animate its opacity. The shadow's pixels are drawn a single time and each frame only changes how transparent the layer is, which the compositor handles.

Is animating filter: blur() expensive? Usually yes, because the blur radius changes the rendered pixels every frame and blur cost grows with radius and area. Crossfading between an unblurred element and a pre-blurred copy with opacity is typically much cheaper.

Should I avoid backdrop-filter? Use it sparingly. backdrop-filter blurs whatever is behind the element every frame that the content behind it changes, including while scrolling. A small, static frosted panel is fine; a large one over animated or scrolling content is costly.

Related articles

More pages in the same section.