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.

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:

app.js
scrollHorizontal(track, {
  distance:       track.scrollWidth - window.innerWidth,
  triggerElement: '#outer',   // the element whose height is the scroll runway
});

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
triggerElementstring | ElementElement whose height is the scroll runway. Default: the container of the nearest position:sticky ancestor
respectReducedMotionbooleanDefault: false. The scrub advances only as the user scrolls, and holding a final state would hide every panel but one
onProgress(p: number) => voidProgress 0–1 through the horizontal travel