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.
Step-by-step resolution
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
opacityandtranslatechange.overflow: clipon 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.
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: isolatecreates a stacking context, which changes how descendants’z-indexvalues 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.
Related
- Compositor-Only Property Optimization — the parent topic
- Animating Colours Without Repaint — the same idea for flat colours
- Skeleton Loaders and Motion Accessibility — when a shimmer should exist at all