tool / horizontal scrollcss · javascript · react
Horizontal scroll section generator
Scroll down, move sideways. Set the panels, their width, the gap and how much scroll the trip takes, watch it run in a box you scroll, and copy the HTML, CSS and JavaScript, a React component, or a version in CSS alone.
- Options
- 7
- Easings
- 7
- Code outputs
- 5
- Every API, gzipped
- 11.6 KB
Build a horizontal scroll section
- 01Hills
- 02Tide
- 03House
- 04Tree
- 05Boat
scroll p 0.00
in this box
Your system asks for reduced motion. scrollHorizontal keeps the scrub anyway (respectReducedMotion is false by default): it moves only as you scroll. The CSS-only version shows its fallback row instead.
<section class="hs-outer" aria-label="Highlights">
<div class="hs-stage">
<ul class="hs-track">
<li class="hs-panel">
<h3>Panel 1</h3>
<p>Your content.</p>
<a href="/panel-1">Read more</a>
</li>
<li class="hs-panel">
<h3>Panel 2</h3>
<p>Your content.</p>
<a href="/panel-2">Read more</a>
</li>
<li class="hs-panel">
<h3>Panel 3</h3>
<p>Your content.</p>
<a href="/panel-3">Read more</a>
</li>
<li class="hs-panel">
<h3>Panel 4</h3>
<p>Your content.</p>
<a href="/panel-4">Read more</a>
</li>
<li class="hs-panel">
<h3>Panel 5</h3>
<p>Your content.</p>
<a href="/panel-5">Read more</a>
</li>
</ul>
<div class="hs-bar" aria-hidden="true"><span></span></div>
</div>
</section>
<!-- The script version. The CSS-only version needs no script. -->
<script type="module" src="main.js"></script>Three files: the HTML, the CSS and main.js. Without the script the section is a row you scroll sideways; with it, it pins and moves as the page scrolls.
How vertical scroll moves it sideways
Three boxes do it (fig. 1). The outer is the scroll room: one screen tall, plus the scroll the trip should take. The stage inside it is position: sticky at top: 0 and one screen tall, so it stays on screen while the outer scrolls past. The track inside the stage is one row of panels, as wide as they are, cut off at the stage’s edges by overflow: clip.
/* The three boxes. */
.outer { height: 400vh; } /* one screen for the stage + 300vh of scroll */
.sticky { position: sticky; top: 0; height: 100vh; overflow: clip; }
.track { display: flex; width: max-content; }scrollHorizontal moves the track. It walks up from the track to the nearest sticky ancestor, the stage, and takes the scroll window from that stage’s parent, the outer: progress 0 when the outer’s top reaches the window’s top, 1 when its bottom reaches the window’s bottom. Over that window it moves the track by translateX from 0 to minus the travel, the track’s width less the stage’s, and it measures both again when the layout changes. It always runs on the library’s JavaScript engine, never the browser’s scroll timeline.
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
// No distance, no trigger: it finds the sticky stage above the track,
// takes the scroll window from the stage's parent and measures the travel.
const strip = scrollHorizontal('.track');
strip.refresh(); // re-measure now (it already does after a resize)
strip.destroy(); // stop, and put the track's transform backScript or CSS: what each costs
The generator writes both, from the same HTML and the same fallback row. The preview runs either one: switch preview with above.
| Cost | scrollHorizontal | CSS only |
|---|---|---|
| JavaScript | The svg-scroll-draw/horizontal entry, on its own. | None. |
| Each frame | While the strip is on screen, one requestAnimationFrame loop reads the scroll position and writes the track’s transform. | The browser moves the transform itself, and can do it off the main thread. |
| Browsers | All of them. | Where animation-timeline: view() works, Chrome and Edge 115+ and Safari 26+. Everywhere else, the fallback row. |
| Travel | Measured: the track’s width less the stage’s, again whenever the layout changes. | Written as translateX(calc(100cqw − 100%)): the same sum, at any width. |
| Easing | Any of the 7 names, or a function. | linear, ease-in and ease-out exactly; the rest as a linear() through the curve. |
| Progress | onProgress(p): a bar, a counter, anything. | A second animation on the same timeline. No callback. |
| Keyboard | A focusin handler scrolls the page to the panel. | :has(:focus-visible) stops the slide, and the row scrolls to the panel. |
| Reduced motion | Keeps the scrub: it moves only as the reader scrolls. | Shows the fallback row. |
Take the script when the strip has to move in every browser, when something else follows its progress, or when it should be measured rather than written down. Take CSS when no JavaScript is the point and a row you scroll sideways is a good enough answer for older browsers. One limit applies to both: the 1:1 scroll length is a calc() of the panel widths, so it holds for panels of a set width; for panels that size themselves, measure the travel and set the height from it, as the step-by-step post does.
Every panel in reach
A sideways strip hides most of its panels at any moment. Whoever reads it, by keyboard, with less motion, without the script or with a screen reader, must still get to all of them.
Keyboard
A panel off to the side is still in the tab order, but the browser cannot bring it into view: overflow: clip stops it scrolling the stage, and overflow: hidden would let it, then leave the strip shifted. main.js listens for focusin and scrolls the page, a frame later, to the point where the strip shows that panel. The CSS-only version stops the slide while a panel has keyboard focus, so the stage turns back into a row, and the browser brings the panel into view as it does in any row that scrolls sideways. Try both with Tab in the preview.
Reduced motion
scrollHorizontal keeps the movement for readers who ask for less motion: respectReducedMotion is false by default, because the strip only follows the reader’s own scroll, and holding it at its end would hide every panel but the last. Set it to true only when the layout shows every panel without the transform. The CSS-only version does honour the setting, because what it falls back to is exactly that layout.
Without the script
The script version’s CSS pins the section only once main.js adds .is-pinned. Until then, or if the script never runs, the stage is a row with overflow-x: auto, and every panel is in reach by touch, trackpad, Shift and the wheel, or Tab.
Screen readers
The panels are list items in reading order, and a transform moves what is painted, not the order it is read in. The progress bar is aria-hidden: the list already says how many panels there are.
Every scrollHorizontal option
distance
measured by default. How far the track moves, in px. Left out, it is the track’s scrollWidth minus the width it is seen through: the sticky stage’s content box (with no stage, the scrollContainer’s width, or the window’s without its scrollbar), measured again whenever the layout changes. A number you pass is used as given.
trigger
{ start: 'top top', end: 'bottom bottom' } by default. The scroll window, measured on the trigger element: from its top at the window’s top to its bottom at the window’s bottom, which is the whole time the stage is pinned.
easing
'linear' by default. How the movement is spread over the scroll: 'linear', 'ease-in', 'ease-out', 'ease-in-out', 'spring', 'bounce', 'elastic', or any function of t. linear keeps the strip in step with the scroll.
scrollContainer
the window by default. A selector or an element that scrolls instead of the page, as the preview’s box does.
triggerElement
the stage’s parent by default. The element whose height is the scroll window. Left out, the parent of the track’s nearest position: sticky ancestor, which is the outer; with no sticky ancestor, the track itself.
respectReducedMotion
false by default. true holds the track at its end for readers who ask for less motion. Off by default, unlike the library’s other APIs: the strip moves only as the reader scrolls, and its end state would leave every panel but the last out of reach.
onProgress
none by default. Called with the eased progress, 0 to 1 (past 1 with spring or elastic), on every frame while the strip is on screen. The generator’s progress bar is one line of it.
It returns destroy(), refresh() (re-measure now; it already does 150 ms after the window, the page, the track or the outer changes size), seek(p), pause(), resume(), replay() and getProgress().
Horizontal scroll questions
How do I make a section scroll horizontally on vertical scroll?
Use three boxes. A tall outer element holds the scroll; inside it, a stage with position: sticky, top: 0, one screen tall and overflow: clip stays on screen while the outer scrolls past; inside the stage, a track with display: flex and width: max-content holds the panels in one row. As the page scrolls through the outer, move the track left with translateX, from 0 to its own width minus the stage’s. scrollHorizontal(track) from svg-scroll-draw/horizontal does the measuring and the moving, and the generator above writes the CSS.
Can I make a horizontal scroll section with CSS only?
Yes, where the browser has scroll-driven animations: give the outer a view-timeline, run an animation on the track along it with animation-range: contain 0% contain 100%, and end it at translateX(calc(100cqw - 100%)) with the stage as the container. Chrome and Edge have supported it since 115 and Safari since 26. Put it inside @supports (animation-timeline: view()) so every other browser keeps a plain row that scrolls sideways. The generator’s CSS only tab writes all of it.
How do I change the speed of a horizontal scroll?
With the outer’s height. The stage stays pinned for the outer’s height minus one screen, and the track crosses its whole travel in that scroll, so the speed is the travel divided by the scroll length. When the two are equal, a pixel of scroll moves the strip a pixel; the generator’s 1:1 setting writes that height in CSS from the panel count, width and gap. The easing changes how the speed varies along the way, not the total.
Why is my horizontal scroll section not sticking or not moving?
The usual causes: overflow: hidden on html or body, which makes body a scroll container the stage sticks to instead of the window (use overflow-x: clip); overflow: hidden, auto or scroll on another ancestor of the stage; an outer no taller than the screen, so there is no scroll to pin through; a track no wider than the stage, so there is no travel; or a scroll window measured on the track, which the stage pins at one screen tall, so it has no length. scrollHorizontal measures it on the stage’s parent by default for that reason.
Is a horizontal scroll section accessible?
It can be. Keyboard users need the strip to follow focus, because a stage with overflow: clip cannot be scrolled to a focused panel; the generated code does that in a focusin handler, or in CSS with :has(:focus-visible). For readers who ask for less motion, scrollHorizontal keeps the scrub by default (respectReducedMotion: false), since it moves only as they scroll and its end state would hide every panel but the last; the CSS version shows a sideways-scrolling row instead.
How do I add a horizontal scroll section in React or Next.js?
Call scrollHorizontal on the track in a useEffect and destroy() it in the cleanup, in a client component ('use client' in the Next.js App Router). There is no ready-made component for it in svg-scroll-draw/react; the generator’s React tab writes the whole component, with the CSS file it imports.
Can the section scroll inside a container instead of the page?
Yes: pass scrollContainer, a selector or an element. The scroll window is then measured against that container’s scroll position and height instead of the window’s. The preview above is exactly that: a small box with the outer, the stage and the track inside it, driven by the box’s own scroll.
Built step by step: horizontal scroll sections without GSAP. More scroll-driven CSS: the CSS scroll-driven animation generator. All of svg-scroll-draw 2.23.3: the docs.