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.

Two ways to animate a full-width band's colourSame start and end colours; the work moves from paint to composite.Two ways to animate a full-width band's colourbackground-color transitionRepaints the whole band each frameCost scales with area x DPR squaredCan interpolate in oklchPaint every framePseudo-element opacity cross-fadeTarget colour painted onceOpacity sampled on the compositorLinear alpha blend of two coloursComposite only
Same start and end colours; the work moves from paint to composite.

Step-by-step resolution

Converting a large colour animationOnly do this where measurement shows the paint matters.Converting a large colour animation1Record the animation with Paint flashing enabled.Shows the repainted area per frame2If the area is small or the transition is a one-off hover, stop here.Not every colour change needs converting3Give the element position: relative and isolation: isolate.A stacking context for the layer4Paint the target colour on ::before with inset: 0 and z-index: -1.Sits behind content, above the base colour5Transition the pseudo-element's opacity.Colour cross-fade on the compositor6Promote the pseudo-element only during the change.
Only do this where measurement shows the paint matters.

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.

Main-thread paint per frame during a colour transitionMid-range phone at DPR 2.75.Main-thread paint per frame during a colour transitionbudget 16.7 msButton, background-color0.2 msFull-width band, background-color6.8 ms200 table rows, background-color13.1 msFull-width band, opacity cross-fade0.1 ms
Mid-range phone at DPR 2.75.

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.