Replacing box-shadow Animation with Layered Opacity

Part of Compositor-Only Property Optimization in Performance Budgeting & GPU Architecture.

The problem

The hover lift is the most common interaction in interface design: a card rises slightly and its shadow deepens. The rise is a transform and costs nothing. The shadow is a box-shadow transition and costs more than everything else on the page combined.

It does not look expensive. A shadow is soft, subtle, and drawn behind the element. But box-shadow is a paint property, and a blurred shadow paints over a region substantially larger than the element that owns it.

Root cause: the blur expands the dirty rectangle

A repaint’s cost is the area of the invalidated region multiplied by the square of the device pixel ratio. For a plain background change, that region is the element’s border box. For a shadow, it is the border box expanded on every side by roughly the blur radius plus the spread, plus the offset.

Take a 320 × 180 card with box-shadow: 0 18px 40px rgb(0 0 0 / 25%). The invalidated region is about 400 × 276 — more than 90,000 additional pixels beyond the card itself. At a device pixel ratio of 3 that is close to a million device pixels re-rasterised, on the main thread, on every frame of the transition.

Worse, that region overlaps the card’s neighbours. Without a repaint boundary, the neighbours are re-examined too, and on a grid of cards the invalidation from one hover can cascade across several tiles — the mechanism described in paint invalidation and repaint boundaries.

The fix follows from noticing that there are only ever two shadow states. Both can be painted once, in advance, and blending between two already-rastered layers is exactly what the compositor exists to do.

Two ways to deepen a shadow on hoverVisually identical; the frame path is completely different.Two ways to deepen a shadow on hovertransition: box-shadowRepaints an area larger than the cardRuns on the main thread every frameInvalidation overlaps neighbouringcardsCost scales with the square of thepixel ratioPaint on every frameCross-faded shadow layersBoth shadows rasterised onceOnly opacity changes per frameGPU blends two existing texturesCost independent of blur radiusComposite only
Visually identical; the frame path is completely different.

Step-by-step resolution

Converting a shadow transitionTwo pseudo-elements, one opacity transition, one containment declaration.Converting a shadow transition1Write down the resting shadow and the raised shadow as two completevalues.These become two static layers2Move each onto a pseudo-element that fills the card and inherits itsradius.Each is painted once, on first render3Set the raised layer to opacity 0 and transition only opacity.The frame path is now composite-only4Add pointer-events: none so neither layer intercepts input.Hover and click behaviour is unchanged5Add a repaint boundary on the card so the first paint cannot dirtyneighbours.
Two pseudo-elements, one opacity transition, one containment declaration.

Production code pattern

/* Intent: a card whose shadow deepens on hover with no per-frame paint.
   Two pseudo-element layers, each holding one static shadow. */
.card {
  position: relative;
  border-radius: 12px;
  contain: layout style;            /* not paint: the shadows must escape */
  transition: translate 180ms cubic-bezier(0.2, 0, 0, 1);
}

.card::before,
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  z-index: -1;                      /* behind the card's own content */
}

/* Resting shadow: painted once, always visible. */
.card::before {
  box-shadow: 0 2px 6px rgb(30 27 75 / 12%);
}

/* Raised shadow: painted once, faded in on hover. */
.card::after {
  box-shadow: 0 18px 40px rgb(30 27 75 / 26%);
  opacity: 0;
  transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
}

.card:hover,
.card:focus-within { translate: 0 -4px; }

.card:hover::after,
.card:focus-within::after { opacity: 1; }

/* Accessibility gate: keep the shadow change, drop the lift. The depth cue
   still communicates the hover without any travel. */
@media (prefers-reduced-motion: reduce) {
  .card { transition: none; translate: 0 0; }
  .card:hover { translate: 0 0; }
  .card::after { transition-duration: 90ms; }
}

Rendering Impact: opacity + translate — composite only. Both shadows are rasterised on first paint and never again; the hover blends two existing textures and moves one matrix.

Note that the containment here is layout style, deliberately not paint. Paint containment would clip the shadows to the card’s border box, which defeats the entire effect. This is the case where the weaker keyword is the correct one, and where reaching reflexively for contain: content breaks the design.

Per-frame cost of the hover, 320x180 card at device pixel ratio 3The cross-fade cost is independent of blur radius; the paint cost is not.Per-frame cost of the hover, 320x180 card at device pixel ratio 3whole frame 16.7 mstransform only (no shadow change)0.4 msCross-faded shadow layers0.6 msbox-shadow transition, 20px blur6.9 msbox-shadow transition, 40px blur11.8 msbox-shadow on a grid of six cards24.5 ms
The cross-fade cost is independent of blur radius; the paint cost is not.

Verification checklist

Constraints and trade-offs

  • Two pseudo-elements are consumed, so a component that already uses both needs a wrapper element instead.
  • z-index: -1 places the layers behind the card’s background, which requires the card itself to have a background or the layers show through.
  • Both shadows are rasterised at first paint, so a page with hundreds of cards pays a slightly larger initial cost in exchange for free interactions.
  • The pattern cannot express a shadow whose geometry is genuinely computed at runtime, such as one that tracks a pointer position.
  • Stacking contexts matter: an ancestor with isolation or a transform can change where z-index: -1 resolves.

Frequently asked questions

Why is animating box-shadow so expensive?

Because the shadow is painted, not composited, and its blur expands the invalidated region well beyond the element’s border box. Changing the shadow on every frame means re-rasterising that whole expanded region sixty times a second, on the main thread.

Can I animate the shadow’s colour instead of its geometry?

It is cheaper than animating blur or spread, but it is still a paint on every frame over the same expanded region. The cross-fade approach removes paint from the frame entirely, which is a different order of improvement.

Does this pattern work for text-shadow and drop-shadow too?

The same principle applies: rasterise the two states and cross-fade. For text it is often simpler to stack two copies of the text, and for filter: drop-shadow() the layered approach is the only way to avoid a per-frame re-raster.


When the two-layer trick is the wrong answer

The layered cross-fade is the right default, and there are three cases where it is not worth the extra pseudo-elements.

The shadow never changes. A card with one static shadow needs no layers at all — a static box-shadow is painted once and costs nothing per frame. The technique only pays when the shadow animates.

The change is imperceptible. A shadow that moves from 0 2px 4px to 0 3px 6px is a two-pixel difference that most people will not consciously see. If the design’s intent is “the card responds”, a translate of a few pixels communicates it more clearly and costs nothing.

The shadow is genuinely dynamic. A shadow whose offset tracks a pointer — the pseudo-3D tilt effect — cannot be pre-rasterised, because there is no fixed set of states. Here the honest options are to animate the filter and cap the number of simultaneous instances to one, or to fake the depth with a transform on an already-painted layer instead.

There is also a maintenance cost worth naming. Two pseudo-elements is two more things for a future contributor to trip over, and z-index: -1 in particular behaves unexpectedly once an ancestor gains a stacking context. Leaving a comment that says why the shadow lives on a pseudo-element saves the next person from “simplifying” it back into a transition.