WSS
BlogPerformancePublished

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:

  1. Compositor scrolls, and notifies the main thread.
  2. Main thread runs your handler.
  3. window.scrollY — a forced synchronous layout. The browser must flush pending layout to answer.
  4. Setting style.width invalidates layout again.
  5. 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:

  1. DevTools → Performance → record while scrolling.
  2. 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.
  3. Enable Rendering → Frame Rendering Stats. Watch for dropped frames.
  4. 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

Claims last verified 25 July 2026. Browser support figures are dated because they move. See our editorial standards.

Related posts