Painting a shadow once is cheap
A shadow is painted, meaning the browser rasterises it into pixels in the paint phase of rendering, along with backgrounds, borders and text. On a modern phone that happens in well under a millisecond for a card-sized element, and it happens once: when the element first appears, and again only when something about it changes. A page with two hundred shadowed cards is not a slow page. The generator on this site paints its preview box with up to twelve layers and updates it on every slider tick, and it stays smooth, because each update is one paint of one element.
So the property is not the problem. The habit that is a problem is the hover state that says transition: box-shadow 0.3s, which turns one paint into eighteen.
Animating box-shadow is not
Tobias Ahlin's article on the subject opens with the plainest possible warning, and I would put it on a sticker if I could:
"Animating a change of box-shadow will hurt performance."
Tobias Ahlin, How to animate box-shadow with silky smooth performance
The reason is the rendering pipeline. Only two properties can change without triggering paint: transform and opacity, which the compositor handles on the GPU. Everything else, box-shadow included, needs the element repainted on every frame of the animation. A 300 millisecond transition at 60 frames per second is eighteen repaints of the element and its blur, and on a grid of cards where several hover states overlap, eighteen repaints each. On a desktop you will not notice. On a mid-range Android phone the hover-equivalent tap feedback stutters, and Interaction to Next Paint, the Core Web Vital that measures exactly this, goes up.
The web.dev guide to high-performance animations, by Kayce Basques and Rachel Andrew, gives the rule and the reason together: "restrict animations to opacity and transform to keep animations on the compositing stage", and "avoid any property that triggers layout or paint unless it's absolutely necessary". It also singles out shadows specifically, noting that "anything that involves a blur (like a shadow, for example) takes longer to paint than drawing a red box". Blur is the expensive paint; animating it is the expensive paint repeated.
The pseudo-element crossfade
The fix is Ahlin's, and it is the technique behind every hover state on this site: paint both shadows once, and fade between them with opacity. The element keeps its resting shadow. A pseudo-element behind it carries the lifted shadow at opacity: 0, and hover transitions that opacity to 1. Ahlin's own description: "animate the opacity of a pseudo-element".
.card {
position: relative;
box-shadow: 0 1px 2px rgba(27, 35, 64, 0.06); /* rest, painted once */
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 8px 24px rgba(27, 35, 64, 0.12); /* lift, painted once */
opacity: 0;
transition: opacity 200ms ease-out;
pointer-events: none;
z-index: -1;
}
.card:hover::after { opacity: 1; }Both shadows are rasterised when the card first renders. The hover changes only opacity, which the compositor blends without asking the main thread for anything. The result looks identical to a shadow transition and costs a fraction of it. The card hover example has both states as generator links and the finished CSS in every format.
Two details matter. The pseudo-element needs border-radius: inherit so its shadow follows the card's corners, and it needs z-index: -1 on a positioned parent with its own stacking context, or it will sit over the card's content. If the card already uses ::after, use ::before; if it uses both, a real child element works the same way.
Lift with transform, not with a bigger shadow
A hover lift reads as the element rising toward you: the shadow gets softer and wider, and the element moves up a pixel or two. The move is transform: translateY(-2px), which is free. Pair it with the opacity crossfade and the whole hover is composited. What you should not do is make the shadow itself grow by animating its blur, or scale the element with a shadow attached, because scaling a painted shadow blurs it a second time and the edges go soft in a way that reads as low resolution rather than as depth.
Blur is the expensive part
Among the four lengths, blur is the one with a cost. Offset and spread move or resize a rectangle; blur runs a Gaussian filter over it, and the work grows with the area the blur covers. A 4 pixel blur on a button is trivial. A 120 pixel blur under a full-width hero panel touches a large fraction of the viewport. The layered approach helps here too: three layers at 2, 8 and 24 pixels of blur cost less to paint than a single layer at 64, and look better. Keep single blur values under about 40 pixels unless the element is small, and be suspicious of any shadow whose blur is larger than the element it belongs to.
Shadows on long lists
Where the cost shows in practice is repetition. A product grid of two hundred cards, each with a three-layer shadow, paints fine on first render. It paints fine on scroll, because scrolling is compositing. The trouble is a staggered entrance animation that transitions each card's shadow as it appears, or a filter that re-renders the grid with new shadows on every keystroke. The rules that keep a list cheap: shadows on list items are two layers at most; entrances animate transform and opacity only; and the lifted shadow lives on a pseudo-element so the hover never repaints. The preset gallery on the home page follows exactly that, with 41 tiles each wearing a real layered shadow.
How to measure it yourself
- Open Chrome DevTools, go to the Performance panel and press Record.
- Hover on and off the element a few times, then stop.
- Look for purple Paint blocks in the main thread track during the hover. A transitioned box-shadow shows a paint on every frame; the crossfade shows one paint at the start and none after.
- Turn on Paint flashing in the Rendering drawer and hover again. Anything that flashes green is being repainted.
Do it on a real phone through remote debugging rather than on a laptop, because the laptop will hide the problem. Those four steps are the check to run on any hover state before it ships.
Frequently asked questions
Is CSS box-shadow bad for performance?
Not on its own. Painting a shadow once, when the page loads or when it changes, is cheap on any modern device. The cost comes from repainting it many times a second, which is what happens when you transition or animate the box-shadow property.
How do I animate a box shadow without lag?
Do not animate box-shadow. Put the hover shadow on a pseudo-element with opacity 0, and transition the opacity to 1 on hover. Opacity is composited on the GPU, so the fade costs almost nothing and the shadow itself is painted once.
Does blur radius affect performance?
Yes, more than any other value. Blur is a Gaussian filter over the shadow area, so a 100px blur on a large box touches far more pixels than a 4px one. Prefer a few moderate layers to a single huge blur.
Should I use will-change for box-shadow?
No. will-change: box-shadow does nothing useful, because shadows are painted, not composited. Use will-change: transform or opacity on the element you actually animate, and only while it is animating.
Do box shadows affect Core Web Vitals?
Painted once, no. Animated on hover across a grid of cards, they can push Interaction to Next Paint up on slower phones. The crossfade pattern keeps hover feedback under the 200ms INP target because nothing is repainted.
How many shadowed elements is too many?
There is no fixed number. A few hundred cards with a two-layer shadow paint fine. The trouble starts when they all animate at once, for instance a staggered reveal that transitions box-shadow, or when a huge blur sits under an element that scrolls inside a container.