Layout, Paint and Composite: What Triggers What

Every frame the browser draws goes through a pipeline: recalculate styles, lay out boxes, paint pixels into layers, and composite those layers onto the screen. An animation's cost depends on how early in that pipeline its property forces work to restart. Animate a property that affects geometry and the browser redoes layout, paint and composite sixty times a second. Animate one that only affects how an already-painted layer is placed and blended, and it can skip straight to compositing — often on a separate thread, unaffected by whatever JavaScript is running. Knowing which properties land where turns "why is this janky?" into a short checklist. This page belongs to Performance & GPU Acceleration in the CSS-Only Micro-Interactions & Animations guide.

The rendering pipeline

The stages run in order. Style works out which rules apply and computes values. Layout turns boxes into positions and sizes. Paint records drawing operations — text, backgrounds, borders, shadows — for each layer. Composite takes the painted layers and assembles them, applying transforms and opacity, into the final frame. A change enters the pipeline at the earliest stage it affects and flows through every stage after it.

Where each change enters the pipeline Four boxes in a row: style, layout, paint, composite. Three bars underneath. The first, labelled width, top, margin, font-size, spans from layout to composite. The second, labelled background-color, color, box-shadow, spans paint and composite. The third, labelled transform and opacity, covers only composite. The later a change enters, the cheaper it is Style Layout Paint Composite width, top, margin, padding, font-size background-color, box-shadow transform, opacity most expensive cheapest

Layout triggers

Anything that changes the size or position of a box in the flow triggers layout: width, height, min-* and max-* sizes, padding, margin, border-width, top/right/bottom/left on positioned elements, inset, font-size, line-height, letter-spacing, display, flex-basis, grid-template-columns, gap, and changes to text content. Layout is rarely confined to the element itself; a height change pushes everything below it, and a width change can re-wrap text in every descendant. The cost scales with how much of the page is affected, which is why the same animation can be smooth on a demo page and janky in a real app.

Paint triggers

Properties that change how a box looks without changing its geometry trigger paint but skip layout: color, background-color, background-image and background-position, border-color, border-radius, box-shadow, text-shadow, outline, and in most engines filter and clip-path. Paint cost is proportional to the painted area and complexity. A colour change on a 40-pixel button is trivial; an animated blur shadow on a full-screen panel repaints hundreds of thousands of pixels per frame.

Composite-only properties

transform and the standalone translate, rotate and scale properties, together with opacity, can be applied by the compositor to an existing layer. When an element with one of these animations is on its own compositor layer, the browser runs the animation on the compositor thread. The main thread can be busy parsing JSON or running a framework update, and the animation still advances every frame.

The complete implementation: swapping expensive properties

The demo shows one of the swaps below: the left card animates box-shadow directly, and the right card fades a pre-painted shadow with opacity. They look nearly identical, which is the point — the difference only shows in the work the browser does per frame. The code block adds two more swaps, for a panel height and a positioned toast.

Live demoThe same effects, two pipelines
Hover each card. The left card animates box-shadow (repaint every frame); the right fades a pre-painted shadow with opacity (composite only). They look alike — turn on Paint flashing in DevTools to see the difference.
/* Expensive: layout on every frame. */
.panel-slow {
  transition: height 300ms ease-out;
}
.panel-slow.is-open { height: 12rem; }

/* Cheaper: the box keeps its full size in layout; only its rendering scales.
   Siblings do not move, so this suits overlays and drop-downs, not accordions. */
.panel-fast {
  transform-origin: top;
  scale: 1 0;
  transition: scale 300ms ease-out;
}
.panel-fast.is-open { scale: 1 1; }

/* Expensive: repaints a large blurred shadow every frame. */
.card-slow {
  transition: box-shadow 200ms ease-out;
}
.card-slow:hover { box-shadow: 0 12px 32px rgb(0 0 0 / 0.25); }

/* Cheaper: the shadow is painted once on a pseudo-element; hover fades it in. */
.card-fast { position: relative; }
.card-fast::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 12px 32px rgb(0 0 0 / 0.25);
  opacity: 0;
  transition: opacity 200ms ease-out;
  pointer-events: none;
}
.card-fast:hover::after { opacity: 1; }

/* Expensive: top triggers layout for a positioned element. */
.toast-slow { position: fixed; top: -4rem; transition: top 250ms ease-out; }
.toast-slow.is-shown { top: 1rem; }

