scrollvars / docs
v1.9.0  ·  demo · fx · bench · llms.txt

The human reference

One rAF in, CSS variables out. This page is for people; AIs get llms.txt. Live patterns: the demo (view-source is the spec) and the fx gallery (copy-paste, three formats each).

Quickstart

npm i scrollvars            # or pin: npm i github:aduptive/scrollvars

Next.js (zero-wrapper, recommended): one boot in the root layout, then plain server components with data attributes.

// app/layout.tsx
import 'scrollvars/styles/core.css'          // or styles.css for everything
import { ScrollVarsBoot } from 'scrollvars/react'
// <ScrollVarsBoot /> once inside <body>

// any page — no client components needed:
<section data-sv data-sv-once>
  <h2 className="sv-rise">Title</h2>
  <p className="sv-rise" style={{ '--sv-order': 1 }}>Copy</p>
</section>

Vanilla: import { scan } from 'scrollvars'; scan() — same attributes. React components: <Reveal auto>, <Track pin>, <Scenes count={4}> when you want props instead of attributes.

The six variables (the entire API surface)

namerangemeaning
--sv-view−1 → 1signed position vs the live band (0 while inside)
--sv-t0 → 1travel through the viewport (native view() semantics)
--sv-pin0 → 1progress across a pinned (sticky) stretch
--sv-scene0 → n−1eased, snapped scene index of a pinned section
--sv-r0 → 1per-child slice of the clock (sv-range choreography)
--mx / --my−1 → 1pointer offset from an element's center

Plus the class .sv-live (inside the band; enter/exit lines default 75%/25%, tunable per element via enter/exit or data-sv-enter/data-sv-exit) and the no-JS guard: hiding styles apply only under html.sv-on, so without JavaScript the page renders complete.

Every export

scrollvars (vanilla core)

track(el, opts?)drive one element; opts: view/travel/pin/scenes/snap/once/root/enter/exit + onLive/onScene/onTravel/onPin. Returns untrack.
scan(root?)zero-wrapper mode: tracks every [data-sv], MutationObserver keeps route changes covered.
slider(el, opts?)featherweight carousel on native scroll+snap; returns next/prev/goTo/seek/active/state/destroy.
toggles(root?)click states: data-sv-toggle/data-sv-target → class + --sv-state + aria-expanded.
trackPointer(el, opts?)pointer module: writes --mx/--my on .sv-tilt matches.
scrollToScene(el, i, n, smooth?)scroll the window to scene i of a pinned section.
mapRange(t, from, to, ease?)JS twin of sv-range for onTravel/onPin consumers.
clamp / snapProgress / easeOutCubic / refresh / prefersReducedMotionutilities.

scrollvars/react ('use client' wrappers — children stay RSC)

<ScrollVarsBoot />mounts scan() + toggles() once.
<Track> <Reveal> <Parallax> <Item> <Scenes>attribute API — order/distance/stagger/duration/ease props compile to the vars.
<Slider> <Slide> <Marquee> <Accordion> <Modal>the component kit (see the accessibility contract below).
useTrack / useScenes / useSlider / usePointer / useCanvasEffecthooks under the components.

scrollvars/canvas · scrollvars/compat

mountEffect(canvas, effect) — lifecycle harness for ambient canvas scenes (DPR cap, delta-time loop, auto-pause offscreen/hidden, reduced-motion flag). scrollvars/compat — opt-in legacy floor (~Chrome 61/FF 60/Safari 11): RO/IO stubs + transform-fallback CSS.

Preset vocabulary

Each name is a class; live previews with copy-paste code in the fx gallery. Import only the parts a page uses (styles/core.css 1.2 KB gz · pin 1.3 · slider 0.4 · tilt 0.5 · ui).

partclasses
core (entrances)sv-rise sv-fade sv-slide-l sv-slide-r sv-auto sv-stagger sv-skip sv-drift sv-spread sv-spread-in sv-view-fade sv-view-rise
pin (scrub)sv-curtain-l sv-curtain-r sv-rail sv-deck sv-reading sv-counter sv-range sv-range-rise sv-acts
slidersv-slider sv-cols sv-active sv-arrow sv-dots sv-dot sv-pause + --sd per slide
state / uisv-open sv-pop sv-words sv-marquee sv-accordion sv-tilt

Coming from GSAP + ScrollTrigger

you write in GSAPherenotes
scrollTrigger: { trigger, scrub }data-sv-travel → style with --sv-tCSS consumes the var; no tween object
pin: truetall wrapper + position: sticky + data-sv-pinthe sticky skeleton is yours (explicit, SSR-safe)
start/end: 'top 80%'enter/exit options (fractions)band lines, not arbitrary expressions
tl.to(a).to(b, '-=.2') scrubbedsv-range: --sv-from/--sv-to per childoverlapping slices = the choreography
ScrollTrigger.batch() revealssv-auto / sv-staggernth-child order for free
scroller: elroot: elnested scroll panels
onUpdate(self.progress)onTravel/onPin callbackscanvas, WebGL, video scrub
timelines with springs/exits/SVG morphkeep GSAPtime-driven work is out of scope here — honestly

