Scroll Horizontal Story Track — Free GSAP ScrollTrigger Sideways Scrollytelling

Scroll Horizontal Story Track · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Vertical-input, horizontal-output scroll
No custom wheel handling — normal scroll drives the horizontal tween.
Resize-safe distance
invalidateOnRefresh recomputes the scroll distance whenever the layout changes.
Single source of truth for progress
Bar and dots both read the same onUpdate progress value.
Clickable chapter navigation
Dots jump to the exact live-computed scroll position for that chapter.
Automatically scales to chapter count
No hardcoded slide count anywhere in the distance or dot logic.
Smooth scrubbed motion
scrub: 0.5 adds a slight easing lag instead of a rigid 1:1 scroll-lock.
Fully pinned, no layout shift
The track pins in place; only its internal transform moves.
Zero external images
Chapter backgrounds are CSS gradients, swappable for real photography.

About this UI Snippet

Scroll Horizontal Story Track — A Story That Moves Sideways as You Scroll Down

Screenshot of the Scroll Horizontal Story Track snippet rendered live

Vertical scroll is the default, but some narratives — a product's journey, a process pipeline, a timeline of scenes — read more naturally as a left-to-right sequence. This snippet uses the standard GSAP "horizontal scroll section" recipe to translate a wide flex row sideways in exact lockstep with normal, familiar vertical scrolling, so visitors never have to learn a new input gesture.

Vertical input, horizontal output — one scrubbed tween

The five .hst-slide chapters sit in a flex row (.hst-track) five viewport-widths wide. A single gsap.to() tween animates the track's x from 0 to -getScrollDistance() (the track's overflow width beyond the viewport), with its scrollTrigger pinning #hstPin and scrubbing at 0.5. Because scrub ties the tween's progress directly to scroll position, the visitor's ordinary mouse wheel or trackpad scroll — still vertical — drives a horizontal slide, with no custom wheel-event hijacking or gesture remapping required.

`invalidateOnRefresh` keeps distances honest on resize

Both the tween's x target and the trigger's end are functions (() => -getScrollDistance(), () => '+=' + getScrollDistance()) rather than fixed numbers, and invalidateOnRefresh: true tells ScrollTrigger to re-run those functions whenever it recalculates — so resizing the window (which changes track.scrollWidth and window.innerWidth) never leaves the pinned scroll distance out of sync with how far the track actually needs to travel.

Dots and a fill bar both read off the same progress value

The onUpdate callback receives the tween's own ScrollTrigger progress once per scroll frame and uses it for two things: setting the fill bar's width directly, and rounding progress * (slides.length - 1) to decide which chapter dot should be marked active. Both indicators are therefore always perfectly in sync with the actual horizontal position of the track — there's no separate, potentially-drifting tracking logic for the dots versus the bar.

Dots are clickable, and compute their own jump target

Each dot's click handler reads the tween's live scrollTrigger.start/end values (not hardcoded pixel offsets) to compute exactly which vertical scroll position corresponds to that chapter's slice of the pinned range, then animates window scroll to it with GSAP's scrollTo (bundled in GSAP core as of v3). Because the jump target is derived from the same live trigger the main tween uses, it stays correct even after a resize changes the total distance.

Customizing it

Add a sixth .hst-slide and the horizontal distance, progress bar, and dot count all adapt automatically — nothing is hardcoded to five chapters. Swap the CSS gradients for real photography using background-image, or combine with a scroll company timeline for a vertical timeline that transitions into this horizontal chapter walk.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain why the tween's x target and the ScrollTrigger's end are both written as functions combined with invalidateOnRefresh, and how that combination keeps the horizontal travel distance correct after a window resize instead of drifting out of sync. The same assistant is useful for extending the pattern — ask it to add per-chapter parallax layers that move at a different horizontal speed than the main track, support touch-swipe as an alternative to vertical scroll on mobile, or add keyboard arrow-key navigation between chapters using the same live scrollTrigger start/end values the dots already use. Treat the code as a working base for your own sideways scrollytelling section.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "scroll horizontal story track" in plain HTML, CSS, and JavaScript using GSAP with its ScrollTrigger plugin, loaded from a CDN with no bundler.

