
CSS Performance Optimization: Selectors, Repaints, and Reflows
When people talk about CSS performance, they usually mean file size: how many kilobytes the browser has to download before it can paint. That matters, but it's only half the story. Once the page has loaded, every hover effect, animation, scroll handler, and DOM update can force the browser to recalculate styles, rebuild layout, and repaint pixels. Do too much of that work at the wrong time and you get stutter, input lag, and poor Interaction to Next Paint (INP) scores.
This guide explains what the browser does to turn CSS into pixels, which changes are expensive and which are cheap, how much selectors actually matter, and the practical techniques that keep interactions smooth.
How the Browser Renders a Frame
Every time something visually changes, the browser runs some or all of these stages:
- Style: work out which rules apply to which elements and compute final values.
- Layout (also called reflow): calculate the size and position of every affected box.
- Paint: fill in pixels for text, colors, borders, shadows, and images, usually into layers.
- Composite: combine those layers in the right order and send them to the screen.
At 60 frames per second, the browser has about 16 milliseconds per frame for all of this plus your JavaScript. On a 120 Hz display, it's about 8.
The key insight is that the stages cascade. A change that affects layout also requires paint and composite. A change that only affects paint skips layout. A change that only affects compositing skips both. The cheapest updates are the ones that start as late in the pipeline as possible.
What Triggers Each Stage
Layout-Triggering Properties
Anything that can change an element's size or position, or the position of its neighbors, triggers layout:
width,height,min-*,max-*margin,padding,border-widthtop,right,bottom,lefton positioned elementsfont-size,font-family,line-height,font-weightdisplay,position,float- Changing text content or adding and removing elements
Layout is often the most expensive stage because boxes affect each other. Changing the width of one element in a flex row can resize its siblings, which reflows their text, which changes the height of the container, and so on.
Paint-Only Properties
These change appearance without moving anything:
color,background-color,background-imageborder-color,outlinebox-shadow,text-shadowvisibility
Paint cost depends on the area and complexity. A solid background color on a small button is cheap. A large blurred box-shadow repainted every frame is not.
Compositor-Only Properties
Two properties can usually be animated without layout or paint, as long as the element sits on its own compositor layer:
transformopacity
Certain filter values can also be handled by the compositor in some browsers. This is why the classic performance advice is to animate transform and opacity, and to avoid animating top, left, width, or height.
Animate the Right Properties
Here's the same slide-in panel done two ways.
/* Triggers layout on every frame */
.drawer {
position: fixed;
top: 0;
left: -320px;
width: 320px;
height: 100vh;
transition: left 300ms ease;
}
.drawer.is-open {
left: 0;
}
/* Compositor-friendly */
.drawer {
position: fixed;
inset-block: 0;
left: 0;
width: 320px;
transform: translateX(-100%);
transition: transform 300ms ease;
}
.drawer.is-open {
transform: translateX(0);
}
Visually they're identical. The second version lets the browser move a pre-painted layer instead of recalculating layout and repainting the page on every frame.
The same idea applies to expanding cards and hover effects. Instead of animating width and height, scale the element. Instead of animating a box-shadow directly, put the shadow on a pseudo-element and fade its opacity:
.card {
position: relative;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
transition: transform 200ms ease;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 32px rgb(0 0 0 / 0.18);
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
.card:hover {
transform: translateY(-4px);
}
.card:hover::after {
opacity: 1;
}
The large shadow is painted once. On hover, only opacity and transform change.
Avoid Layout Thrashing in JavaScript
Most reflow problems aren't caused by CSS alone. They're caused by JavaScript that reads layout information right after writing styles, which forces the browser to calculate layout immediately instead of waiting until the end of the frame. This is called a forced synchronous layout. When it happens repeatedly in a loop, it's called layout thrashing.
// Thrashing: read, write, read, write...
const cards = document.querySelectorAll(".card");
cards.forEach((card) => {
const width = card.offsetWidth; // read: forces layout
card.style.height = `${width * 0.75}px`; // write: invalidates layout
});
Each iteration reads offsetWidth after the previous iteration changed a height, so the browser has to redo layout every time. With 200 cards, that's 200 layouts in one frame.
Batch reads first, then writes:
const cards = [...document.querySelectorAll(".card")];
// Read phase
const widths = cards.map((card) => card.offsetWidth);
// Write phase
cards.forEach((card, i) => {
card.style.height = `${widths[i] * 0.75}px`;
});
Now the browser calculates layout once. Better still, in this case you don't need JavaScript at all:
.card {
aspect-ratio: 4 / 3;
}
Properties and methods that force layout when read include offsetWidth, offsetHeight, offsetTop, clientWidth, scrollTop, scrollHeight, getBoundingClientRect(), and getComputedStyle(). Reading them is fine; reading them right after a style change, in a loop, is the problem.
Schedule Visual Work with requestAnimationFrame
When you respond to scroll or pointer events, don't update styles directly in the handler. Coalesce the work into the next frame:
let ticking = false;
let latestY = 0;
window.addEventListener(
"scroll",
() => {
latestY = window.scrollY;
if (!ticking) {
requestAnimationFrame(() => {
document.documentElement.style.setProperty("--scroll-y", latestY);
ticking = false;
});
ticking = true;
}
},
{ passive: true },
);
Where possible, use CSS scroll-driven animations or IntersectionObserver instead of scroll handlers. Both let the browser do the work more efficiently.
Do Selectors Actually Matter?
Old performance guides spent a lot of time on selector efficiency: avoid the universal selector, avoid descendant selectors, never qualify classes with tags. Browsers have become much faster at matching since then, and for most sites selector choice is a minor factor compared with layout and paint.
That said, selectors still matter in two situations: very large DOMs and frequent style invalidation.
How Matching Works
Browsers match selectors from right to left. For .sidebar ul li a, the engine first finds every a element, then walks up the tree checking for an li, a ul, and a .sidebar ancestor. The rightmost part, called the key selector, determines how many elements are candidates in the first place.
/* Key selector is "a": every link on the page is a candidate */
.sidebar ul li a {
color: #334155;
}
/* Key selector is a class: only matching elements are candidates */
.sidebar-link {
color: #334155;
}
The second rule is simpler to match and easier to maintain. You don't need to rewrite every selector on your site, but prefer flat, class-based selectors for things that appear many times.
Expensive Patterns to Use Carefully
- Broad
:has()selectors.:has()is enormously useful, but a rule likebody:has(.is-invalid)means changes anywhere in the body might require re-checking. Anchor:has()to a specific container, like.form-field:has(input:invalid), so the browser has less to re-evaluate. - Complex
:nth-child()and sibling combinators on long lists. Inserting an element at the top of a list can invalidate styles for every sibling after it. - Universal selectors in the key position.
.grid *makes every descendant a candidate. - Attribute selectors with substring matching such as
[class*="icon-"]on large pages.
Measure Selector Cost
Chromium-based browsers' DevTools can show selector matching statistics in the Performance panel. Enable the selector stats option in the panel settings, record an interaction, and select a Recalculate Style event to see which selectors took the most time and how many elements they were tested against. Look here before optimizing selectors; guessing is rarely accurate.
Reduce the Scope of Style and Layout Work
Sometimes the change itself is unavoidable, but you can limit how much of the page it affects.
Keep the DOM Small
Style and layout costs scale with the number of elements involved. A page with thousands of nodes, deeply nested wrappers, and hidden duplicate content for different breakpoints does more work on every update. Remove unnecessary wrapper div elements and render long lists with virtualization or pagination.
Toggle Classes Close to the Change
Adding a class to body or html invalidates styles for the entire document. If a state only affects one component, set the class or data attribute on that component:
// Invalidates everything
document.body.classList.add("menu-open");
// Invalidates only the menu subtree
menu.dataset.state = "open";
Use CSS Containment
The contain property tells the browser that an element's internals don't affect the rest of the page, so it can skip work outside that boundary.
.comment {
contain: layout paint;
}
.widget-sidebar {
contain: content; /* shorthand for layout paint style */
}
With layout containment, changes inside a .comment don't force layout for the rest of the page. With paint containment, its contents can't draw outside its box, so the browser can skip painting it when it's offscreen. Containment is widely supported, but be aware of the side effects: paint clips overflowing content such as dropdowns, and containment creates a new containing block and stacking context.
For long pages, content-visibility: auto builds on containment by skipping rendering work for offscreen sections entirely.
Keep Paint Cheap
- Limit large blurs.
filter: blur(),backdrop-filter, and bigbox-shadowspreads are costly to paint, especially over large areas or when animated. - Avoid animating gradients directly. Repainting a full-screen gradient every frame is expensive. Animate
transformoropacityon a layer that contains the gradient instead. - Be careful with
position: fixedbackgrounds combined with scrolling content, which can cause repaints on scroll in some browsers. - Promote only what animates. Elements animating
transformoropacityare usually promoted to their own layer automatically. Forcing extra layers withwill-changeon many elements costs memory and can slow things down.
Finding the Real Bottleneck
Guessing wastes time. Use DevTools to see where the frame budget goes.
- Open the Performance panel and turn on CPU throttling (4x or 6x) to simulate a mid-range phone.
- Record while performing the slow interaction.
- Look for long purple bars (Recalculate Style and Layout) and green bars (Paint).
- Click a layout event. If DevTools shows a warning about forced reflow, it will link to the JavaScript that caused it.
- Enable Paint flashing in the Rendering panel to see which areas repaint as you interact.
- Use the Layers panel to check how many compositor layers exist and why each was created.
Fix the biggest offender first, then record again.
Quick Reference
| Change | Layout | Paint | Composite |
|---|---|---|---|
width, margin, top, font-size | Yes | Yes | Yes |
color, background-color, box-shadow | No | Yes | Yes |
transform, opacity (on its own layer) | No | No | Yes |
Conclusion
CSS performance after load comes down to how much work each change asks the browser to do. Changes that affect geometry trigger layout, paint, and composite. Visual-only changes skip layout. transform and opacity can often skip straight to compositing. Animate the cheap properties, batch DOM reads and writes to avoid layout thrashing, and scope state changes and containment so updates stay local.
Selectors matter less than they used to, but broad :has() rules, universal key selectors, and huge DOMs still add up. Profile with the Performance panel, find the actual bottleneck, and fix that. Your users will feel the difference every time they scroll, tap, or type.