When NOT to use scrollvars

Interop: scrollvars alongside GSAP on one page

They don't conflict — different writers on different properties. Keep each element owned by exactly one engine. GSAP can also consume the vars for the rare mixed case:

// GSAP reading scrollvars' clock (no second scroll listener):
gsap.ticker.add(() => {
  const t = parseFloat(getComputedStyle(section).getPropertyValue('--sv-t')) || 0
  heavyTimeline.progress(t)   // scrollvars steers, GSAP renders
})

The page pays GSAP's bundle then — do it for the page that needs it, not globally.

Accessibility contract

surfaceguarantees
every presethiding gated on html.sv-on (no-JS = fully visible); complete prefers-reduced-motion blocks — entrances render final state, scrub presets settle at end state, deck lays out in flow
SliderAPG carousel: role=region + aria-roledescription=carousel + label prop; slides annotated "i of n"; arrows/dots labeled; keyboard: arrows/Home/End on the focusable track, arrow keys inside form fields stay theirs; autoplay pauses on hover, keyboard focus, offscreen and hidden tab, renders a visible pause/resume control, track is aria-live=polite when not rotating; drag never steals plain clicks or focus
Marqueeduplicate copy aria-hidden + inert; pauses on hover and keyboard focus-within; reduced motion stops it
Modal / Accordionnative <dialog> / <details> — focus management, Escape, exclusivity from the platform
pinned scenesnative scroll is never hijacked — the driver only reads; snap is optional and never mandatory on pins

Troubleshooting

symptomcause → fix
content hidden until first scrollentrance CSS not gated on .sv-on — use the shipped presets or copy their guard pattern
Tailwind arbitrary class does nothing for mapped datathe JIT never generates interpolated class names — use style={{ '--sv-order': i }} for dynamic values (static one-offs like [--sv-order:1] are fine)
animation inside a modal/panel doesn't runtracked element lives in a nested scroller — pass root: scrollerEl so geometry matches (the listener already hears it)
rotate/translate combo lands wrongindividual transform properties apply in fixed order (translate→rotate→scale) — radial math needs the transform: shorthand
parallax rubber-bandsnever put a transition on a property driven by a continuous var — transitions are for state flips (sv-live), continuous motion is direct
--sv-r stuck at end stateengine lacks calc() division by var (needs Chrome 112/Safari 16.4/FF 112) — expected degradation; keep consuming as var(--sv-r, 1)
style-recalc heavy on giant sectionsan inherited var pays per-descendant — keep tracked elements thin: big static content lives next to, not inside, the animated elements (measured curve on /bench/)

Changelog

1.9.0 — 2026-08-26

Driven by the third blind-review round (Kimi K3 moved 7 → 8; the remaining criticisms became this release). per-frame getBoundingClientRect (IntersectionObserver-gated; entries with a custom root and once-completed entries are handled; tested). Long pages stop paying for sections nowhere near the screen — and the main bench scenario's total CPU flipped in scrollvars' favor with it. tracked element with live variable values, live badge, tracked-element outlines and click-to-scroll. import('scrollvars/debug').then(m => m.debug()). wire sizes are stamped at build time from the actual dist (esbuild+gzip); fx pages compute the engine size from the real bundle; every stale 1.2/3.1 KB claim corrected to measured values (driver 1.4 KB gz, full core 3.9, slider 1.7, core.css 1.8). The demo page's inline driver is labeled as that page's teaching copy — the package in dist/ is the source of truth. claim: not faster frames — the same frames for ~12× less bundle and a fraction of the heap, with total CPU trading blows (scrollvars wins shallow scenarios, batched GSAP wins deep subtrees; both published).

1.8.0 — 2026-08-25

