Smooth Web Animations: Complete Performance Guide | Bersus

Before we get to techniques, here’s the constraint everything else follows from: at 60 FPS the browser has about 16.7 ms to produce a frame. On a 120 Hz display – most recent flagship phones, iPads with ProMotion, plenty of laptops and monitors – the budget drops to roughly 8.3 ms. And you don’t get all of it. The browser needs part of that window for its own bookkeeping, so treat something like 8-10 ms at 60 Hz as your realistic ceiling for style, layout, paint, and your own JavaScript combined.

Posted On

September 9, 2026

Time To Read

12 mins

A useful mental model: the browser works in layers, a bit like Photoshop or Figma. It slices the page into composited layers, uploads them to the GPU as textures, and then either shuffles those textures around with cheap GPU math or throws them away and repaints them when their content changes. Almost every animation performance question reduces to one thing – can the browser move an existing texture, or does it have to redraw?

The Rendering Pipeline

When something on the page changes, the browser walks through up to four stages:

Style → Layout → Paint → Composite

The further down the pipeline you can stay, the cheaper the frame. Style recalculation figures out which rules apply and what the computed values are. Layout works out geometry. Paint produces pixels. Composite assembles the result. Skipping the first three is the whole game.

1. Layout (Reflow)

Pure geometry math: where each box sits, how big it is, how it pushes its neighbours around. Change a width high in the tree and the browser recalculates positions for that whole subtree – sometimes for the entire document.

Layout cost scales with the number of affected elements and the depth of the tree. On a big page it’s the most expensive thing you can trigger, and it’s alarmingly easy to trigger it 60 times a second by accident.

Triggers Layout:

  • width, height, margin, padding, border-width
  • top, left, right, bottom on positioned elements
  • font-size, line-height
  • Reading offsetWidth, offsetHeight, scrollTop, getBoundingClientRect(), getComputedStyle()

Those reads force a synchronous layout if anything geometric changed since the last frame. Alternating writes and reads in a loop is layout thrashing, and it’s one of the most common causes of jank in hand-written animation code. Batch all your reads, then all your writes.

2. Paint (Repaint)

The browser rasterizes elements into bitmaps: colours, borders, shadows, text, backgrounds.

Flat fills are cheap. Gradients, large blurs, shadows, filters, and dense text are not. Paint cost scales with the painted area, so a full-viewport element with a soft shadow costs far more than a small card with the same shadow.

Triggers Paint:

  • color, background-color, background-image
  • box-shadow, text-shadow, border-radius
  • outline, visibility

3. Composite

The GPU takes the already-rasterized layer textures and combines them. This is matrix math in hardware, it’s the cheapest stage by a wide margin, and it’s where you want every per-frame animation to live.

Triggers Composite only:

  • transform (translate, scale, rotate, skew)
  • opacity

There’s a bonus here that’s easy to miss. transform and opacity animations declared in CSS or created with the Web Animations API can be handed to the compositor thread entirely. They keep running smoothly even while the main thread is stuck in a long task. The identical animation driven by JavaScript setting style.transform every frame cannot do that – it depends on the main thread by definition, so a 200 ms task will freeze it.

Worth knowing: compositor handoff isn’t guaranteed. Chrome will refuse to composite an animation in various situations – a layer that’s too large for the GPU’s texture limits, certain filter or clip combinations, or a transform animated on an element that also needs main-thread work each frame. Chrome DevTools flags these cases in the Animations panel and in the Performance trace, so check rather than assume.

Compositing Layers

The browser doesn’t repaint the page 60 times a second. It splits the page into compositing layers, rasterizes each one, and uploads it to the GPU. After that, transform: translateX(100px) just relocates a texture. Nothing is repainted.

What promotes an element to its own layer

  • will-change: transform, will-change: opacity, will-change: filter
  • A running CSS animation or transition on transform or opacity
  • <video>, <canvas>, <iframe>
  • position: fixed and position: sticky (usually promoted, not guaranteed)
  • filter, backdrop-filter
  • 3D transforms (translate3d, translateZ)

Promotion is a heuristic, not a contract, and the heuristics differ between Chromium and WebKit and change between releases. Don’t guess – open the Layers panel or turn on “Layer borders” in the Rendering tab and look.

The memory bill

A layer’s texture costs roughly width × height × DPR² × 4 bytes.

  • 1920×1080 CSS px at DPR 1 ≈ 8 MB
  • 1920×1080 at DPR 2 ≈ 33 MB
  • A 390×844 phone viewport at DPR 3 ≈ 12 MB

Real allocations are tiled and depend on the compositor’s internals, so treat these as order-of-magnitude figures. There’s no published VRAM budget either; it depends on the device, the driver, and the browser build, and mobile budgets are dramatically tighter than desktop ones.

