Skip to content
Learnings

CSS Horizontal Scroll Animation That Runs Off the Main Thread

Build a pinned horizontal scroll section with position: sticky and a CSS view timeline. No scroll listener, and the browser can run it off the main thread.

Artem Semkin
Artem Semkin
October 22, 2026· 15 min read
A pinned horizontal scroll section drawn in violet on a dark circuit background: a scroll progress ruler at 35%, four panels sliding left behind a glowing sticky viewport frame, and the translateX(calc(-100% + 100cqw)) endpoint

The short answer

A CSS horizontal scroll animation needs no scroll listener. Pin a flex track with `position: sticky` inside a taller section. Name that section as a view timeline and animate the track's `transform` across its `contain` range. JavaScript only measures the track's width on resize. In Chrome and Safari 26.4+, the browser then moves the track off the main thread.

Pinned horizontal scrolling is the section that holds still while its panels slide sideways as you scroll down. The common recipes use a scroll listener or GreenSock Animation Platform (GSAP) ScrollTrigger. Both recompute the track's position in JavaScript on every frame while you scroll. That holds up until the main thread gets busy. Then the page keeps scrolling and the track falls behind.

I maintain two horizontal scroll engines. One runs on GSAP ScrollTrigger. The other powers a free, open-source Elementor plugin and uses no animation library at all. This article rebuilds the second one as a plain CSS horizontal scroll animation: a sticky track, a view timeline for progress, and JavaScript that only measures on resize. Every snippet below ran together in one page in Chrome 153 and Firefox 156, and I flag the bugs I hit shipping it.

Why does a scroll-listener horizontal scroll stutter?

A listener-based horizontal scroll runs a loop in your code. On each scroll update it reads the section's position and turns it into progress. Then it writes a new transform to the track. GSAP ScrollTrigger is a more careful version of the same loop. Its documentation says it debounces scroll events and updates on the next requestAnimationFrame. That is still JavaScript on the main thread.

The browser scrolls the page on a separate compositor thread, so scrolling itself stays smooth when the main thread is busy. Your loop gets no such help. Say a long task holds the main thread for 200 milliseconds. The page keeps moving under the user's finger. The track freezes, then jumps to catch up. On a quiet demo page you never see it. On a production page with hydration and a tag manager, you will.

Scroll-driven animations remove the loop instead of optimizing it. You declare the relationship once: this animation's progress equals this element's position in the scroller. The browser advances the animation itself. When the animated property allows it, that happens on the compositor.

So the line that matters runs between JavaScript that defines an animation and JavaScript that drives its frames. A Web Animations API (WAAPI) call attached to a scroll timeline can be composited just like a CSS rule. In the trace test below, both were.

If an AI builder generated your horizontal scroll, search the code for a scroll handler that sets style.transform. That handler is the part to replace.

How do you build a horizontal scroll section in CSS?

Pinned horizontal scrolling has two jobs: hold the section still, and move the track sideways while it is held. position: sticky does the first one with no JavaScript.

The structure is .hs > .hs__track > .hs__panel. The outer section supplies the vertical scroll distance, and the track sticks to the top of the viewport inside it. Here is the horizontal scroll section CSS:

.hs {
  width: 100%;
  container-type: inline-size;
  overflow-x: clip;
  height: calc(100vh + var(--travel, 0px));
}

.hs__track {
  position: sticky;
  top: 0;
  display: flex;
  width: max-content;
  height: 100vh;
}

.hs__panel {
  flex: 0 0 auto;
  width: 100cqw;
}

container-type: inline-size makes 100cqw equal the section's width, so panels size against the section instead of the window. Panels can be any width; mine range from 60 to 100 percent of the section.

overflow-x: clip looks like a detail. It decides whether the pin works at all. overflow: hidden turns an element into a scroll container. A sticky element sticks to its nearest scroll container, so the pin silently stops working. clip cuts off the overflow without creating one. A support thread I handled traced a dead pin to a theme's html, body { overflow-x: hidden }. Either rule alone is harmless, because the browser passes it on to the viewport. Together, the body keeps its own overflow and becomes a scroll container. The pin is gone.

The track is wider than the section by a distance I call travel. It depends on content, so measure it at startup and whenever either box resizes. Never on scroll:

const section = document.querySelector(".hs");
const track = section.querySelector(".hs__track");

function measure() {
  const travel = Math.max(0, track.scrollWidth - section.clientWidth);
  section.style.setProperty("--travel", `${travel}px`);
}