The "path to 8" release — everything the external review panel said would move the score, minus the parts only the real world can provide. child of .sv-range derives --sv-r (0..1) from its --sv-from/--sv-to slice of the parent clock (--sv-pin, else --sv-t); sv-range-rise is the ready-made flavor; mapRange(t, from, to, ease?) is the JS twin for onPin/onTravel consumers. Reduced motion settles ranges at the end state. New fx entry + CLI component: sequenced-scrub. measures against an inner scroll container (root rects read once per root per frame, still strictly read-then-write); enter/exit options — also data-sv-enter/data-sv-exit and <Track> props — replace the hard-coded 75%/25% band. reproduces every published number: symmetric pairings both directions (idiomatic AND batched one-trigger-per-section GSAP), medians of N runs, rotated engine order, calibrated CPU throttle, raw JSON committed. The bench page tables regenerate from results/latest.json (scripts/bench-tables.mjs); the inline engine resyncs from dist on every build; the 900-trigger Lighthouse row is labeled a stress test. ?deep=N gives every box a realistic subtree; at 50 nodes/box the batched GSAP build wins total CPU. The measured curve and the authoring rule (keep tracked elements thin; static content next to, not inside, animated elements) are on /bench/ and in the docs. aria-roledescription + label prop; per-slide "i of n" annotation in place; visible pause/resume control whenever autoplay is set (.sv-pause, arrow knob family); aria-live off-while-rotating / polite otherwise; renderDot keeps focus indication. Marquee pauses on focus-within. guards SSR): carousel contract, <Scenes> prop hygiene, inert marquee duplicate. react/react-dom join as devDependencies. preset vocabulary, coming-from-GSAP mapping (with the honest "keep GSAP" row), when-NOT-to-use, interop recipe, per-surface accessibility contract, troubleshooting, rendered changelog — version visible on every gallery page.

1.7.0 — 2026-08-25

Fix release driven by a four-model external review panel (blind site evaluations + source-level code reviews). Everything below was independently found by at least one reviewer and verified before fixing.

Core driver:

phase, so scrolls inside modals and inner panels reach the driver. scrollHeight — progress reaches 1 on fixed-height elements with overflowing content. continuous outputs (travel/pin/scenes/callbacks) stop paying the per-frame getBoundingClientRect. --sv-view freezes at its last value. evaluated before the environment guard).

Slider:

from the content start); arrows mirror; progress/seek/goTo correct under dir="rtl". from drags (links and inputs inside slides work again, focus included), and the accidental click after a real drag is swallowed. native snap suspended forever.

React:

delegates at call time (it was permanently null). driver, onScene/as/VarProps work, and nothing leaks to the DOM. no longer lets md win at desktop widths). and reads the last IntersectionObserver record, not the first.

Canvas & pointer:

matchMedia listener calls as the driver.

Styles & packaging:

children of an inner .sv that hasn't gone live (plain CSS3 selectors). them stacked in one grid cell. doesn't parse (the advertised Chrome 61 floor). tree-shaking. on git installs (npm i github:aduptive/scrollvars works).

1.6.0 — 2026-08-24

copy-paste effects — live preview, Tailwind + CSS + React tabs with copy button, knobs documented, per-gallery llms.txt for AI ingestion. Built from one data file (scripts/fx-build.mjs); 8 seed effects. [--dir] [--force] — fetches a remote registry (registry.json on the fx site) and writes a complete component file into the project; the library grows without package releases.

1.5.0 — 2026-08-24

perView number or responsive map — breakpoints ARE media queries; gap, span per slide, arrows/dots chrome on stable classes + var knobs, prevIcon/nextIcon/renderDot, external control via ref exposing the full SliderHandle, autoplay that pauses on hover/offscreen/hidden), <Marquee> (infinite strip, the honest answer to Swiper loop), <Accordion> (native details + interpolate-size animation, exclusive groups via name), <Modal> (native dialog + sv-pop). --sv-span per slide), slider chrome, styles/ui.css (marquee, accordion). useSlider now returns the handle and accepts duration/axis.

1.4.0 — 2026-08-24

flex row and a per-card translate collapses them onto the center (slight fan) while --sv-spread is 0. Two clocks: .sv-spread-in plays on arrival (sv-live + transition + stagger, re-deals on re-entry), or map the variable from --sv-t/--sv-pin to scrub it. Demo case 18, zero bespoke JS.

1.3.0 — 2026-08-24

property (--sv-act) transitions 0 → N when sv-open (click) or sv-live (scroll) arrives; acts are the same clamp() slices as the scroll scenes — one idiom for every timeline. Relative retiming (--sv-acts-duration rescales all acts), reversible and interruptible by construction. Older browsers snap to the finished state.

1.2.0 — 2026-08-24

data-sv-toggle/data-sv-target flip a class, write --sv-state and keep aria-expanded in sync. Deliberately one click = one state change; no timeline engine. via @starting-style + allow-discrete; sv-words — rotating words (clipped column, --sv-word: n), promoted from the pizza demo.

1.1.0 — 2026-08-23

entrances, stagger, drift, native view()-tier), pin.css (1.3 KB), slider.css (0.4 KB), tilt.css (0.5 KB). styles.css remains as the generated aggregate (scripts/build-styles.mjs) — fully backwards compatible, selector set verified identical. reveal page ships ~2.2 KB gzipped total (track + core.css).

1.0.0 — 2026-08-21