The practical point is that every promoted layer holds memory for as long as it exists. A handful of full-screen, high-DPR layers is enough to push a budget Android phone into evicting and re-uploading textures, which you’ll see as stutter and brief blank flashes during scroll.

Shadows

box-shadow is a paint cost, and it grows with blur radius and element area. Painted once on a static element, it’s a non-issue – it’s part of a bitmap that already exists.

Shadows plus transform animations

If the element sits in its own compositing layer, the shadow is baked into the layer texture. Moving the element is then pure compositing.

				
					.card {
    box-shadow: 0 8px 20px rgba(0,0,0,0.15);
    transition: transform0.3s;
}

.card:hover {
    transform: translateY(-10px);
}
				
			

The shadow is rasterized once. On hover, the GPU slides the whole texture, shadow included. No repaint.

Don't animate the shadow itself

				
					/* Bad */
.card {
  transition: box-shadow 0.3s;
}

.card:hover {
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
}

				
			

Blur, spread, and offset change every frame, so the shadow is re-rasterized every frame. On a grid of cards under the cursor this is very visible.

If you want a shadow that appears to grow, cross-fade to a pseudo-element that already has the larger shadow painted:

				
					.card::after {
  content: '';
  position: absolute;
  inset: 0;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
  opacity: 0;
  transition: opacity 0.3s;
  pointer-events: none;
}

.card:hover::after {
  opacity: 1;
}
				
			

Both shadows are painted once; only opacity changes per frame.

Summary

Pros:

  • Static elements – Fine, painted once
  • Alongside a transform animation – Fine, the shadow rides in the layer

Cons:

  • Animating blur, spread, offset, colour – Repaint every frame, avoid
  • Very large elements with very large blur – Expensive even once, avoid

Scroll and Pointer Handlers

The problem

				
					window.addEventListener('scroll', () => {
    element.style.top =window.scrollY +'px';
// disaster
});
				
			

Writing top triggers layout, so you’re relaying out the page on every scroll tick. Read geometry in the same handler and you add a forced synchronous layout on top of that.

One thing worth correcting if you learned this a while ago: modern browsers already align scroll, wheel, and pointer-move dispatch to the frame loop and coalesce the events. You generally get about one event per frame, not hundreds per second. That does not save you here. One layout per frame is more than enough to blow the budget.

The fix: requestAnimationFrame plus transform

				
					let scrollY = 0;
let ticking = false;

window.addEventListener('scroll', () => {
  scrollY = window.scrollY;
  if (!ticking) {
    requestAnimationFrame(updateElements);
    ticking = true;
  }
}, { passive: true });

function updateElements() {
  element.style.transform = `translateY(${scrollY}px)`;
  ticking = false;
}
				
			

What this buys you:

  • The handler only reads scrollY (cheap) and flips a flag.
  • requestAnimationFrame caps you at one DOM write per frame even if events arrive faster.
  • Writes happen immediately before rendering, with no read/write interleaving.
  • transform keeps the work in the compositor.

About { passive: true }

The advice you’ll see everywhere is “always add { passive: true } to scroll, touch, and wheel listeners.” It’s harmless, but in 2025 it’s mostly cargo cult:

  • The scroll event is not cancelable, so passive changes nothing there.
  • touchstart, touchmove, and wheel listeners registered on window, document, or document.body are already passive by default in Chromium, Firefox, and Safari.

Where it still matters is listeners attached to a specific scrollable element – a carousel track, a modal body, a custom slider. Those default to non-passive, and a slow handler there will delay scrolling. Add { passive: true } explicitly, and if you genuinely need preventDefault(), use touch-action: none in CSS to declare intent instead of blocking the compositor.

Prefer scroll-driven animations where you can

If all you need is motion tied to scroll position, CSS scroll-driven animations do it off the main thread:

				
					@supports (animation-timeline: scroll()) {
  .progress {
    animation: grow auto linear;
    animation-timeline: scroll(root);
  }
}
				
			

Support today: Chrome and Edge 115+, Safari 26+. Firefox has it implemented but still behind a preference, so keep a fallback and declare animation-timeline after the animation shorthand, since the shorthand resets it.

Pointer handlers

Same pattern, same reasoning:

				
					let mouseX = 0, mouseY = 0;
let ticking = false;

document.addEventListener('pointermove', (e) => {
  mouseX = e.clientX;
  mouseY = e.clientY;

  if (!ticking) {
    requestAnimationFrame(updateCursor);
    ticking = true;
  }
});

function updateCursor() {
  cursor.style.transform = `translate(${mouseX}px, ${mouseY}px)`;
  ticking = false;
}
				
			

