Animating Backgrounds with Pseudo-Element Layers

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

The problem

A promotional banner animates its background gradient between two colour pairs. A button has a “shine” that sweeps across it on hover, implemented with an animated background-position. A skeleton placeholder shimmers using a moving gradient. Each of these repaints its element on every frame, and on a page with many skeleton placeholders the shimmer alone can consume most of the frame budget.

The visual results are all achievable with opacity and transform, which the compositor can animate without repainting anything.

Root cause analysis: backgrounds are painted, layers are composited

The background-* properties describe how an element’s own box is painted. Changing any of them — colour, image, position, size — invalidates that paint and re-rasterises the element’s area every frame, as described in border-radius and background animation costs.

A pseudo-element is a separate box. Its background is painted once, and moving or fading it afterwards is compositor work.

Two patterns cover nearly everything.

For a change between two backgrounds, stack the target background on a pseudo-element above the element’s own background and cross-fade with opacity. The two backgrounds are painted once each.

For a moving background, such as a shine or shimmer, put the gradient on a pseudo-element that is wider than the element and translate it. The gradient is rasterised once and slides as a texture, with the parent clipping it.

Stacking is where this goes wrong. z-index: -1 puts the pseudo-element behind its parent’s content but, without a stacking context on the parent, also behind the parent’s background — and potentially behind ancestors’ backgrounds, making it invisible. isolation: isolate (or any other stacking-context trigger) on the parent keeps the pseudo-element inside it, painted above the parent’s background and below its content.

Each animated layer is a compositor layer. That is the trade-off: texture memory proportional to the element’s area, as covered in GPU memory and texture management. For a hundred skeleton rows, promote only the ones in view.

Moving the gradient against moving a layerSame shimmer; one repaints, one composites.Moving the gradient against moving a layeranimated background-positionGradient re-rasterised each frameCost scales with element areaDozens of skeletons saturate the main threadPaint per frametranslate on a ::after layerGradient painted onceLayer slides on the compositorParent clips the overhangComposite per frame
Same shimmer; one repaints, one composites.

Step-by-step resolution

Converting a shimmer and a cross-fadeStacking context, layer, transform or opacity.Converting a shimmer and a cross-fade1Give the element position: relative, isolation: isolate and overflow: clip.A contained stacking context2Add a pseudo-element with inset: 0 and z-index: -1 for a cross-fade layer.Second background painted once3For a sweep, make the pseudo-element wider than the element and offset it.Room to slide through4Animate opacity for cross-fades and translate for sweeps.Compositor-only motion5Promote only elements in the viewport.Bounded texture memory6Remove decorative sweeps under reduced motion.
Stacking context, layer, transform or opacity.

Production code pattern

/* 1. Cross-fade between two gradients. */
.banner {
  position: relative;
  isolation: isolate;                               /* keeps z-index: -1 inside this element */
  background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-500));
}
.banner::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(135deg, var(--color-accent-600), var(--color-primary-600));
  opacity: 0;
  transition: opacity var(--duration-500) var(--ease-standard);
}
.banner[data-variant="warm"]::before { opacity: 1; }

/* 2. Skeleton shimmer: a sliding layer, not a moving background. */
.skeleton {
  position: relative;
  isolation: isolate;
  overflow: clip;
  background: var(--color-surface-2);
}
.skeleton::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: -150%;
  inline-size: 150%;
  background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.35), transparent);
  animation: sweep 1.4s linear infinite;
}
@keyframes sweep { to { translate: 200% 0; } }

/* Only rows in view get the animated layer. */
.skeleton:not(.is-visible)::after { animation: none; content: none; }

@media (prefers-reduced-motion: reduce) {
  .skeleton::after { animation: none; content: none; }   /* static placeholder */
  .banner::before { transition: none; }
}

Rendering Impact: composite. The banner’s second gradient and the shimmer’s highlight are each painted once; afterwards only opacity and translate change. overflow: clip on the skeleton keeps the sweep inside the row without creating a scroll container.

Setting content: none on off-screen skeletons removes the pseudo-element entirely rather than merely stopping its animation, which also releases its layer. An IntersectionObserver that toggles .is-visible keeps the number of live shimmer layers proportional to the viewport, not to the list length.

Main-thread time per frame, 40 skeleton rows shimmeringMid-range phone; each row is 320 by 20 CSS pixels.Main-thread time per frame, 40 skeleton rows shimmeringbudget 16.7 msAnimated background-position12.4 msTranslated pseudo-element layer0.5 msTranslated layer, only visible rows0.2 ms
Mid-range phone; each row is 320 by 20 CSS pixels.

Where the pseudo-element approach does not fit

An element that already uses both ::before and ::after needs a wrapper element instead — the technique is about having a separate box, not specifically about pseudo-elements. Replaced elements such as img, video and form controls do not generate pseudo-elements at all, so a wrapper is required there too. And for backgrounds that must animate shape rather than position or opacity — a gradient whose angle rotates continuously — the layer trick does not help, because the gradient itself changes; use the registered-property technique in animating gradients with registered properties and keep the element small.

Verification checklist

Constraints and trade-offs

  • Each animated layer costs texture memory for the element’s area.
  • Elements using both pseudo-elements need a wrapper.
  • Replaced elements cannot host pseudo-elements.
  • isolation: isolate creates a stacking context, which changes how descendants’ z-index values interact with the rest of the page.
  • A cross-fade between two gradients is not identical to interpolating gradient stops.

Frequently asked questions

Why is my z-index: -1 pseudo-element invisible?

Without a stacking context on the parent, a negative z-index puts it behind the parent’s own background. Add isolation: isolate or another stacking-context trigger to the parent.

How do I animate a shimmer without repainting?

Put the highlight gradient on a pseudo-element wider than the element and animate its translate, with overflow: clip on the parent.

Does this work for background images as well as gradients?

Yes. Any background can be painted on the layer; the animation then moves or fades the layer instead of the background.

What about elements that cannot have pseudo-elements?

Use a wrapper element with the same structure: an absolutely positioned child behind the content.