You Might Also Like
Scroll Horizontal Story Track — Free GSAP ScrollTrigger Sideways Scrollytelling
Scroll Horizontal Story Track · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Horizontal Story Track — A Story That Moves Sideways as You Scroll Down

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