Home / Articles / Animation & Motion /
Shadow transitions: performance tips
Box-shadow is a paint-heavy property. Learn when shadow transitions are acceptable and how to minimize their performance cost.
The cost of shadow animations
Shadow effects are visually appealing — a subtle elevation on hover, a glowing pulse on focus. But box-shadow is a paint-only property. Unlike transform and opacity, which can run entirely on the compositor thread, shadow changes force the browser to repaint the element on every frame.
When you animate a shadow, the browser must calculate new pixel values for every frame of the animation. The paint operation includes compositing the shadow color with the background, applying the blur filter across all affected pixels, and potentially recalculating anti-aliasing at the edges. This is significantly more expensive than moving a layer around on the GPU.
/* Expensive: forces paint on every frame */
.card {
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.2);
transition: box-shadow 0.25s ease;
}
.card:hover {
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.3);
}
/* Performance: Paint → Paint → Paint → ... (per frame) */
For short, subtle transitions (100-200ms), the paint cost is usually acceptable. For longer animations or complex shadows (multiple layers, large blur radii), the performance impact becomes noticeable, especially on mobile devices. On modern desktop hardware, a simple shadow transition at 60fps might consume 2-3 milliseconds per frame. On mobile, that can jump to 8-15ms, which is enough to cause visible frame drops if other work is happening simultaneously.
when shadow transitions are acceptable
Not every shadow animation needs to be avoided. Modern browsers have optimized shadow painting significantly. A single shadow layer with a moderate blur radius (< 20px) will typically run at 60fps even on older hardware. Issues arise when:
- The transition is long (500ms+) — long paint operations become perceptible as stutter.
- Multiple elements animate shadows simultaneously — each element adds paint work.
- The shadow has large blur radii (100px+) — blur is computationally expensive.
- Multiple shadow layers change together — each layer multiplies the cost.
In practice, shadow transitions work well for modal dialogs appearing on screen, card elevations on hover that last under 200ms, and focus indicators that need to draw attention without complex motion. The key is knowing when the visual benefit justifies the performance cost. A button that animates its shadow on every hover in a grid of 20 items will create noticeable jank. A single card in a dashboard that elevates on hover is usually fine.
the shadow-opacity trick
Instead of animating the shadow's size, animate its opacity while keeping the shadow geometry constant. This still triggers paint but reduces the complexity of each paint operation. The browser can reuse the same blurred edge calculations since the blur radius and offset never change.
/* Better: shadow is always painted, opacity changes */
.card {
box-shadow: 0 8px 24px oklch(0% 0 0 / 0);
transition: box-shadow 0.25s ease;
}
.card:hover {
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.3);
}
/* Geometry doesn't change — simpler paint operation */
This approach produces a similar visual effect — the shadow appears to grow as it darkens — but with less computational work. The blur radius stays constant, so the browser uses the same shader across all frames. This trick works especially well when combined with a subtle scale transform on the element itself, creating the illusion of a growing shadow while doing minimal paint work.
The alpha channel in oklch makes this technique particularly clean. You write the same shadow definition in both states and only change the alpha value. This also makes the CSS more maintainable since you're not duplicating the blur radius and offset values across multiple rules.
using transform to simulate shadow growth
A more performant alternative is to keep the shadow static and scale a pseudo-element that has the shadow. The pseudo-element scales on the compositor thread, avoiding paint operations. This technique creates a true compositor-only animation by moving the shadow calculation outside the animation cycle.
.card {
position: relative;
z-index: 1;
}
.card::before {
content: "";
position: absolute;
inset: -4px -4px -8px -4px;
background: oklch(0% 0 0 / 0.3);
filter: blur(16px);
opacity: 0;
transform: scale(0.9);
transition: opacity 0.25s, transform 0.25s;
z-index: -1;
}
.card:hover::before {
opacity: 1;
transform: scale(1);
}
/* Shadow is compositor-only via transform/opacity */
This is more complex to implement but can be worth it for frequently animated elements like navigation cards or interactive dashboard widgets. The tradeoff is increased CSS complexity for better runtime performance. The pseudo-element approach does have one limitation: it increases the element's layout footprint since the scaled shadow needs space to render, so ensure your container can accommodate the expanded dimensions.
An alternative variation uses a wrapper element instead of a pseudo-element, which can be easier to maintain in component-based frameworks. The wrapper sits behind the content layer and handles all shadow-related animations while the content layer remains unchanged.
minimal shadow for hover feedback
Often you don't need to animate the shadow at all. A static shadow on hover provides sufficient depth feedback without any animation cost. The instant feedback is often preferable to the delayed sensation of an animated shadow.
.card {
box-shadow: 0 2px 4px oklch(0% 0 0 / 0.1);
transition: background-color 0.2s;
}
.card:hover {
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.2);
/* No transition — instant shadow change */
}
This approach also has better accessibility: users with cognitive disabilities may find instant feedback preferable to delayed animations. The instant shadow still communicates the interactive state clearly. From a UX perspective, instant shadow changes can feel more responsive since there's no lag between the user's action and the visual feedback. This is particularly important for keyboard users who expect immediate state changes when navigating with Tab and Enter.
If you still want some animation, consider animating only the background color or a subtle scale transform while leaving the shadow instant. This gives the illusion of motion without the paint cost of shadow transitions. The human eye is less sensitive to instant shadow changes than to missing motion cues on other properties.
drop-shadow for irregular shapes
The drop-shadow() filter works with the element's alpha mask, making it perfect for non-rectangular shapes. Like box-shadow, animating drop-shadow() parameters triggers paint on every frame. The difference is that drop-shadow() must analyze the alpha channel of the entire element, which can be even more expensive for complex shapes or images with detailed edges.
/* Expensive: animating filter */
.shape {
filter: drop-shadow(0 2px 4px oklch(0% 0 0 / 0.3));
transition: filter 0.3s;
}
.shape:hover {
filter: drop-shadow(0 8px 16px oklch(0% 0 0 / 0.4));
}
/* Better: keep filter static, animate wrapper */
.shape-wrapper {
transition: transform 0.3s ease;
}
.shape-wrapper:hover {
transform: scale(1.02);
}
/* Drop-shadow stays constant, transform is compositor-only */
When you need to highlight an irregular shape on hover, consider wrapping it and scaling the wrapper rather than animating the drop-shadow itself. This pattern works well for avatar images, icon badges, and decorative assets that don't have a simple rectangular bounding box. For SVG content, consider using feDropShadow in the SVG itself for potentially better performance, as the browser can optimize SVG filters differently than CSS filters.
One important limitation: drop-shadow() cannot benefit from the opacity trick mentioned earlier since the filter syntax doesn't allow animating just the alpha component. This makes wrapper-based approaches even more important for dropped-shadow animations on irregular shapes.
layering multiple shadows
Multiple shadow layers for realistic depth are common in modern UI. Three-layer shadows create a sense of atmospheric perspective: a tight dark shadow for contrast, a medium shadow for depth, and a large soft shadow for ambient occlusion. But animating each layer multiplies the paint cost. Use fixed-layer shadows that appear/disappear rather than animating each layer's values.
/* Don't animate each layer */
.card {
box-shadow: 0 2px 4px oklch(0% 0 0 / 0.1),
0 8px 24px oklch(0% 0 0 / 0.15),
0 16px 48px oklch(0% 0 0 / 0.2);
transition: box-shadow 0.3s; /* Animating all 3 layers */
}
/* Better: change opacity of entire shadow stack */
.card {
box-shadow: 0 2px 4px oklch(0% 0 0 / 0),
0 8px 24px oklch(0% 0 0 / 0),
0 16px 48px oklch(0% 0 0 / 0);
transition: box-shadow 0.25s; /* Just opacity */
}
.card:hover {
box-shadow: 0 2px 4px oklch(0% 0 0 / 0.1),
0 8px 24px oklch(0% 0 0 / 0.15),
0 16px 48px oklch(0% 0 0 / 0.2);
}
By keeping blur radii and offsets constant across animation states, you reduce the complexity of each paint operation. The browser can optimize the paint more effectively when the geometry is static. This technique also simplifies maintenance since all shadow values live in one place – just toggle the alpha values to control visibility.
For even better performance with layered shadows, combine this approach with the opacity trick: define each layer at full opacity in the base state, then fade them out entirely for the hidden state. This keeps the paint work consistent across both states while still appearing to show/hide the shadow.
oklch vs rgba in shadows
Modern oklch() syntax with alpha doesn't change the paint cost compared to rgba(), but it makes colors more predictable in animations. When transitioning between shadows with different hues, oklch produces smoother, more consistent color transitions. This is because oklch is perceptually uniform – equal numeric changes produce equal perceptual changes.
/* Oklch produces more natural color transitions */
button {
box-shadow: 0 4px 16px oklch(0.4 0.22 250 / 0.3); /* Blue glow */
transition: box-shadow 0.2s;
}
button.danger {
box-shadow: 0 4px 16px oklch(0.4 0.22 30 / 0.3); /* Red glow */
}
This is a perceptual improvement rather than a performance one, but it's worth using oklch() consistently across your animations for a polished look. The chroma and lightness values in oklch make it easy to maintain consistent shadow intensity across different hues. With rgb or hsl, shifting hues often requires manually adjusting the values to keep the shadow looking equally prominent.
Additionally, oklch provides better future-proofing. As browsers continue to optimize color space conversions, oklch values are more likely to remain performant since they align better with how human vision works. The alpha channel syntax is also cleaner, making shadow definitions more readable when you need to adjust transparency.
performance checklist
Before shipping shadow transitions, verify:
- Keep blur radius under 30px for animations. Larger blurs require more pixel sampling.
- Keep durations under 250ms for hover effects. Longer than that and paint becomes noticeable.
- Use
will-change: box-shadowsparingly — it creates a GPU texture but doesn't eliminate paint. - Test on mobile devices — they have less GPU memory and less powerful blurs.
- Consider instant shadow changes for frequently triggered interactions.
- Use
contain: painton elements with heavy shadows to limit paint scope.
These guidelines help ensure your shadow animations remain smooth across a wide range of devices. The 30px blur threshold is a good rule of thumb – above this value, performance degradation becomes more pronounced on mid-range mobile hardware. The 250ms duration guideline balances perceived smoothness with paint overhead; longer animations keep the paint pipeline busy for more frames, increasing the chance of frame drops.
css custom properties for shadow control
CSS custom properties let you manage shadow values in one place and adjust them based on user preferences or performance settings. This is especially useful for implementing reduced-motion alternatives or dynamic shadow intensity controls.
:root {
--shadow-color: oklch(0% 0 0 / 0.25);
--shadow-offset-y: 8px;
--shadow-blur: 24px;
}
.card {
box-shadow: 0 var(--shadow-offset-y) var(--shadow-blur) var(--shadow-color);
transition: box-shadow 0.25s ease;
}
.card:hover {
--shadow-color: oklch(0% 0 0 / 0.4);
--shadow-offset-y: 12px;
}
While animating custom properties still triggers paint for shadows, using them simplifies maintenance and enables dynamic adjustments. You can use JavaScript to detect performance issues and automatically reduce shadow complexity by modifying these variables. This provides a graceful degradation path for slower devices without requiring separate CSS rules.
Custom properties also work well with prefers-reduced-motion queries. You can define a base shadow and conditionally set it to zero opacity when users request reduced motion, eliminating the transition entirely for those who need it.
browser support and fallbacks
Modern shadow syntax with oklch has broad support in Chrome, Firefox, Safari, and Edge. However, older browsers will ignore oklch colors entirely, causing shadow transitions to fail silently. Always provide a fallback for critical shadow effects.
.card {
/* Fallback for older browsers */
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
/* Modern oklch syntax */
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.25);
transition: box-shadow 0.25s ease;
}
The browser applies the last valid declaration, so older browsers keep the rgba fallback while modern browsers use oklch. For feature detection, use the @supports rule to conditionally apply optimizations that only work in modern environments. This approach ensures that all users get functional shadows while modern browsers benefit from the improved color transitions and cleaner syntax.
Consider using CSS feature queries to serve simpler shadow animations to older browsers that lack performant painting engines. The performance characteristics of shadow animations in 2015-era browsers are significantly worse than modern equivalents, so detecting and adapting to those limitations can improve the experience for users on older hardware.