Zero-Jank Scroll: Getting Animation Off the Main Thread
Binding animation to a scroll event listener is an engineering failure with a known fix. Scroll-driven animations run on the compositor, off the main thread, in pure CSS — and the syntax is not the one most tutorials still teach.
Zero-Jank Scroll: Getting Animation Off the Main Thread
Start with a correction, because it matters and most published material has it wrong.
@scroll-timeline does not exist. It was an early draft at-rule that never shipped in any browser. It was replaced during standardisation. If you have found it in a tutorial, a Stack Overflow answer, or a code sample, that material predates the spec settling and the code will do nothing.
The shipped API is the animation-timeline property, with the scroll() and view() functions, plus scroll-timeline / view-timeline as named-timeline properties. That is what this article covers.
Why scroll listeners produce jank
The architecture of modern browsers is the thing to understand here, because once you see it the failure is obvious.
Browsers scroll on the compositor thread, separately from the main thread — Chrome’s own documentation puts it plainly: modern browsers scroll on a separate process, and main-thread animations are therefore subject to jank. This is why a page with a blocked main thread — parsing a large bundle, running an expensive layout — still scrolls smoothly. The compositor moves already-painted layers around without asking the main thread anything.
Now add this:
// The failure. Do not ship this.
window.addEventListener('scroll', () => {
const progress = window.scrollY / (document.body.scrollHeight - innerHeight);
bar.style.width = `${progress * 100}%`;
});
You have just moved scroll-linked visual work onto the main thread. Every frame now requires:
- Compositor scrolls, and notifies the main thread.
- Main thread runs your handler.
window.scrollY— a forced synchronous layout. The browser must flush pending layout to answer.- Setting
style.widthinvalidates layout again. - Layout, paint, composite.
At 120Hz you have 8.3ms per frame for all of it, plus whatever else the main thread was doing. Miss the budget and the visual update lags the actual scroll position. The page scrolls smoothly — the compositor never stopped — while your progress bar stutters behind it. That desync is what users perceive as jank, and it is worse than no animation at all.
transform is often prescribed as the fix, and it does help: transform and opacity are compositor-only properties that skip layout and paint. But the handler still runs on the main thread, still fires at scroll frequency, and still competes with everything else. You have reduced the cost per frame without removing the dependency.
The dependency is the bug. Scroll-linked animation should not touch the main thread at all.
Scroll-driven animations: the shipped API
Two timeline types replace the entire pattern.
Scroll progress timeline — tracks a scroll container’s position from start to end. scroll().
View progress timeline — tracks one element’s position as it crosses the viewport. view().
Both run on the compositor. Neither requires a line of JavaScript.
Reading progress bar
The canonical scroll-listener demo, in five declarations:
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: var(--accent);
transform-origin: 0 50%;
animation: grow-progress linear forwards;
animation-timeline: scroll(root block);
}
scroll(root block) says: use the document root’s scroll position on the block axis as the timeline. Animation progress maps directly to scroll progress. No listener, no scrollY read, no layout thrash, no main thread involvement.
scroll() takes a scroller and an axis:
| Value | Meaning |
|---|---|
nearest |
Nearest scrollable ancestor (default) |
root |
The document viewport |
self |
The element itself |
block / inline |
Logical axes (default block) |
x / y |
Physical axes |
Reveal on scroll
The other ubiquitous pattern — fade content in as it enters view — is view():
@keyframes reveal {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
animation-range is where the control lives. entry 0% is the moment the element’s leading edge touches the viewport; cover 30% is 30% of the way through its total viewport traversal. So the card is fully revealed by the time it is comfortably on screen, rather than completing exactly as it exits.
The range keywords:
| Keyword | Range covered |
|---|---|
cover |
First contact with viewport to last contact — the full traversal |
contain |
Period where the element is fully inside the viewport |
entry |
Entering: leading edge in, to fully in |
exit |
Exiting: starts leaving, to fully out |
entry-crossing / exit-crossing |
Crossing a single viewport edge |
Parallax, correctly
Parallax is where scroll listeners do the most damage, because the effect requires updating on every frame.
.hero {
position: relative;
overflow: clip;
block-size: 100svh;
}
.hero__bg {
position: absolute;
inset: -20% 0;
object-fit: cover;
will-change: transform;
animation: parallax linear;
animation-timeline: view();
animation-range: cover;
}
@keyframes parallax {
from { transform: translateY(-10%); }
to { transform: translateY(10%); }
}
Compositor-only. Correct at any scroll velocity, including a trackpad fling or a jump-to-anchor, both of which break naive listener implementations because they produce huge position deltas between events.
Named timelines: driving one element from another
Anonymous timelines only reach ancestors. For an arbitrary relationship, name the timeline:
.article-body {
view-timeline-name: --article;
view-timeline-axis: block;
}
/* Sidebar indicator driven by the article's position — not its own */
.sidebar-indicator {
animation: grow-progress linear;
animation-timeline: --article;
/* The timeline element must be a descendant of the scope root,
or you must set timeline-scope on a common ancestor. */
}
body {
timeline-scope: --article;
}
timeline-scope is the escape hatch for when the driven element is not a descendant of the driver, which is common in real layouts.
Browser support and the honest fallback
As of 25 July 2026, roughly 85% global support per caniuse. Chromium since 115 (July 2023). Safari since Safari 26. Firefox implemented but behind a flag, enabled by default in Nightly.
Check the current figure before relying on this — it is the kind of number that moves.
That number is fine, because the correct approach is progressive enhancement and the degradation is benign — the element renders in its final state, unanimated. Nobody sees a broken page; some people see a page without decoration.
/* Base: final state. Everyone gets this. */
.card { opacity: 1; transform: none; }
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
Do not reach for the polyfill by reflex. It works by running the animation from JavaScript — which reintroduces exactly the main-thread cost this technique exists to eliminate, on precisely the browsers that lack native support. You would be shipping the performance problem to a subset of users in order to show them a decoration. Ship the static fallback.
The one non-negotiable:
@media (prefers-reduced-motion: reduce) {
.card, .hero__bg, .progress-bar {
animation: none !important;
}
}
Scroll-linked parallax is a documented vestibular trigger. WCAG 2.3.3 Animation from Interactions addresses this directly — note that it is Level AAA, not AA, so this is not an AA conformance failure. We include it regardless, because vestibular disorders are real and the mitigation is three lines. Do not tell a client it is an AA requirement; a technical buyer will check.
Where Intersection Observer still belongs
Scroll-driven animations replace scroll-linked animation — where progress maps continuously to scroll position. They do not replace scroll-triggered logic, where crossing a threshold fires an event.
Intersection Observer remains correct for:
- Lazy-loading below-the-fold resources
- Firing analytics when a section becomes visible
- Starting or pausing video on visibility
- Infinite scroll pagination
- Triggering a one-shot animation that then runs on its own clock
// Correct use: a discrete event, not a continuous mapping.
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.dataset.seen = 'true';
io.unobserve(entry.target); // one-shot; stop observing
}
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.1 });
document.querySelectorAll('[data-track-view]').forEach(el => io.observe(el));
IntersectionObserver callbacks run on the main thread, but they fire on threshold crossings — a handful of times per element — rather than on every frame. That is a categorically different cost profile from a scroll handler.
The decision:
| Requirement | Use |
|---|---|
| Progress maps continuously to scroll | animation-timeline |
| Parallax | animation-timeline: view() |
| Reading progress bar | animation-timeline: scroll() |
| Reveal on entry | animation-timeline: view() + animation-range |
| Load something when visible | IntersectionObserver |
| Fire analytics on visibility | IntersectionObserver |
| Sticky header on scroll past threshold | IntersectionObserver on a sentinel |
| Anything at all | Not a scroll event listener |
Verifying it is actually off the main thread
Claiming compositor execution is not the same as achieving it. Verify:
- DevTools → Performance → record while scrolling.
- Look at the Main track. Compositor-driven animations leave it essentially empty during scroll. If you see repeating Layout or Recalculate Style entries at scroll frequency, something is still on the main thread.
- Enable Rendering → Frame Rendering Stats. Watch for dropped frames.
- Throttle CPU to 4× or 6× slowdown. This is where the difference becomes stark: compositor animations stay smooth under CPU pressure because they do not need the CPU.
A useful test: throttle to 6×, then scroll. If your animation is still perfectly smooth while the page is otherwise struggling, it is genuinely on the compositor.
Watch for animating a non-compositable property. animation-timeline on height, top, or margin produces layout on every frame and undoes the entire benefit. Compositor-safe properties are transform, opacity, and filter. Everything else forces main-thread work regardless of the timeline.
Migrating off a scroll library
Most sites arrive here with GSAP ScrollTrigger, Locomotive Scroll, AOS, or a hand-rolled listener. The migration is usually smaller than expected, because the majority of what these libraries do is now native.
Audit first. Every scroll-linked effect falls into one of three buckets:
| Effect | Replacement |
|---|---|
| Fade/slide in on entry | animation-timeline: view() — native |
| Parallax | animation-timeline: view() — native |
| Progress bar | animation-timeline: scroll() — native |
| Sticky element that changes state at a threshold | IntersectionObserver on a sentinel |
| Pinned section with scrubbed timeline | Library — no native equivalent yet |
| Scroll-jacking / smooth-scroll hijack | Delete it |
That last row is not a joke. Libraries that replace native scrolling with a JavaScript-driven virtual scroll — Locomotive Scroll in its default mode, most “smooth scroll” wrappers — move the entire scroll operation onto the main thread. They break keyboard scrolling, break scroll-behavior, break browser find-in-page, and disable the compositor path that makes native scroll smooth in the first place. They are a net negative on every axis except the visual effect they were installed for.
The sticky-header pattern, done without a scroll listener:
<div id="sentinel" aria-hidden="true"></div>
<header class="site-header">…</header>
#sentinel { block-size: 1px; }
.site-header { position: sticky; inset-block-start: 0; }
.site-header[data-stuck] { box-shadow: 0 1px 0 var(--border); }
new IntersectionObserver(
([entry]) => {
document.querySelector('.site-header')
.toggleAttribute('data-stuck', !entry.isIntersecting);
},
{ threshold: 0 }
).observe(document.getElementById('sentinel'));
The observer fires twice across the entire page lifetime — once when the sentinel leaves the viewport, once when it returns. Compare that to a scroll handler running on every frame to compute the same boolean.
When to keep the library. Pinned scroll-scrubbed sequences — where a section locks in place and a multi-step timeline scrubs with scroll — have no native equivalent. animation-trigger is beginning to land in Chromium and covers part of this, but it is early. If you have one hero animation of this kind, load the library dynamically on that route only rather than globally:
if (document.querySelector('[data-pinned-sequence]')) {
const { gsap } = await import('gsap');
const { ScrollTrigger } = await import('gsap/ScrollTrigger');
gsap.registerPlugin(ScrollTrigger);
// …
}
Frequently asked questions
Does @scroll-timeline work?
No. It was a draft at-rule that never shipped and was replaced before standardisation completed. Use the animation-timeline property with scroll() or view().
Do scroll-driven animations run off the main thread?
Yes, provided you animate compositor-friendly properties — transform, opacity, filter. Animating layout-inducing properties like height or top forces main-thread work regardless.
What is the browser support?
Roughly 85% as of mid-2026. Chromium 115+, Safari 26+, Firefox behind a flag. Use @supports (animation-timeline: view()) and ship a static fallback.
Should I use the scroll-timeline polyfill? Generally no. The polyfill drives the animation from JavaScript on the main thread, which reintroduces the exact cost you are trying to avoid — on the browsers least likely to handle it well. Prefer a static fallback.
What is the difference between scroll() and view()?
scroll() tracks a scroll container’s overall position, for things like a page progress bar. view() tracks a single element’s position as it crosses the viewport, for reveals and parallax.
Can I still use Intersection Observer? Yes, for discrete events: lazy loading, analytics, one-shot triggers, sticky sentinels. It is the wrong tool only when you need continuous scroll-linked progress.
How do I respect reduced motion?
@media (prefers-reduced-motion: reduce) { animation: none; }. WCAG 2.3.3 (Animation from Interactions) covers this at Level AAA — so it is not an AA conformance requirement, but it is standard practice and the cost is three lines.
Is will-change: transform needed?
Sometimes, for large elements like full-bleed parallax images, to force layer promotion up front. Use it sparingly — each promoted layer consumes GPU memory, and over-promotion causes its own problems.
Sources
- MDN:
animation-timeline - MDN:
scroll() - Chrome for Developers: Animate elements on scroll — compositor vs main thread, Chrome 115 shipping
- WebKit: A guide to Scroll-driven Animations with just CSS — Safari support
- W3C WCAG 2.2: Understanding 2.3.3 Animation from Interactions (Level AAA)
- caniuse: CSS scroll-timeline — support percentage, checked 25 July 2026
Claims last verified 25 July 2026. Browser support figures are dated because they move. See our editorial standards.