// measure() resizes the section. Doing that inside the observer
// callback fires the "ResizeObserver loop" error, so wait a frame.
const observer = new ResizeObserver(() => requestAnimationFrame(measure));
observer.observe(section);
observer.observe(track);
measure();

That requestAnimationFrame is there because of a bug. Changing the section's height inside the observer callback fires ResizeObserver loop completed with undelivered notifications on window. It reproduced in every engine I tried: Chromium 153, WebKit 26.5 and Firefox 156. Deferring the write by one frame fixed it everywhere. The price is one frame painted with the old height.

You get one pixel of horizontal travel per pixel of vertical scroll. To slow it down, make the section taller without touching the travel. The plugin's scroll length setting uses height: calc(100vh + var(--travel) * var(--factor, 1)). Speed lives in the height; the endpoint never changes.

How do scroll-driven animations map vertical scroll to horizontal movement?

This is the part people search for as horizontal scroll on vertical scroll. Name a timeline on the section and attach the track's animation to it:

.hs {
  view-timeline: --hs block;
  view-timeline-inset: 0px;
}

.hs__track {
  animation: hs-slide linear both;
  animation-timeline: --hs;
  animation-range: contain 0% contain 100%;
}

@keyframes hs-slide {
  to {
    transform: translateX(calc(-100% + 100cqw));
  }
}

The timeline's subject is the section. The sticky track does not move relative to the viewport while pinned, so a timeline on the track would stall.

contain lines the movement up with the pin. For a subject taller than the scrollport, MDN defines contain 0% as the moment its start edge reaches the start of the scrollport, and contain 100% as the moment its end edge reaches the end. With a 100vh track, those two moments are the start and end of the sticky interval. The animation runs during the pin and nowhere else.

Three details in that block come straight from comments in the engine's source:

  • linear is load-bearing. The animation shorthand defaults to ease, which makes the track crawl at both ends of the pin and rush through the middle.
  • Declare the animation shorthand before animation-timeline and animation-range. The shorthand resets both, so putting it second silently detaches the timeline.
  • Set view-timeline-inset: 0px explicitly. The default auto uses the scroller's scroll-padding, and WordPress sets scroll-padding-top to the admin bar height. On a logged-in page, the range shifts by 32 pixels.

The endpoint has no pixel value in it. -100% is the track's own width and 100cqw is the section's, so their sum is always minus the travel. The animation is created once and never rebuilt. Resizing the test page from 1200 to 900 pixels wide moved the end position from -2880 to -2160 pixels on the same animation.

The native path of the open-source horizontal scroll engine on GitHub is this CSS, plus a direction multiplier for right-to-left pages. Clone it and run pnpm test for the unit tests. Its Playwright spec, tests/e2e/specs/01-pin-and-scrub.spec.ts, runs against WordPress in wp-env. It scrolls through the pin and resizes the window. It fails if a ResizeObserver error reaches window. GitHub Actions runs it in Chromium for the native path and Firefox for the polyfilled one.

How do you add more effects to the same timeline?

Once the section has a named timeline, every extra effect is another animation on it. No second listener, and no progress value copied between callbacks. Each effect also gets the same compositor treatment as the track, as long as it animates transform or opacity.

Elements inside the section can use --hs directly, because a named view timeline is visible to the descendants of the element that declares it. This decorative layer drifts 80 pixels while the pin goes from 20 to 60 percent:

.hs__layer {
  animation: drift linear both;
  animation-timeline: --hs;
  animation-range: contain 20% contain 60%;
}

@keyframes drift {
  from { transform: translateX(-40px); }
  to { transform: translateX(40px); }
}

Effects tied to one panel need that panel's own window: the moment it enters at the right edge and the moment it leaves at the left. Both follow from numbers measure() already has. The inputs are the panel's offset x and width w, plus the section width v and the travel d:

start = clamp((x - v) / d, 0, 1)
end   = clamp((x + w) / d, 0, 1)

The clamp matters at both ends. The first panel is already on screen before the pin starts, so its raw start is negative. The last panel's raw end is above 1. Extend measure() to write the clamped values onto each panel as custom properties:

function measure() {
  const travel = Math.max(0, track.scrollWidth - section.clientWidth);
  section.style.setProperty("--travel", `${travel}px`);

  const viewport = section.clientWidth;
  const clamp = (n) => Math.min(1, Math.max(0, n));

  for (const panel of track.children) {
    const start = travel ? clamp((panel.offsetLeft - viewport) / travel) : 0;
    const end = travel ? clamp((panel.offsetLeft + panel.offsetWidth) / travel) : 1;
    panel.style.setProperty("--panel-start", `${(start * 100).toFixed(3)}%`);
    panel.style.setProperty("--panel-end", `${(end * 100).toFixed(3)}%`);
  }
}

