ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Horizontal rail

Vertical scroll travels a horizontal track through a pinned stage.

Use it for: Process steps, timelines, galleries that read left-to-right.
Knobs: outer height = scroll length; works at any viewport width (min() keeps it moving)

01
02
03
04
05

Install: npx scrollvars add horizontal-rail
Styles: import 'scrollvars/styles/pin.css'

<div data-sv data-sv-pin="300vh">
  <div class="sv-stage flex items-center">
    <div class="sv-rail flex gap-4 px-[10vw]">
      {cards.map(c => <Card key={c.id} class="w-80 shrink-0" />)}
    </div>
  </div>
</div>
<div data-sv data-sv-pin="300vh">        <!-- the pin helper owns the height -->
  <div class="sv-stage rail-stage">      <!-- the sticky viewport, from pin.css -->
    <div class="sv-rail">…cards…</div>
  </div>
</div>

/* the preset (styles/pin.css), plus the stage layout, which is yours: */
.rail-stage { display: flex; align-items: center; }
/* --sv-stage-width is measured by the driver; --sv-rail-start overrides it */
.sv .sv-rail { width: max-content; display: flex; gap: 1rem; padding: 0 10vw;
  translate: calc((1 - var(--sv-pin, 0)) * var(--sv-rail-start, var(--sv-stage-width, 100vw)) +
                  var(--sv-pin, 0) * min(var(--sv-stage-width, 100vw) - 100%, 0px)) 0; }

/* the same sheet's reduced-motion override, last so it wins on source order: */
@media (prefers-reduced-motion: reduce) {
  .sv .sv-rail { translate: none; width: auto; flex-wrap: wrap; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .sv .sv-rail { translate: none; width: auto; flex-wrap: wrap; }
<Track pin="300vh">
  <div className="sv-stage flex items-center">
    <div className="sv-rail flex gap-4 px-[10vw]">{cards}</div>
  </div>
</Track>

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.