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

Sequenced scrub

Each child animates over its own slice of the pin. Choreography without a timeline.

Use it for: Pinned stories where A plays over 0–40%, B over 30–70%, C over 60–100%.
Knobs: --sv-from / --sv-to per child (slice of the clock), --sv-distance; override --sv-clock to pick the clock

First this

then this

then this

Install: npx scrollvars add sequenced-scrub
Styles: import 'scrollvars/styles/pin.css'

<div data-sv data-sv-pin="250vh">
  <div class="sv-stage grid place-items-center">
    <div class="sv-range sv-range-rise grid gap-3">
      <h2 data-sv-from="0" data-sv-to=".4" class="text-4xl font-bold">First</h2>
      <p data-sv-from=".3" data-sv-to=".7">Second</p>
      <p data-sv-from=".6" data-sv-to="1">Third</p>
    </div>
  </div>
</div>
<!-- needs calc() division by var: Chrome 112 / Safari 16.4 / FF 112.
     --sv-r is a registered property (initial-value: 1), so an engine
     that can't compute the division settles there instead of turning
     invalid; still consume it as var(--sv-r, 1). -->
<div data-sv data-sv-pin="250vh">     <!-- the pin helper owns the height -->
  <div class="sv-stage">                <!-- the sticky viewport, from pin.css -->
    <div class="sv-range sv-range-rise">
      <h2 style="--sv-from: 0; --sv-to: .4">First</h2>
      <p style="--sv-from: .3; --sv-to: .7">Second</p>
      <p style="--sv-from: .6; --sv-to: 1">Third</p>
    </div>
  </div>
</div>

/* styles/pin.css ships it; the mechanism, if you want it inline: */
/* the clock lives on the container at zero specificity, so your own rule on
   that container (--sv-clock: var(--sv-t)) or an inline style wins */
:where(.sv .sv-range) {
  --sv-clock: var(--sv-pin, var(--sv-t, 0));
}
.sv .sv-range > * {
  --sv-r: clamp(0, calc((var(--sv-clock) - var(--sv-from, 0)) /
                        (var(--sv-to, 1) - var(--sv-from, 0))), 1);
}
/* consume --sv-r however you like, ALWAYS with a fallback of 1, and floor
   an opacity so unread text still meets 4.5:1 (the presets floor at .55): */
.mine > * { opacity: calc(.55 + .45 * var(--sv-r, 1)); scale: calc(.8 + var(--sv-r, 1) * .2); }

/* the same sheet's reduced-motion override, last so it wins on source order: */
@media (prefers-reduced-motion: reduce) {
  .sv .sv-range > * { --sv-r: 1; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .sv .sv-range > * { --sv-r: 1; }
<Track pin="250vh">
  <div className="sv-stage grid place-items-center">
    <div className="sv-range sv-range-rise grid gap-3">
      {steps.map((s, i, all) => (
        <Step key={s.id} style={{ '--sv-from': i / all.length, '--sv-to': (i + 1.6) / all.length } as React.CSSProperties} {...s} />
      ))}
    </div>
  </div>
</Track>

// JS twin for canvas/WebGL consumers:
// track(el, { pin: '250vh', onPin: (p) => uniform.set(mapRange(p, 0.3, 0.7)) })

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.