Any descendant can then animate over its own panel's visibility:

.hs__panel-media {
  animation: drift linear both;
  animation-timeline: --hs;
  animation-range: contain var(--panel-start) contain var(--panel-end);
}

The plugin hands per-panel ranges to other code the same way, as inherited --arts-hs-panel-start and --arts-hs-panel-end properties written on resize. In my test page, the third panel's range came out as 25% to 100%. Its animation read 0 when the panel's left edge touched the right side of the viewport, and 1 when its right edge left on the other side.

Elements outside the section cannot see a timeline named inside it. A fixed progress bar at the top of the page is the usual case. For those, build the same timeline in JavaScript and hand it to WAAPI:

const progress = document.querySelector(".progress");

if ("ViewTimeline" in window) {
  const timeline = new ViewTimeline({
    subject: section,
    axis: "block",
    inset: "0px",
  });

  progress.animate(
    { transform: ["scaleX(0)", "scaleX(1)"] },
    {
      timeline,
      rangeStart: "contain 0%",
      rangeEnd: "contain 100%",
      easing: "linear",
      fill: "both",
    }
  );
}

The "ViewTimeline" in window check keeps the script from throwing where the API is missing. What those browsers show instead is covered below.

Does this CSS horizontal scroll animation really run off the main thread?

This build still reads layout and writes styles in measure(), but only on load and resize. The per-frame loop is gone. Whether the browser then runs the animation on the compositor depends on the browser and the property.

Chrome and Edge shipped scroll-driven animations in version 115, and Chrome's own case study dates the off-main-thread gains to 116. There, transform, opacity and some filters can run on the compositor. Safari shipped support in 26.0 on the main thread, and 26.4 moved it to the compositor. WebKit's release notes promise animations that "stay smooth even when the main thread is busy". Firefox 156, stable when I tested, has no native support. It is on by default only in Nightly.

I checked the test page with a Chrome 153 performance trace. Chrome emits an Animation trace event for each running animation and attaches a compositeFailed reason when it cannot composite one. All four animations on the page came through clean, the WAAPI progress bar included. As a control, I switched the track's keyframe from transform to margin-left. The same trace then reported compositeFailed with unsupportedProperties: ["margin-left"].

To run the same check by hand, record a profile in the Chrome DevTools Performance panel while scrolling through the section. Then open the Animations track. DevTools puts a red triangle on non-composited animations and lists the reason in the Summary tab. No triangle on the track animation means it runs on the compositor.

I did not measure frame rates under load. Chrome's case study shows that behavior by running a heavy loop every 100 milliseconds next to two progress bars. The scroll listener version turns janky. The scroll-driven one is "completely unaffected by the heavy JavaScript work".

The practical rule: put transform and opacity on the scroll timeline. Anything that changes layout, such as width or margin, sends the work back to the main thread even without a listener.

What happens in browsers without scroll-driven animations?

Treat the horizontal layout as an enhancement over a readable vertical stack. Where the timeline cannot run, a sticky track that never moves is worse than no effect: the first panel pins and the others are unreachable. So unsupported browsers get the stack:

@supports not (animation-range: contain 0% contain 100%) {
  .hs {
    height: auto;
  }

  .hs__track {
    position: static;
    flex-direction: column;
    width: auto;
    height: auto;
  }
}

In Firefox 156, this turned the test page into four stacked panels with no script errors.

Some layouts should stack on purpose. The plugin stacks panels on touch screens by default. A media query can reset a CSS animation, but animation: none does nothing to an animation created with WAAPI. Cancelling it from JavaScript means JavaScript has to know about breakpoints. The engine avoids that by folding a state variable into the endpoint and zeroing it wherever the panels stack:

@keyframes hs-slide {
  to {
    transform: translateX(calc((-100% + 100cqw) * var(--move, 1)));
  }
}

@media (hover: none) and (pointer: coarse) {
  .hs {
    --move: 0;
    height: auto;
  }

  .hs__track {
    position: static;
    flex-direction: column;
    width: auto;
    height: auto;
  }
}

Any animation built from that endpoint, CSS or WAAPI, now resolves to translateX(0) in that state. The trace showed Chrome still composites it with the var() inside.