slider, trackPointer, scrollToScene, refresh), React layer (Track/Reveal/Parallax/Scenes/Item, ScrollVarsBoot, hooks, VarProps attribute API), scrollvars/canvas, scrollvars/compat, styles.css presets. tree-shaking the stylesheet import), repository/homepage/bugs metadata, prepublishOnly runs build + tests. README gains a Defaults table. parse checks; deploy re-points the alias and verifies). Driver core test suite added — 9 test files green.

0.12.0 — 2026-08-20

(viewport-resize backed), always-visible IntersectionObserver stub, and a transform:-based preset fallback stylesheet (no :is()/clamp()/ min()). Free on modern browsers (feature-checks and exits). With the consumer bundler downleveling ES2020, the animated floor extends to ~Chrome 61 / Firefox 60 / Safari 11.

0.11.4 — 2026-08-20

through the same soft glide instead of native 40px key-scroll steps + hard snap settle. Containers are made focusable (tabindex=0) so this works in Safari too.

0.11.3 — 2026-08-20

starting under the drag, and selection auto-scroll inside the scrollable container fought the gesture (the opposite-direction tug). Note: mousedown no longer focuses elements inside slides; irrelevant for carousels.

0.11.2 — 2026-08-20

slider's axis: vertical page scrolling over the carousel (trackpad gestures are always slightly diagonal) was triggering mid-gesture goTo(nearest) and made multi-slide travel feel impossible. Quiet window 160 → 200ms.

0.11.1 — 2026-08-20

window while dragging (pointer capture on scrollable containers is unreliable) — release happens on the real pointerup, anywhere on the page.

0.11.0 — 2026-08-20

active/count/position (continuous)/progress/dragging/gliding; container gets --sv-progress; new seek(progress) for followers — two sliders chain in one line (Swiper controller/thumbs, unidirectional). bundle 151 KB min / 42 KB gzip (+18 KB CSS).

0.10.3 — 2026-08-20

a fixed-duration tween: short drag-release settles feel as soft as long button glides — the tween front-loaded short distances and read as a dry snap. Retargets stay continuous. duration calibrates the settle time.

0.10.2 — 2026-08-20

in-flight glide destination (pending target), not from the lagging active index — five fast clicks land five slides ahead.

0.10.1 — 2026-08-20

style for the whole interaction (authored inline value preserved), and a wheel-quiet debounce replaces the native fast settle with the slow glide. Skipped on instances authored with scroll-snap-type: none. sliders no longer advertise a hand they can't honor.

0.10.0 — 2026-08-20

configurable duration for next/prev/goTo AND the drag release — native smooth scrolling is fast and not configurable. Snap suspends while gliding (sv-gliding); pointerdown cancels the glide (the user takes over).

0.9.0 — 2026-08-20

scroll-snap, mouse drag, active-slide observer as --sd per slide (signed distance from center) + .sv-active, next/prev/goTo. .sv-slider CSS in styles.css. four snap flavors. Case 12: the treasure map — scroll drives a camera along an SVG path with heading + counter-rotating stations.

0.8.0 — 2026-08-20

the pinned stretch, for frame scrubbing and camera tours. The demo already taught it; now the package has it. deck/reading/counter samples now show the official presets, useScenes misuse replaced by useTrack({ scenes }).

0.7.0 — 2026-08-19

via data-sv-once/pin/travel/scenes) and follows DOM mutations; <ScrollVarsBoot /> wraps it for Next.js layouts — pages stay 100% RSC. sv-reading (guided reading), sv-counter (@property + counter()). Node too, not only through bundlers.

0.6.0 — 2026-08-19

band — the same 75%/25% lines the sv-live class uses, so the variable and the class always agree. −1 with the top at the viewport's bottom edge, 0 across the whole band, +1 once the bottom clears the exit line. Entrance/exit ramps are shorter than before (0.25 vh each); sv-drift reacts a touch snappier near the edges.

0.5.0 — 2026-08-19

stagger, duration, ease as props on Track/Reveal/Parallax — they compile to the CSS variables; new <Item effect="rise|fade|slide-l| slide-r|drift|tilt"> for children. The variables remain the real API. right-edge aligned (min() keeps it moving when the track fits).

0.4.0 — 2026-08-18

harness for ambient (time-driven) canvas effects: resize, DPR cap (2), delta-time loop, auto-pause offscreen and on hidden tab, live reduced-motion flag, full cleanup. Simulations stay in userland. test/canvas.test.mjs.

0.3.0 — 2026-08-18

(−1..1 from the element's center) with one delegated listener; sv-tilt preset (3D tilt + glare).

0.2.0 — 2026-08-17

.sv-live, html.sv-on guard. React layer: Track, Reveal, Parallax, Scenes, useTrack, useScenes. Presets incl. sv-curtain-l/r, sv-rail, sv-auto stagger, sv-view-* pure-CSS tier.

0.1.0 — 2026-08-17

batched read → write), CSS variables as the entire output surface, zero React renders during scroll.