Which CSS Properties Trigger Reflow vs Repaint

Animating top, left, width, or margin drops frames because every change re-runs the layout phase for the animated element and its geometric dependents, and layout is the most expensive stage in the rendering pipeline. The symptom is a Performance panel full of long purple Layout bars during a transition that should have been free; the responsible phase is layout (reflow), not paint and not composite.

This guide is part of Reflow and Repaint Triggers, within the broader Layout and Paint Optimization area. The question it answers is narrow and practical: given a CSS property you want to change, which pipeline stages does that change re-run, and therefore how expensive is it per frame? Get that mapping wrong and you pay reflow cost on a property that never needed it; get it right and the same visual effect runs entirely on the compositor thread while your main thread stays idle.

Minimal reproduction

Two boxes slide the same distance. One animates left; the other animates transform. Visually identical, radically different cost.

<div class="track"><div class="box lefty"></div></div>
<div class="track"><div class="box shifty"></div></div>

<style>
.track { position: relative; height: 60px; }
.box   { position: absolute; width: 60px; height: 60px; background: #4456a8; }

/* BAD: left is a layout property β€” each frame re-runs layout for the box
   and reflows every sibling whose position depends on it */
.lefty  { animation: slide-left 1s infinite alternate; }
@keyframes slide-left { to { left: 300px; } } /* <-- triggers reflow every frame */

/* GOOD: transform is composited β€” no layout, no paint of the box contents */
.shifty { animation: slide-x 1s infinite alternate; }
@keyframes slide-x { to { transform: translateX(300px); } }
</style>

Open the Performance panel and record a few seconds. The .lefty animation stacks a Layout event on every frame; the .shifty animation shows none β€” its frames contain only a compositor commit. Same pixels on screen, but one animation competes with your JavaScript for the main thread and the other does not.

How a property change routes through the pipeline

When you mutate a computed style, Blink marks the element dirty and decides how far down the pipeline the change must propagate. Every rendered element carries flags for the stages it needs re-run: NeedsLayout, NeedsPaintInvalidation, and a composited-property bit. Changing a geometry property (anything that alters box size or position β€” width, height, padding, margin, top, font-size, display) sets NeedsLayout, and because layout output feeds paint which feeds composite, all three stages re-run. This is reflow. Changing a paint-only property (color, background, box-shadow, visibility, border-radius) skips layout β€” the box geometry is unchanged β€” but the element’s paint record is regenerated and re-rasterized. Changing a compositor property (transform, opacity, and on modern engines filter) can skip both: if the element is already on its own composited layer, the change is a property update the compositor thread applies directly to the existing layer texture, so Style Calculation and Cascade runs but layout and paint do not.

The critical detail is that layout is not local. Reflowing one element can dirty its ancestors (an auto-sized parent), its descendants (percentage widths), and its later siblings (normal flow position), so a single geometry write can cascade across a large subtree. Paint invalidation, by contrast, is bounded to the changed element’s paint region, and a compositor property update touches nothing but one layer’s transform matrix. That is why the same 300-pixel move costs a subtree reflow as left and a single matrix multiply as transform.

How three classes of property change route through the pipeline Geometry changes enter at layout and re-run all three stages, paint-only changes enter at paint and re-run paint and composite, compositor changes update only the composite stage. width, top, margin geometry change color, box-shadow paint-only change transform, opacity compositor change Layout reflow Paint rasterize Composite GPU paint-only enters at Paint compositor enters at Composite

A property-by-property map

The lane a property lands in is determined by whether its new value can change the box model. Below is the working set most animations and dynamic UIs touch. The cost column is per changed element per frame, and the layout figures assume the change propagates to a modest subtree β€” deep or auto-sized layouts cost much more.

Property Pipeline entry Re-runs Relative cost
width, height, top, left, margin, padding Layout layout β†’ paint β†’ composite highest
font-size, line-height, display, border-width Layout layout β†’ paint β†’ composite highest
color, background-color, box-shadow, outline Paint paint β†’ composite medium
visibility, border-radius, background-image Paint paint β†’ composite medium
transform, opacity Composite composite only lowest
filter (composited) Composite composite only lowest

Two traps hide in this table. First, visibility: hidden is paint-only but display: none is layout β€” the second removes the box from flow and reflows its siblings, so toggling display is never free. Second, a compositor property only stays cheap while the element actually owns a composited layer; if it does not, the browser must paint the change into a shared layer and the β€œfree” transform quietly costs a paint. The transform and opacity best practices guide explains why these two properties earned their dedicated fast path, and when to use will-change without memory leaks covers how to request a layer without leaking GPU memory.

Matrix of common properties against the stages they re-run A grid showing geometry properties fill all three stage columns, paint properties fill paint and composite, and transform and opacity fill only composite. Layout Paint Composite width / margin / top font-size / display color / box-shadow visibility / border-radius transform / opacity reflow paint commit

Reading the cost in a DevTools trace

The Performance panel labels each stage, so you can confirm which lane a change actually took rather than guessing from the property name. The tree below annotates one frame of the left animation from the reproduction. Note the Recalculate Style β†’ Layout β†’ Paint chain inside a single animation frame β€” the presence of Layout is the proof that the property forced reflow.

Frame  (16.6ms budget)
└─ Animation Frame Fired
   β”œβ”€ Recalculate Style         0.4ms   ← style resolves new `left`
   β”œβ”€ Layout                    6.1ms   ← REFLOW: box + siblings re-measured
   β”‚    nodesNeedingLayout: 84          (whole flow subtree dirtied)
   β”œβ”€ Update Layer Tree         0.3ms
   β”œβ”€ Paint                     1.2ms   ← box repainted at new position
   └─ Composite Layers          0.5ms
        total: 8.5ms β€” half the frame budget for a slide

Frame  (the `transform` version, same visual move)
└─ Animation Frame Fired
   └─ Composite Layers          0.4ms   ← no Style, no Layout, no Paint
        total: 0.4ms β€” animation runs off the main thread entirely

If your trace shows Layout where you expected none, the animated property is a geometry property in disguise β€” check for width, height, top/left/bottom/right, margin, or a font-size/line-height change nested inside. A Paint event without Layout means you are on a paint-only property, which is cheaper than reflow but still main-thread work per frame. Only a lone Composite Layers event confirms the compositor fast path. When the Layout bar carries a red corner it is a forced synchronous layout β€” a script read the geometry back mid-task and forced the queue to flush early, which is a related but distinct problem covered in the batch DOM reads and writes guide.

The fix: pick the cheapest lane that produces the effect

Most β€œanimate a layout property” code has a compositor-only equivalent that looks identical on screen. The rule is: express position and size changes as transform, and fades as opacity. Here is a complete before/after for a menu that slides in from the left and a badge that grows on hover.

/* BEFORE β€” both animations drive layout properties */
.menu {
  left: -280px;
  transition: left 200ms ease;           /* left = geometry β†’ reflow per frame */
}
.menu.open { left: 0; }

.badge {
  width: 20px; height: 20px;
  transition: width 150ms, height 150ms;  /* size = geometry β†’ reflow per frame */
}
.badge:hover { width: 28px; height: 28px; }
/* AFTER β€” the same motion expressed on the compositor */
.menu {
  transform: translateX(-280px);
  transition: transform 200ms ease;       /* translateX β†’ composite only, no reflow */
  will-change: transform;                 /* promotes to its own layer ahead of time */
}
.menu.open { transform: translateX(0); }

.badge {
  width: 20px; height: 20px;
  transform: scale(1);
  transition: transform 150ms;            /* scale β†’ composite only, no reflow */
}
.badge:hover { transform: scale(1.4); }   /* 28/20 = 1.4, visually identical */

The browser now handles both differently because transform and opacity are the only properties that can be animated purely by the compositor thread: their new values do not affect box geometry, so no element needs re-measuring, and they do not change the rasterized pixels of the layer, so no repaint is needed β€” the GPU just re-composites an existing texture at a new matrix. The will-change: transform hint tells the engine to promote the element to its own layer before the interaction starts, so the first frame is not stalled by a one-off paint-and-promote. Reserve that hint for elements about to animate and remove it afterward, per the will-change guidance, because every promoted layer consumes GPU memory. Where a genuine size change is unavoidable β€” a container that must reflow its contents β€” isolate the damage with CSS containment so the reflow stops at the container boundary instead of walking the whole tree.

Decision tree for picking the cheapest property lane Starting from the visual effect, the tree routes moving or resizing to transform, fading to opacity, appearance changes to paint properties, and unavoidable geometry to containment. What is changing? move or resize fade in / out colour / shadow true layout change transform composite only opacity composite only paint property paint, no layout contain: layout bound the reflow

Verification checklist

Frequently Asked Questions

Does changing transform ever trigger a reflow?

Changing transform on an element that already has its own composited layer does not trigger layout β€” the compositor applies the new matrix directly. But if the element is not yet on its own layer, the first change forces a one-off paint and layer promotion. That is a paint plus a compositing update, not a full reflow, and it happens once rather than per frame. Adding will-change: transform ahead of the interaction moves that promotion cost out of the animation’s first frame.

Is repaint cheaper than reflow?

Yes. A repaint regenerates the paint record and re-rasterizes only the changed element’s paint region, which is bounded work. A reflow can dirty ancestors, descendants, and later siblings, so its cost scales with how much of the layout tree depends on the changed box. A paint-only property like color is meaningfully cheaper than a geometry property like width, though both are still main-thread work β€” only transform and opacity reach the compositor fast path.

Why does display none cost more than visibility hidden?

visibility: hidden keeps the box in the layout tree and occupying its space, so only paint changes β€” the box just stops drawing. display: none removes the box from the flow entirely, which reflows every sibling that shifts to fill the gap and re-measures the ancestor chain. Toggling display therefore triggers layout in both directions, while toggling visibility triggers only paint.

How do I confirm which lane a property took in DevTools?

Record the interaction in the Performance panel and expand the frame. A Layout event proves the change forced reflow; a Paint event without Layout means a paint-only property; a lone Composite Layers event with no Recalculate Style, Layout, or Paint confirms the compositor fast path. The Rendering tab’s Paint flashing overlay is a faster visual check for whether a repaint is firing each frame.