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.
Step-by-step resolution
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
translateis 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.
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.
100vhon mobile changes as browser chrome shows and hides;100svhis 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.
Related
- Scroll-Driven Animation Patterns — the parent topic
- Named Animation Ranges Explained — contain and the other ranges
- Profiling Scroll-Driven Animations in DevTools — confirming the effect stays off the main thread