Use pointermove rather than mousemove unless you need legacy support – it covers mouse, touch, and pen, and it’s coalesced per frame. Only reach for pointerrawupdate if you’re building something like a drawing app that genuinely needs sub-frame input resolution.

Video and Memory

Decoded frames don’t sit in memory as RGBA. Hardware decoders typically output YUV 4:2:0, about 1.5 bytes per pixel. So a 1080p frame is roughly 3 MB decoded and a 4K frame roughly 12 MB, and the browser keeps only a handful of frames in flight – never the whole video.

The pipeline:

  1. Decode – compressed frames go through a hardware decoder where one is available.
  2. Upload – the current frame lands in a GPU texture.
  3. Composite – the GPU blends that texture with the rest of the page.
  4. Release – old frames are freed and the decoder moves on.

That’s why a 4K video plays fine on a phone with a modest memory budget. What actually hurts is many videos at once. Each holds a decoder session, a compositing layer, and a continuous stream of decode and upload work. Hardware decoder instances are a limited resource; past the limit the browser falls back to software decoding on the CPU, which is exactly when phones get hot, batteries drain, and frames start dropping.

Do browsers stop off-screen videos automatically?

Mostly no. A playing <video> scrolled out of the viewport generally keeps decoding.

The exceptions:

  • iOS Safari pauses <video autoplay muted playsinline> when it scrolls out of the viewport, and resumes it when it comes back. That’s a documented WebKit autoplay policy, not general behaviour.
  • All browsers throttle or suspend decoding in fully hidden tabs, which is a different thing from an off-screen element in a visible tab.

Don’t rely on either. Manage playback yourself.

Intersection Observer

				
					const videoObserver = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.play().catch(() => {});
    } else {
      entry.target.pause();
    }
  });
}, { threshold: 0.25 });

document.querySelectorAll('video').forEach((video) => {
  videoObserver.observe(video);
});
				
			

The .catch() matters: play() returns a promise that rejects if autoplay is blocked or the element is paused again immediately, and unhandled rejections will spam your console.

Pair this with preload="none" (or "metadata") and lazy src assignment so off-screen videos never download in the first place. On a page with more than two or three videos, this is usually the single biggest win available.

Class Toggles and Cascade Cost

Adding or removing a class triggers style recalculation – the browser works out which rules now apply.

				
					document.body.classList.add('dark-mode');
				
			
				
					body.dark-mode .card {
  background: #1a1a1a;
}

body.dark-mode .button {
  color: white;
}
				
			

Modern engines use invalidation sets, so they don’t blindly recompute every descendant. But a class on <body> that participates in descendant selectors does invalidate broadly, because a large part of the tree genuinely might match. Roughly, cost is the number of affected elements multiplied by the selectors that have to be evaluated against them. DevTools shows you both: “Recalculate Style” in the Performance panel, and Selector Stats for which selectors are actually eating the time.

Fine: theme switching, opening a modal, one-off state changes. Nobody notices 4 ms once.

Not fine: doing it in a scroll handler, a pointer handler, or anything per frame. If you must change state every frame, change it on the smallest element you can get away with, never on html or body. Better still, animate a CSS custom property and let the cascade read it:

				
					.card {
    transform: translateY( calc(var(--scroll-offset,0) * 1px));
}
				
			

Registered custom properties (@property) can even be transitioned and, when they only feed transform or opacity, may stay off the main thread.

:has() and :not()

Short version: :not() is essentially free. :has() scoped to a small component is fine. :has() on html or body can be genuinely bad.

:has() looks downward into subtrees, which inverts the engine’s usual assumptions: a mutation deep in the tree can invalidate a rule matched on an ancestor. Chromium and WebKit both ship dedicated invalidation logic for this and it’s far better than it was in 2022, but the scope of the subject still decides everything.

Good:

				
					.card:has(>.badge) {
    padding-top: 40px;
}
				
			

Small subject, narrow scope, child combinator instead of a full descendant walk.

Bad:

				
					body:has(.modal-open) {
    overflow: hidden;
}

html:has(.something) {
    /* ... */ 
}
				
			

Now every mutation anywhere in the document is a candidate for re-evaluating this rule. Toggle a class from JavaScript instead – it’s one line and it costs nothing.

Also bad: :has() in anything that changes per frame. Scroll handlers, drag interactions, animation loops. Wrong tool.

DOM Size and Nesting

Short version: fifteen wrapper divs deep across 10,000 nodes is a real problem. A flat-ish tree around five levels deep with a couple of thousand nodes is fine.

Style and layout cost scale with node count and tree depth, and every wrapper is one more box to measure. Lighthouse’s DOM size audit warns at roughly 800 elements and errors at 1,400 – not laws of physics, but a reasonable sanity bar.

