How-ToJune 2026 · 6 min read

Horizontal scroll sections
without GSAP.

Apple does it. Stripe does it. Linear does it. Vertical scroll drives horizontal movement — and it's one of the most striking effects on the web. Here's how to build it in ~10 lines, no GSAP.

How it works

The pattern has three layers:

  1. A tall outer container — gives the page enough scroll height for the horizontal travel distance
  2. A sticky inner container — stays fixed in the viewport while the user scrolls through the outer
  3. 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

OptionTypeDescription
distancenumberHorizontal travel in px. Default: track.scrollWidth - window.innerWidth
easingEasingName | fnEasing for horizontal movement. Default: linear
triggerTriggerConfigScroll window. Default: top top → bottom bottom
scrollContainerstring | ElementCustom scroll container. Default: window
onProgress(p: number) => voidProgress 0–1 through the horizontal travel