How it works
The pattern has three layers:
- A tall outer container — gives the page enough scroll height for the horizontal travel distance
- A sticky inner container — stays fixed in the viewport while the user scrolls through the outer
- A horizontal track — slides left (translateX) proportional to how far through the outer the user has scrolled
You set up the CSS. scrollHorizontal drives the translateX.
Step 1 — CSS setup
.outer {
height: 400vh; /* scroll space = 3 extra viewport heights */
}
.sticky {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.track {
display: flex;
width: max-content; /* as wide as all sections combined */
height: 100%;
}
.section {
width: 100vw;
height: 100vh;
flex-shrink: 0;
}Step 2 — HTML
<div class="outer">
<div class="sticky">
<div class="track" id="track">
<section class="section">Section 1</section>
<section class="section">Section 2</section>
<section class="section">Section 3</section>
<section class="section">Section 4</section>
</div>
</div>
</div>Step 3 — JavaScript
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
const track = document.querySelector('#track');
scrollHorizontal(track, {
// Travel the full width of all sections minus one viewport
distance: track.scrollWidth - window.innerWidth,
easing: 'linear', // linear = scrub feel (matches scroll exactly)
});That's it. The trigger defaults to top top → bottom bottom which maps the full scroll height of the outer container to the full horizontal travel distance.
Note which element that window is measured from: the outer container, not the track. The track can't supply it — the sticky stage pins the track at exactly one viewport tall, so measuring against it would put the start and end of the window at the same scroll position, and nothing would move. scrollHorizontal finds the outer container for you by walking up to the nearest position: sticky ancestor and taking its parent, which is the structure above.
If your layout doesn't use a sticky stage — a custom pinning approach, or a nested scroll container — pass the element that owns the scroll length explicitly:
scrollHorizontal(track, {
distance: track.scrollWidth - window.innerWidth,
triggerElement: '#outer', // the element whose height is the scroll runway
});React version
'use client';
import { useEffect, useRef } from 'react';
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
const SECTIONS = ['Intro', 'Features', 'Pricing', 'Contact'];
export function HorizontalScroll() {
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const inst = scrollHorizontal(track, {
distance: track.scrollWidth - window.innerWidth,
easing: 'linear',
});
// Recalculate on resize
const onResize = () => inst.refresh();
window.addEventListener('resize', onResize);
return () => {
inst.destroy();
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div style={{ height: '400vh' }}>
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden' }}>
<div ref={trackRef} style={{ display: 'flex', width: 'max-content', height: '100%' }}>
{SECTIONS.map(s => (
<section key={s} style={{ width: '100vw', height: '100vh', flexShrink: 0 }}>
<h2>{s}</h2>
</section>
))}
</div>
</div>
</div>
);
}Progress indicators
Use onProgress to drive dot indicators:
const dots = document.querySelectorAll('.dot');
scrollHorizontal('#track', {
distance: 3 * window.innerWidth,
easing: 'linear',
onProgress: (p) => {
// p is 0→1 across all sections
const activeIndex = Math.round(p * (dots.length - 1));
dots.forEach((d, i) => d.classList.toggle('active', i === activeIndex));
},
});Combine with scrollReveal inside sections
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
import { scrollReveal } from 'svg-scroll-draw/reveal';
import { scrollProgress } from 'svg-scroll-draw/progress';
// Drive horizontal movement
scrollHorizontal('#track', { distance: 3 * window.innerWidth });
// Reveal content inside sections as they come into view
scrollReveal('.section-content', {
preset: 'fadeUp',
stagger: 0.1,
});
// Expose progress as CSS variable for background color transitions
scrollProgress('#track', {
variable: '--scroll-p',
easing: 'ease-in-out',
});
// CSS: background: hsl(calc(240 + var(--scroll-p) * 120), 60%, 10%);easing: 'linear' for a true scrub feel. Non-linear easings work too but feel less physically accurate — the horizontal position will lead or lag the scroll position, which can feel jarring.API
| Option | Type | Description |
|---|---|---|
| distance | number | Horizontal travel in px. Default: track.scrollWidth - window.innerWidth |
| easing | EasingName | fn | Easing for horizontal movement. Default: linear |
| trigger | TriggerConfig | Scroll window. Default: top top → bottom bottom |
| scrollContainer | string | Element | Custom scroll container. Default: window |
| triggerElement | string | Element | Element whose height is the scroll runway. Default: the container of the nearest position:sticky ancestor |
| respectReducedMotion | boolean | Default: false. The scrub advances only as the user scrolls, and holding a final state would hide every panel but one |
| onProgress | (p: number) => void | Progress 0–1 through the horizontal travel |
Continue reading