Animating Colours Without Repaint
Part of Hardware-Accelerated Properties in Core CSS Animation Fundamentals.
The problem
A full-width announcement bar pulses between two brand colours to draw attention. A hero section changes background colour as carousel slides advance. A table highlights a whole row on hover. Each is a background-color transition, and each shows up in a Performance trace as a Paint event on every frame, covering the entire element.
For a button, that is nothing to worry about. For a 1400-pixel-wide band on a high-density phone, or two hundred rows, it is a real share of the frame budget — and it cannot run on the compositor, because the compositor does not recolour pixels.
Root cause analysis: colour is paint, opacity is composite
A colour change alters what the element’s pixels are, so the browser must repaint the display items that use the colour and re-rasterise the tiles they cover. That work is proportional to the painted area, and it happens on the main thread for every frame of the transition.
opacity changes how an existing layer is blended. If the element is on its own layer, the compositor adjusts a single blend factor and no repaint happens.
The substitution follows directly. Paint both colours once — the base colour on the element, the target colour on a pseudo-element stacked on top — and animate the pseudo-element’s opacity from 0 to 1. The visual result is a colour cross-fade; the rendering work is one paint at the start and compositing afterwards.
The two approaches are not visually identical. Interpolating background-color passes through intermediate colours in the chosen colour space; cross-fading two opaque layers blends them linearly by alpha, which for most colour pairs is indistinguishable and for very different hues can look slightly duller at the midpoint. Setting an interpolation colour space such as oklch on the direct transition is only possible with the paint approach.
Step-by-step resolution
Production code pattern
.announcement {
position: relative;
isolation: isolate; /* keep the pseudo-element behind content, above the base */
background-color: var(--color-primary-700);
color: white;
}
.announcement::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-color: var(--color-accent-700); /* the target colour, painted once */
opacity: 0;
transition: opacity 600ms ease-in-out;
}
.announcement.is-highlighted::before {
opacity: 1;
}
/* Attention pulse: the same layer, animated in a loop. */
.announcement.is-pulsing::before {
animation: band-pulse 2.4s ease-in-out infinite alternate;
}
@keyframes band-pulse { to { opacity: 1; } }
/* Small elements: a direct colour transition is fine and can use oklch. */
.button {
transition: background-color 150ms ease-out;
transition-behavior: normal;
}
@media (prefers-reduced-motion: reduce) {
.announcement::before { transition: none; }
.announcement.is-pulsing::before { animation: none; opacity: 1; } /* static highlight */
}
Rendering Impact: composite for the band, paint for the button. The pseudo-element’s colour is rasterised once; after that only its opacity changes. The button’s direct transition repaints a region small enough that converting it would add complexity for no measurable gain.
isolation: isolate is what makes z-index: -1 safe. Without a stacking context on the element, the pseudo-element with a negative z-index would drop behind the element’s own background — and possibly behind ancestors — and the cross-fade would appear to do nothing.
Gradients, text and theme changes
Gradients cannot be interpolated as images at all, so animating between two gradients is either a discrete swap or a registered-property technique that repaints every frame. The cross-fade approach works for gradients unchanged: paint the second gradient on the pseudo-element. For continuously shifting gradients see animating gradients with registered properties, and budget for the paint.
Text colour transitions repaint glyphs, which is among the more expensive display items. Keep them short and avoid transitioning color on long passages. A site-wide theme switch that transitions every element’s colours at once repaints the whole page for the duration; an instant switch, or a view transition that cross-fades snapshots, avoids it.
Verification checklist
Constraints and trade-offs
- The alpha cross-fade cannot use a custom interpolation colour space.
- Each converted element gains a pseudo-element layer while animating, costing texture memory.
- Elements already using both pseudo-elements need a wrapper instead.
- Semi-transparent colours cross-faded over a base do not produce the same result as interpolating the colours.
- Converting many tiny colour transitions adds layers without saving meaningful paint.
Frequently asked questions
Is background-color animation slow?
It repaints the element every frame. For small elements that cost is tiny; for large areas, many elements or high-density screens it becomes significant.
Why use a pseudo-element instead of animating opacity on the element?
Fading the element would fade its content too. A pseudo-element carries only the colour, so the content stays fully opaque while the background cross-fades.
Can the compositor animate colour directly?
Not in general. Colour determines pixel values, which requires paint. Opacity and transform are the properties the compositor can change on an existing texture.
Related
- Hardware-Accelerated Properties — the parent topic
- Replacing box-shadow Animation with Layered Opacity — the same substitution for shadows
- Animating Backgrounds with Pseudo-Element Layers — extending it to images and gradients