Gradient, Shadow and Blur Repaint Costs
Part of Paint Invalidation & Repaint Boundaries in Performance Budgeting & GPU Architecture.
The problem
A card design has a soft shadow, a subtle gradient surface, a frosted backdrop behind its header and a glow that intensifies on hover. Individually each is a line of CSS. Together, on a grid of twenty cards, hovering one card produces Paint events of several milliseconds, and scrolling the grid on a phone drops frames even though nothing is animating.
Decoration is where most paint time goes, and the costs are not intuitive: a large blur can cost more than everything else on the page combined.
Root cause analysis: what each effect costs per pixel
Blur is the most expensive. A Gaussian-style blur samples many pixels for each output pixel, and the work grows with the blur radius as well as the area. Doubling the radius costs considerably more than doubling the area. filter: blur(), box-shadow with a large blur, text-shadow and drop-shadow() all pay it.
backdrop-filter pays it repeatedly. It must read what is behind the element, blur it, and composite — and it redoes that whenever the backdrop changes, which during scrolling is every frame, as described in promoting fixed and sticky elements.
Shadows cost blur plus area, and spread enlarges both. A shadow’s painted region is the element’s box grown by spread and blur, so a 40px blur with 12px spread on a 300px card paints a region far larger than the card. Inset shadows are clipped to the box but still blurred.
Gradients are cheaper than blur but not free. A linear gradient is a per-pixel computation; conic and radial gradients cost more, and many stops cost more than two. A full-viewport animated gradient is expensive mostly because of its area.
Solid fills are nearly free. A flat colour is a memory fill.
Stacking multiplies. An element with a gradient background, a blurred shadow and a backdrop filter pays all three every time it repaints.
Step-by-step resolution
Production code pattern
/* Expensive: three effects on an element that animates on hover. */
.card-old {
background: linear-gradient(160deg, var(--color-surface), var(--color-surface-2));
box-shadow: 0 24px 60px -16px rgb(16 12 32 / 0.5);
transition: box-shadow 200ms ease-out, translate 200ms ease-out;
}
.card-old:hover { box-shadow: 0 32px 80px -16px rgb(16 12 32 / 0.6); translate: 0 -4px; }
/* Affordable: gradient stays static, shadow is pre-painted and cross-faded. */
.card {
position: relative;
isolation: isolate;
background: linear-gradient(160deg, var(--color-surface), var(--color-surface-2));
box-shadow: 0 10px 24px -12px rgb(16 12 32 / 0.35); /* smaller radius at rest */
transition: translate 200ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
box-shadow: 0 24px 56px -16px rgb(16 12 32 / 0.55); /* painted once */
opacity: 0;
transition: opacity 200ms ease-out;
}
.card:hover { translate: 0 -4px; }
.card:hover::after { opacity: 1; }
/* Frosted header: small, non-scrolling surface only. */
.panel__header {
backdrop-filter: blur(10px); /* 260 px wide, static: acceptable */
}
.hero__overlay {
background: rgb(0 0 0 / 0.45); /* full-width: solid instead of backdrop-filter */
}
@media (prefers-reduced-motion: reduce) {
.card, .card::after { transition: none; }
.card:hover { translate: none; }
}
Rendering Impact: composite for the hover; one paint for the pre-rendered shadow layer. Halving the resting shadow’s blur radius also reduces the cost of every ordinary repaint of the card, including during scrolling.
Reducing blur radius is the least-discussed and most effective change. A 60px blur is rarely twice as beautiful as a 30px blur, and it can cost three times as much. Designers are usually willing to trade a few pixels of softness for smooth scrolling if the comparison is shown side by side.
Where the cost is worst
Three situations turn affordable decoration into a problem. Large areas: the same effect on a full-width hero costs an order of magnitude more than on a card. Repeated elements: twenty decorated cards repaint twenty times. And elements that repaint anyway: decoration inside a region that updates frequently — a live list, a sticky header over scrolling content — pays its cost on every one of those repaints, not only during animation.
The remedy for all three is the same as for any paint problem: bound the region with a repaint boundary so the decoration is not redrawn when unrelated content changes, as covered in using contain to scope repaints, and keep the decorated area as small as the design allows.
Verification checklist
Constraints and trade-offs
- Pre-painted shadow layers need a pseudo-element or wrapper and cost texture memory.
- Reducing blur changes the design; agree it with designers rather than unilaterally.
- Translucent solids are flatter than frosted glass.
- Pre-rendered gradient images do not adapt to theme changes without extra assets.
- Very small elements can afford effects that would be unaffordable at scale.
Frequently asked questions
Which is more expensive, a large shadow or a large gradient?
The shadow, usually, because blur samples many pixels per output pixel. Gradient cost is closer to proportional to area.
Does reducing blur radius really help?
Substantially. Blur cost grows quickly with radius, so halving the radius often more than halves the cost.
Why is backdrop-filter so much worse than filter?
It has to read and blur the content behind the element, and repeat that whenever the backdrop changes — during scrolling, every frame.
Can I animate a shadow cheaply?
Not directly. Pre-paint the target shadow on a layer and cross-fade it with opacity, which moves the work to the compositor.
Related
- Paint Invalidation & Repaint Boundaries — the parent topic
- Reading the Paint Profiler in DevTools — measuring which decoration costs most
- When filter and backdrop-filter Are Worth the Paint — deciding when the effect earns its cost