Requirements:
- A pinned full-viewport section containing a flex row of several full-viewport-width chapter slides (each with a chapter number, heading, and short paragraph, styled with a distinct background gradient per slide), preceded by an intro section and followed by an outro section.
- Animate the flex row's horizontal x transform from 0 to the negative of its total overflow width (its scrollWidth minus the viewport width) using a single GSAP tween whose target value and whose ScrollTrigger's end distance are both computed via functions (not fixed numbers), combined with invalidateOnRefresh set to true, so both values are correctly recalculated any time ScrollTrigger recalculates positions, such as after a window resize.
- Use scrub (a fractional value for a slight easing lag) on the tween's ScrollTrigger so that ordinary vertical scrolling — with no custom wheel event handling, and no remapping of the scroll gesture — drives the horizontal position in direct proportion to scroll progress, while the section itself stays pinned via pin: true for the full horizontal travel distance.
- Drive both a linear progress bar and a row of small chapter-indicator dots from the exact same onUpdate callback on the same tween's ScrollTrigger, so the two indicators can never fall out of sync with each other or with the actual horizontal position.
- Make each chapter dot clickable, computing its target vertical scroll position at click time from the tween's own live ScrollTrigger start and end values (not a value cached once at page load) and animating the window's scroll to that position with GSAP's scrollTo utility.
- Ensure the whole setup scales automatically to any number of chapter slides with no hardcoded slide count anywhere in the distance, progress-bar, or dot-generation logic.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Add GSAP, ScrollTrigger, and ScrollToPluginLoad all three from the CDN and call gsap.registerPlugin(ScrollTrigger, ScrollToPlugin).
  2. 2
    Paste the HTML, CSS, and JSChapters are laid out as a wide flex row inside the pinned section.
  3. 3
    Scroll down normallyThe vertical scroll gesture drives a horizontal slide through the chapters.
  4. 4
    Watch the bar and dotsBoth track exact horizontal progress, computed from the same tween.
  5. 5
    Click a dotJumps directly to that chapter's scroll position, computed live from the trigger.
  6. 6
    Add more chaptersDuplicate an .hst-slide article — distance and dot count adapt automatically.

Real-world uses

Common Use Cases

Product manufacturing journey pages
Walk visitors from concept sketch to retail shelf, chapter by chapter.
Process or pipeline explainers
Show a multi-stage workflow as a left-to-right sequence instead of a vertical list.
Portfolio project case studies
Present a project's phases (research, design, build, launch) horizontally.
Editorial "day in the life" features
Move through hours or scenes as a sideways-scrolling narrative.
Museum or exhibit microsites
Recreate a gallery-walk feel entirely in the browser.
Onboarding tours with a distinct visual identity
Differentiate a horizontal walkthrough from standard vertical scroll pages.

Got questions?

Frequently Asked Questions

The section is pinned in place with ScrollTrigger's pin: true, so as the visitor keeps scrolling vertically without the page actually moving, that pinned scroll distance is fed into a single gsap.to() tween's scrub option, which maps 0-100% scroll progress onto the track's horizontal x position. The visitor never needs to scroll sideways at all — their ordinary vertical scroll gesture is simply reinterpreted as the input for a horizontal animation.

Writing them as () => -getScrollDistance() and () => '+=' + getScrollDistance() means ScrollTrigger re-evaluates the actual pixel distance every time it recalculates, rather than baking in a number computed once at page load. Combined with invalidateOnRefresh: true, this keeps the horizontal travel distance and the pinned scroll length correctly matched even after a window resize changes how wide the track's slides collectively are.

Both are updated from inside the exact same onUpdate callback on the exact same tween's ScrollTrigger, using the single progress value that callback receives each frame. The bar sets its width directly from that value, and the dots derive an active index by rounding progress times one less than the slide count — since there is only one source of truth for progress, the two indicators can never visually drift apart from each other.

Each dot's click handler reads the live start and end values off the tween's own scrollTrigger object at the moment of the click, computes that chapter's target progress as its index divided by one less than the total slide count, and interpolates between start and end to get an actual vertical scroll position — then animates window scroll to it with GSAP's built-in scrollTo utility. Because it reads the trigger's current values rather than a value cached at page load, the jump stays accurate even after a resize.

Create the tween and its ScrollTrigger inside a mount effect after the track and slide elements have rendered, keeping the track's DOM ref (or a query selector scoped to the component) available to the getScrollDistance function. Store the returned tween so its .scrollTrigger.kill() can be called in the cleanup function, which also removes the pin and any generated spacer elements to avoid duplicate triggers on re-render.