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
styles.css
.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
index.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
app.js
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.
React version
HorizontalScroll.tsx
'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:
app.js
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
app.js
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%);Performance tip: Use
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 |
| onProgress | (p: number) => void | Progress 0–1 through the horizontal travel |
Continue reading