Horizontal Scroll Sections with Scroll Timelines

Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.

The problem

A product story page has a section where vertical scrolling moves a row of panels sideways: the page appears to pause while the gallery slides horizontally, then resumes scrolling down. The existing implementation listens to wheel and scroll events, prevents default scrolling, and translates the row from JavaScript. It stutters on trackpads, fights momentum scrolling on phones, breaks keyboard scrolling and Page Down, and confuses screen readers because the document position no longer matches what is visible.

The effect itself is reasonable. The implementation — hijacking scroll — is the problem.

Root cause analysis: a runway, a sticky viewport and a timeline

The pattern does not need to intercept scrolling at all. It needs three pieces of layout and one scroll-driven animation.

The runway. An outer section is made tall — its height is the viewport height plus however far the gallery needs to travel horizontally. Normal vertical scrolling through that height is the “pause” in which the gallery moves.

The sticky viewport. An inner wrapper with position: sticky; top: 0; height: 100vh stays pinned to the screen while the runway scrolls past. To the user, the page appears to stop.

The timeline. The horizontal track is animated with animation-timeline: view() measured against the runway. The contain range covers exactly the scroll distance during which the runway fully covers the viewport, which is the time the sticky wrapper is pinned. Mapping 0% to 100% of that range onto translate from 0 to the track’s overflow width makes the gallery reach its end exactly as the runway ends.

Why it is smooth. The document scrolls natively — momentum, keyboard, scrollbars and assistive technology all work — and the animation is a scroll-driven translate, which the browser updates with scrolling rather than from script. This is the same machinery as the scroll progress bar, applied to a subject element.

Why the subject must be the runway. A view() timeline uses the animated element as its subject by default. The track itself is inside a sticky wrapper and barely moves relative to the viewport, so its own view timeline would hardly progress. Naming a view timeline on the runway and referencing it from the track, via view-timeline-name and timeline-scope when needed, measures the right element.

The three layout layers of a horizontal scroll sectionNative vertical scrolling moves the runway; the timeline maps it onto the track.The three layout layers of a horizontal scroll sectionTrack (animated translate)Moves sideways by its overflow widthSticky viewport (100vh)Pinned while the runway passesRunway section (tall)Named view timeline subjectDocument scrollNative, unmodified
Native vertical scrolling moves the runway; the timeline maps it onto the track.

Step-by-step resolution

Building the sectionLayout first, then one animation.Building the section1Set the runway height to 100vh plus the track's overflow width, via a custom property.Enough scroll distance for the full slide2Make the inner wrapper sticky, full height and overflow clip.The visible stage stays put3Declare view-timeline-name: --runway on the runway section.A timeline measuring the right subject4Animate the track's translate with animation-timeline: --runway and animation-range: contain.Movement spans the pinned period exactly5Compute the overflow width once on load and resize, and write it to a custom property.Track ends flush with the viewport6Under reduced motion, collapse the runway and make the track a native horizontal scroller.
Layout first, then one animation.

Production code pattern

<section class="hscroll" style="--overflow: 1800">
  <div class="hscroll__stage">
    <ol class="hscroll__track">
      <li class="panel">…</li> <li class="panel">…</li> <li class="panel">…</li> <li class="panel">…</li>
    </ol>
  </div>
</section>
.hscroll {
  view-timeline-name: --runway;
  block-size: calc(100vh + var(--overflow) * 1px);      /* vertical distance = horizontal travel */
}

.hscroll__stage {
  position: sticky;
  top: 0;
  block-size: 100vh;
  overflow: clip;
  display: flex;
  align-items: center;
}

.hscroll__track {
  display: flex;
  gap: 2rem;
  padding-inline: 5vw;
  animation: slide-track linear both;
  animation-timeline: --runway;
  animation-range: contain;                               /* only while the runway covers the viewport */
}

@keyframes slide-track {
  to { translate: calc(var(--overflow) * -1px) 0; }
}

@media (prefers-reduced-motion: reduce) {
  .hscroll { block-size: auto; }
  .hscroll__stage { position: static; block-size: auto; overflow-x: auto; scroll-snap-type: x mandatory; }
  .hscroll__track { animation: none; }
  .panel { scroll-snap-align: start; }
}
// Measure the track's overflow once, and again when the layout changes.
const section = document.querySelector('.hscroll');
const track = section.querySelector('.hscroll__track');
const update = () => {
  const overflow = Math.max(0, track.scrollWidth - section.clientWidth);
  section.style.setProperty('--overflow', String(Math.round(overflow)));
};
new ResizeObserver(update).observe(track);
update();

Rendering Impact: composite. The track’s translate is driven by the runway’s view timeline and updates with scrolling; no scroll handler runs. The runway’s height is layout, computed once per resize.

Setting the runway height to exactly the overflow width makes the horizontal movement track vertical scrolling one to one, which feels most natural on a trackpad. A multiplier greater than 1 slows the gallery relative to scrolling; less than 1 speeds it up. Stay close to 1 — large mismatches make users feel the page is ignoring their input.

Scroll hijacking against a scroll-driven runwaySame visual effect; opposite relationship with native scrolling.Scroll hijacking against a scroll-driven runwaywheel listener + preventDefaultBlocks native scrolling and momentumKeyboard and Page Down misbehaveScript updates translate per eventFragile and inaccessibleRunway + sticky + view() timelineDocument scrolls nativelyKeyboard, momentum and scrollbars workBrowser maps scroll to translateSmooth and robust
Same visual effect; opposite relationship with native scrolling.

Accessibility of horizontal scroll sections

Even without hijacking, this pattern moves a large area sideways in response to vertical input, which is the kind of scroll-coupled motion that can bother users with vestibular sensitivity; the concerns are covered in scroll-jacking and vestibular risk. The reduced-motion branch above turns the section into an ordinary horizontal scroller with snap points, which keeps all content reachable with a swipe, a trackpad gesture or Shift plus the wheel. Keyboard users also need a path: panels should contain focusable elements, or the stage should be focusable, so that tabbing to a panel scrolls it into view — which works in both modes because the document position is real.

Verification checklist

Constraints and trade-offs

  • Browsers without scroll-driven animations show a static track; provide the native horizontal scroller as the default and enhance with @supports (animation-timeline: view()).
  • The runway adds a lot of vertical scroll height; long galleries make long pages.
  • 100vh on mobile changes as browser chrome shows and hides; 100svh is steadier.
  • Nested scrollers inside panels can capture wheel input and interrupt the effect.
  • Deep links to a panel need the vertical scroll offset that corresponds to it.

Frequently asked questions

How do I make vertical scrolling move content horizontally without JavaScript scroll handlers?

Use a tall section with a sticky inner viewport and animate the inner track’s translate with a view timeline on the tall section, limited to the contain range.

Why use animation-range: contain?

contain covers the scroll distance during which the tall section fully covers the viewport, which is exactly when the sticky viewport is pinned.

Why doesn’t view() on the track itself work?

The track sits in a sticky container and hardly moves relative to the viewport, so its own view timeline barely progresses. Measure the tall outer section instead.

Is this pattern accessible?

It keeps native scrolling, which helps, but large sideways motion can still bother some users. Provide a reduced-motion native horizontal scroller and keyboard-reachable panels.