Guidelines:

  • Keep node count modest, and be stricter on mobile than on desktop.
  • Keep nesting shallow. Delete wrappers whose only job is holding a class.
  • Virtualize long lists rather than rendering 5,000 rows.
  • Use contain or content-visibility to isolate independent blocks.

will-change

will-change is a hint, not a performance mode. It buys you pre-emptive layer promotion in exchange for memory held indefinitely.

Correct usage

Set it right before the animation, drop it right after:

				
					element.style.willChange = 'transform';

const anim = element.animate(
  [
    { transform: 'translateX(0)' },
    { transform: 'translateX(100px)' }
  ],
  {
    duration: 300,
    easing: 'ease-out'
  }
);

anim.finished.then(() => {
  element.style.willChange = 'auto';
});
				
			

Or declare it only in the state that precedes the animation:

				
					.card:hover { 
    will-change: transform;
}
				
			

That works because hover typically precedes the click or transition you care about. It is not a magic bullet – if the promotion happens in the same frame as the animation start, you’ve gained nothing.

Incorrect usage

Permanently on:

				
					.element {
    will-change: transform;
}
				
			

Memory reserved forever for a layer that may never animate.

On many elements at once – this is the one that actually breaks devices. A hundred cards at 500×500 CSS px on a DPR-2 screen is about 4 MB of texture each, so roughly 400 MB permanently allocated. That’s how you ship a buttery animation that crashes phones.

When to skip it entirely

For ordinary CSS transitions on transform and opacity, the browser promotes the element for the duration of the animation on its own:

				
					.button {
    transition: transform 0.3s;
}

.button:hover {
    transform: scale(1.1);
}
				
			

No will-change needed. Reach for it only after you’ve measured a visible hitch on the first frame.

contain and content-visibility

contain tells the browser how far the effects of a change can propagate.

contain: layout

				
					.list-item { 
    contain: layout;
}
				
			

Changes inside the element can’t affect layout outside it, so invalidation stops at the boundary instead of climbing to the root. On a long list, that’s the difference between relaying out one row and relaying out the whole list.

contain: content

Shorthand for layout paint style. A sensible default for independent blocks, because it deliberately leaves out size containment – the element still sizes to its content.

				
					.grid-card { 
    contain: content;
}
				
			

One caveat that bites people: paint containment clips to the padding box. Shadows, tooltips, dropdowns, and focus rings that escape the element will be cut off. If a component has anything that overflows on purpose, use contain: layout alone.

content-visibility: auto

				
					.section {
    content-visibility: auto;
    contain-intrinsic-size: auto 600px;
}
				
			

Off-screen sections skip layout, paint, and rendering of their subtree until they approach the viewport. On long pages this can cut initial render time dramatically.

Always pair it with contain-intrinsic-size, otherwise off-screen sections collapse to zero height and the scrollbar jumps as you scroll. The auto keyword tells the browser to remember the real size once it has measured it and use your value only as the initial guess.

Two things to know about the current state of this feature: skipped content in Chromium is reachable by find-in-page, anchor links, and tab focus (the browser forces it visible), so it’s not the accessibility hazard it was when it first shipped. But content-visibility is animatable now via transition-behavior: allow-discrete, and mixing that with auto on the same element is a good way to confuse yourself. Pick one job per element.

Checklist

  • Animate transform and opacity only, in anything that runs per frame.
  • Prefer CSS or the Web Animations API so animations can live on the compositor.
  • Route JavaScript-driven updates through requestAnimationFrame: reads first, writes second, never interleaved.
  • Add { passive: true } to touch and wheel listeners on specific elements; know that it’s already the default on window and document, and meaningless for scroll.
  • Pause and unload off-screen videos with Intersection Observer, and set preload="none".
  • Never animate width, height, margin, top, left, or box-shadow. Cross-fade a pseudo-element instead.
  • Keep :has() scoped to small components. Never put it on html or body.
  • Use contain: content or content-visibility: auto on independent blocks, and remember the clipping caveat.
  • Add will-change immediately before an animation and remove it immediately after. Never leave it on dozens of elements.
  • Keep the DOM shallow and small, and virtualize long lists.
  • Measure in DevTools (Performance, Layers, Rendering, Animations) on a mid-range Android phone with CPU throttling on – not on your M-series laptop, where everything looks fine.

Lead Developer at Bersus

Alex is a seasoned expert with 9 years of experience in WebGL, WordPress, VR/AR, and ecommerce, turning complex (and sometimes crazy) ideas into seamless, user-friendly solutions with passion and attention to detail.

Back
minimap_23433_09-15-2026_05-19-21.jpeg
Latest Posts
FREE
Join the Loop

Subscribe for exclusive access to my tailored knowledge base, inspiring content, the latest in tech, and practical tutorials that empower your growth.

By submitting, you agree to receive email updates. Unsubscribe anytime.