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.
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.
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.
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.
Related Guides
- Reflow and Repaint Triggers β the parent overview of what forces layout versus paint and how to trace it.
- How to Batch DOM Reads and Writes to Prevent Thrashing β stop a script from forcing reflows mid-task by reordering reads and writes.
- Why transform and opacity are GPU-accelerated β the layer model that makes these two properties composite-only.
- When to use will-change without memory leaks β promote an element to its own layer without leaking GPU memory.
- CSS Containment Strategies β bound an unavoidable reflow so it stops at a container boundary.