Firefox and Safari before 26 can get the horizontal layout through a polyfill. The plugin lazy-loads a patched scroll-timeline polyfill (about 19 KB gzipped) when a CSS.supports() check fails. The stock copy aborts its whole setup when one stylesheet trips its parser, and Elementor's inline CSS does exactly that. Its CSS layer also maps contain wrongly on subjects taller than the viewport, which is every pinned section. So the engine builds the polyfilled track through WAAPI with the same endpoint, and stays vertical if loading fails. That path runs on the main thread. It keeps Firefox working. It does not make Firefox fast.

How to build it, step by step

Here is the whole build in order. Steps 1 to 5 produce the effect in Chrome, Edge and Safari 26 or later. Step 6 covers every other browser, and step 7 confirms the result. Each step matches a section above if you need the reasoning.

  1. Step 1: Structure the section. Nest .hs > .hs__track > .hs__panel. The section provides the scroll distance, and the track carries the panels sideways.
  2. Step 2: Pin the track with CSS. Make the track position: sticky and 100vh tall. Give the section overflow-x: clip and container-type: inline-size, because overflow: hidden on any ancestor breaks the pin.
  3. Step 3: Measure the travel on resize. One frame after each ResizeObserver callback, write track.scrollWidth - section.clientWidth to --travel and add it to the section's height. Waiting that frame avoids the ResizeObserver loop error.
  4. Step 4: Name a view timeline on the section. Set view-timeline: --hs block and view-timeline-inset: 0px on the section, not the track. The pinned track never moves relative to the viewport, so a timeline on it would stall. The explicit inset stops scroll-padding from shifting the range.
  5. Step 5: Animate the track across the contain range. Attach a linear animation ending at translateX(calc(-100% + 100cqw)) to --hs with animation-range: contain 0% contain 100%. The relative endpoint stays correct after every resize, so the animation is never rebuilt.
  6. Step 6: Stack the panels where the timeline cannot run. Reset the layout under @supports not and in stacking media queries. Set --move: 0 there too, because WAAPI animations ignore animation: none.
  7. Step 7: Verify compositing. Profile a scroll in DevTools and check the Animations track for red triangles. A red triangle names the property that kept an animation on the main thread.

Key takeaways

  • Pin with position: sticky, and use overflow-x: clip instead of hidden.
  • Measure the travel one frame after a resize, never on scroll.
  • Put the view timeline on the section and animate the track from contain 0% to contain 100%, with linear easing.
  • Animate transform, then check DevTools for red triangles.
  • Stack the panels wherever the timeline cannot run.

Frequently asked questions

Can I build a horizontal scroll section with pure CSS and no JavaScript?

Yes, if the panel widths are known in advance. When every panel is as wide as the viewport, the travel is a fixed multiple of the viewport width. CSS alone can then set the section height. You need JavaScript only when panel widths depend on content. Then it measures the travel on resize and writes it to a custom property.

Why does position: sticky stop working in my horizontal scroll section?

An ancestor became a scroll container. `overflow: hidden`, `auto` or `scroll` on any ancestor makes the sticky track stick to that ancestor instead of the viewport. Use `overflow-x: clip` on the section. Also look for `html, body { overflow-x: hidden }`. That pair turns the body into a scroll container, while either rule alone is safe.

Which browsers support CSS scroll-driven animations?

Chrome and Edge support them from version 115, and Safari from 26.0. Safari runs them on the compositor thread from 26.4. Firefox 156, stable when I tested in September 2026, does not support them yet. They are on by default only in Firefox Nightly. For Firefox, load a polyfill or fall back to a vertical layout.

Is a scroll-driven animation faster than GSAP ScrollTrigger?

On an idle page, both look smooth. The difference shows when the main thread is busy. ScrollTrigger updates on `requestAnimationFrame`, so it waits for the main thread. A scroll-driven `transform` animation can run on the compositor in Chrome and Safari 26.4+, so a long task does not stall it. GSAP still wins for complex sequenced timelines.

Run the Proof

This is not just a description. It is a minimal, public repo that reproduces the bug above and proves the fix with passing tests. Clone it and run it yourself, or open it in the browser.

git clone https://github.com/artkrsk/horizontal-scroll-for-elementor.git
cd horizontal-scroll-for-elementor/src/ts
pnpm install
pnpm test
cssscroll-driven animationsweb performancefrontendanimation
Artem Semkin
Artem Semkin

Senior WordPress Engineer

I develop premium motion-focused themes and plugins for WordPress focusing primarily on Elementor ecosystem. I can either design (in Figma) and build using modern WordPress stack and tools. I'm also fine with supporting legacy/outdated WordPress installations from jQuery era. Currently I have 10 plugins published on WP.org https://profiles.wordpress.org/artemsemkin/ that I maintain.