X Xerobit

CSS Box Shadow Performance — GPU Compositing and Repaint Optimization

Box shadows can hurt scroll and animation performance if used incorrectly. Learn which shadow properties trigger repaints, how to move shadows to the compositor layer, and...

Mian Ali Khalid · · 4 min read
Use the tool
Box Shadow Generator
Visual CSS box-shadow builder. Control offset, blur, spread, color, inset. Multi-layer shadows. Live preview + copyable CSS.
Open Box Shadow Generator →

Box shadows trigger paint (not just composite) when they change. For static shadows on static elements they’re free. For animated shadows, the paint cost matters.

Generate box shadows with the Box Shadow Generator.

The rendering pipeline

JavaScript → Style → Layout → Paint → Composite

box-shadow changes affect: Paint + Composite
transform/opacity changes affect: Composite only (GPU)

Animating box-shadow directly forces the CPU to repaint on every frame, limiting you to ~60fps on fast hardware and causing jank on slower devices.

Measuring shadow paint cost

// Chrome DevTools: Performance panel → record → look for "Paint" events
// Or use PerformanceObserver:
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'longtask') {
      console.warn(`Long task: ${entry.duration}ms`);
    }
  }
});
observer.observe({ entryTypes: ['longtask'] });

// CSS: mark elements for paint worklet inspection
// Open DevTools → Layers panel → see painted areas

The pseudo-element trick: animating opacity instead

Pre-render the final shadow state and animate its opacity — opacity is compositor-only:

/* Card with smooth shadow on hover */
.card {
  position: relative;
  transition: transform 0.3s ease;
  
  /* Base shadow (always rendered) */
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* Pre-render the hover shadow */
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  
  /* Hover shadow: large and blurry */
  box-shadow: 0 20px 40px rgba(0,0,0,0.25);
  
  /* Start invisible */
  opacity: 0;
  transition: opacity 0.3s ease;
}

.card:hover {
  transform: translateY(-4px);  /* Compositor-only */
}

.card:hover::after {
  opacity: 1;  /* Compositor-only */
}

This renders both shadows on every frame, but only changes opacity (GPU). No repaint on hover.

will-change: transform

/* Promote to its own compositor layer — shadows paint once */
.animated-card {
  will-change: transform;
  /* Now box-shadow is in its own layer, painted once */
}

/* For elements that are always animating: */
.floating-button {
  will-change: transform;
  animation: float 3s ease-in-out infinite;
}

/* Remove will-change after animation ends to free GPU memory: */
element.addEventListener('mouseenter', () => {
  element.style.willChange = 'transform';
});

element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

filter: drop-shadow vs box-shadow

filter: drop-shadow can be more or less performant depending on context:

/* box-shadow: respects border-radius, ignores transparent pixels */
.rounded-card {
  box-shadow: 0 4px 12px rgba(0,0,0,0.2);  /* Clips to border-radius */
}

/* filter: drop-shadow: follows alpha channel (good for PNGs, SVGs) */
.svg-icon {
  filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));
  /* Follows shape of the SVG paths, not the bounding box */
}

/* For complex SVGs: drop-shadow is GPU-accelerated via the filter pipeline */
/* For simple rectangles: box-shadow is faster */

/* Combining both (rare — adds shadow inside and outside) */
.complex {
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  filter: drop-shadow(0 4px 12px rgba(0,0,0,0.1));
}

Scrolling performance: stacking contexts

Shadows on scrollable list items cause per-item paint cost:

/* ❌ Every list item has its own shadow = expensive scroll */
.list-item {
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

/* ✅ Contain paint to a single stacking context */
.list-container {
  contain: paint layout;  /* New CSS Containment property */
}

/* ✅ Alternative: use outline for borders (no shadow needed) */
.list-item-simple {
  border-bottom: 1px solid rgba(0,0,0,0.08);
  /* No paint cost for border changes */
}

Reducing shadow complexity for lower-end devices

/* Use media query to detect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
  .card::after {
    display: none;
  }
}

/* CSS custom property + JS to reduce on slow devices */
// Detect low-end device and simplify shadows:
const isLowEnd = navigator.hardwareConcurrency <= 2 ||
  (navigator.deviceMemory && navigator.deviceMemory <= 2);

if (isLowEnd) {
  document.documentElement.classList.add('reduce-effects');
}
.reduce-effects .card {
  box-shadow: 0 1px 2px rgba(0,0,0,0.1);  /* Minimal shadow */
}
.reduce-effects .card::after {
  display: none;  /* No hover animation */
}

Background: how Chrome paints shadows

Chrome paints box shadows in a separate sub-pass from the background. For elements in their own compositor layer (will-change: transform, or transform: translateZ(0)), the shadow is rasterized once to a texture. Subsequent frames just move the texture — no repaint.

For elements NOT in a compositor layer, any change to the element (including parent layout changes during scroll) can trigger a repaint that includes re-drawing the shadow.

Summary checklist

  • Static shadows on static elements: no concern
  • Hover shadow changes: use ::after opacity trick
  • Scroll lists with shadows: add contain: paint
  • CSS animations with shadows: use will-change: transform
  • SVG/PNG shadows: prefer filter: drop-shadow
  • Always measure with DevTools Layers panel before optimizing

Related posts

Related tool

Box Shadow Generator

Visual CSS box-shadow builder. Control offset, blur, spread, color, inset. Multi-layer shadows. Live preview + copyable CSS.

Written by Mian Ali Khalid. Part of the Frontend & Design pillar.