/* Cheaper: translate moves the already-painted layer. */
.toast-fast { position: fixed; top: 1rem; translate: 0 -5rem; transition: translate 250ms ease-out; }
.toast-fast.is-shown { translate: 0 0; }

Each swap trades a pipeline stage for a composite-only operation. The height animation becomes a scale; the shadow animation becomes an opacity fade of a pre-painted shadow; the positional slide becomes a translate. Visually they are close. Under load, the second version of each stays smooth while the first stutters.

The key technique: animate the result, not the cause

The pattern behind every swap is to paint the end state once and animate how it is displayed. The shadow exists at full strength from the start — only its opacity changes. The toast is always at its final position in layout — only its translation changes. This does cost something: the pre-painted layer uses memory, and a scaled element's content stretches unless it is counter-scaled. Those costs are paid once, not every frame.

Paint once, composite many times Top row: five frames of a box-shadow transition, each marked with a paint icon, meaning the shadow is repainted five times. Bottom row: one paint when the pseudo-element is first drawn, then five frames marked composite only as its opacity changes. Where the work happens across five frames box-shadow ::after opacity paint paint paint paint paint paint once composite composite composite composite frame 1 to frame 5

Style recalculation counts too

The pipeline diagram starts at style, and style work is easy to overlook. Every animation frame that changes a property must recompute styles for the affected element; animating an inherited value multiplies that. An animated registered custom property on :root — a popular way to drive a site-wide hue shift — forces style recalculation for every element that inherits it, which on a large page is thousands of elements per frame, before layout or paint are even considered. Animate custom properties on the smallest element that needs them, and register them with inherits: false when descendants do not need the value.

The same logic applies to class toggles. Adding a class to body to start a transition makes the browser re-match selectors across the document; adding it to the component that animates keeps the recalculation local. Chromium's Performance panel shows this cost as purple "Recalculate Style" blocks, with the number of affected elements listed in the summary.

When layout animation is still right

Composite-only animation is a strong default, not a rule. Some effects genuinely need layout to change: an accordion whose content pushes the rest of the page down, a list whose items reflow as one is removed. Scaling a panel does not move its siblings, so a scaled accordion leaves a gap or overlaps what follows. In those cases, animate layout but keep it contained: animate a single element whose size change affects as little of the page as possible, keep durations short, and use contain to limit how far the layout work spreads, as described in The contain Property for Animation Performance. Newer tools such as interpolate-size make height-auto animation easier to write, but they still run layout every frame; Animating to height: auto With interpolate-size discusses that trade-off. View transitions offer another route, animating snapshots of a layout change on the compositor.

Verifying with DevTools

Categorising properties by memory is a starting point; measurement is the proof. In Chromium's Performance panel, record while the animation runs. Purple "Layout" and green "Paint" blocks on every frame mean the animation is not composite-only. The Rendering panel's "Paint flashing" option highlights repainted areas in green as they happen — a hover effect that flashes the whole card green on every frame is repainting it. Profiling Animations in DevTools walks through both tools.

Browser support

transform is supported in Chrome 36+, Edge 12+, Firefox 16+ and Safari 9+; the standalone translate, rotate and scale properties in Chrome and Edge 104+, Firefox 72+ and Safari 14.1+. opacity transitions work everywhere. Which properties the compositor can handle directly varies by engine and version, which is why transform and opacity remain the dependable pair across all of them.

FAQ

Which CSS properties are cheapest to animate?transform and its standalone forms translate, rotate and scale, plus opacity. Browsers can animate them on the compositor without recalculating layout or repainting the element, so they stay smooth even when the main thread is busy.

Why is animating width or height slow? Changing a box's size changes layout: the element and potentially its siblings, parent and descendants must be measured and positioned again, then repainted, on every frame. On complex pages that work easily exceeds the frame budget.

Is animating background-color expensive? It skips layout but repaints the element on every frame. For a small button that is cheap; for a large area it can be costly. Crossfading an overlay with opacity is often cheaper for large surfaces.

Do filters and clip-path run on the compositor? Increasingly, yes, in some browsers and in some conditions, but not consistently across engines. Treat them as paint-level properties unless profiling on your target browsers shows otherwise.

Related articles

More pages in the same section.