Profiling Scroll-Driven Animations
Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.
The problem
A scroll-driven animation is supposed to run entirely on the compositor thread — that is the whole reason to prefer animation-timeline over a JavaScript scroll listener. But nothing in the CSS guarantees it. One layout-triggering property in the keyframes silently escalates the animation to the main thread, and it will still look correct on a fast desktop while dropping frames on mid-tier mobile. Profiling is how you turn “it looks fine” into “the Main thread is provably idle during scroll”.
Root-cause analysis: what “on the compositor” means in a trace
When a scroll-driven animation is healthy, the browser reads the scroll offset, maps it to progress, and samples transform/opacity keyframes inside the same compositing pass that produces the frame. In a DevTools trace this appears as activity on the Compositor track with the Main thread lane essentially empty during scroll — no scripting, no style, no layout.
When a keyframe touches a non-composited property, the browser cannot resolve it on the compositor. It must run Recalculate Style and Layout on the main thread every frame, then hand the result back for compositing. In the trace this shows as repeating purple Layout and grey/blue Recalculate Style blocks on the Main thread, synchronised with scroll. That is the fingerprint of a fallback, and it is what the workflow below is designed to expose. The fix is always the property-discipline rule from avoiding layout thrashing in CSS animations.
Step-by-step DevTools workflow
- Record a scroll session. Open Chrome DevTools (
F12) → Performance. Click Record, scroll slowly through the animated section for 3–5 seconds, then Stop. Slow, deliberate scrolling makes per-frame work easier to read than a fast flick. - Read the Compositor track. In the trace, find the Compositor thread row. During scroll you should see steady compositing work here. This is where a healthy scroll-driven animation lives.
- Confirm the Main thread is idle. Look at the Main thread lane across the same scroll span. For a compositor-driven animation it should be near-empty — no recurring tasks locked to scroll. Any repeating block synchronised with your scroll motion is a red flag.
- Hunt for Recalculate Style and Layout. Zoom into a scrolled span. If you see repeating Layout (purple) or Recalculate Style blocks tied to each scroll increment, a keyframe is animating a non-composited property. A single
Update Layer Treeon a resize is acceptable; recurring layout per scroll tick is not. - Check the Frames row. The Frames strip at the top shows delivered frames. Green, evenly-spaced frames during scroll confirm you are hitting the budget; red or widely-spaced frames indicate dropped frames from main-thread work.
- Enable Layer borders. Open Rendering (⋮ → More tools → Rendering) and tick Layer borders. The animated element should show a compositor-layer border. If it does not have its own layer and still janks, a targeted
will-change: transformforces promotion — used sparingly, per layer promotion strategy. - Cross-check in the Animations panel. Open More tools → Animations, scroll, and confirm the timeline’s progress bar tracks scroll position. A bar that does not move signals a misbound
animation-timelinerather than a performance issue.
Root-causing jank when it falls back to the main thread
The primary annotated example below is the anti-pattern you are hunting for, paired directly with its compositor-safe fix so the contrast is unambiguous.
/* ANTI-PATTERN — do NOT ship this. Animating height + top forces Layout
on every scroll tick, escalating the animation to the main thread. */
@keyframes grow-bad {
from { height: 40px; top: 20px; } /* layout properties — main-thread */
to { height: 200px; top: 0; }
}
.panel-bad {
position: relative;
animation: grow-bad linear both;
animation-timeline: view();
animation-duration: auto;
}
/* FIX — same visual effect via transform + opacity, sampled on the compositor. */
@keyframes grow-good {
from { opacity: 0; transform: translateY(20px) scaleY(0.2); }
to { opacity: 1; transform: translateY(0) scaleY(1); }
}
.panel-good {
transform-origin: top center;
animation: grow-good linear both;
animation-timeline: view();
animation-duration: auto;
}
/* Accessibility gate: both variants settle to a static, visible state. */
@media (prefers-reduced-motion: reduce) {
.panel-bad,
.panel-good {
animation: none;
animation-timeline: auto;
opacity: 1;
transform: none;
}
}
Rendering Impact: the
grow-badkeyframes triggerlayouton every scroll tick (main-thread); thegrow-goodkeyframes aretransform+opacity— composite only.
After swapping to the grow-good pattern, re-record: the Layout blocks that appeared under grow-bad should vanish from the Main thread, and the Compositor track should carry the animation alone.
Verification checklist
Constraints and trade-offs
- The Compositor track only proves this animation is off-thread; other main-thread work (expensive scroll listeners elsewhere) can still cause jank — profile the whole scroll, not one element.
- A one-off
Update Layer Treewhen the page resizes is normal; only recurring layout per scroll tick indicates a fallback. - Forcing
will-change: transformto guarantee a layer costs GPU texture memory; audit layer count in the Layers panel so promotions stay within budget. - Emulated reduced-motion in DevTools does not always match a real OS setting for scroll timelines; verify on a device with the system preference enabled.
- Fast flick-scrolling can hide per-frame cost; always profile with slow, controlled scrolling to see the true per-tick work.
Related
- Scroll-Driven Animation Patterns — the parent guide to
scroll()andview()timelines - Avoiding Layout Thrashing in CSS Animations — the property audit that fixes most main-thread fallbacks
- Layer Promotion & will-change Strategy — when and how to force compositor-layer promotion