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

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" data-sv-order="1">Copy</p>
</section>

Per-element knobs are attributes too: data-sv-order, data-sv-distance, data-sv-from/data-sv-to become the matching CSS variables on mount. No style attribute, safe for mapped CMS content, zero global CSS. (React props on <Item>/<Reveal> compile the same way; sv-stagger on a parent needs no per-child anything.)

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 variables (what the driver writes)

outputrangemeaning
--sv-view−1 → 0 → 1Below the live band → inside it (flat at 0) → gone above
--sv-t0 → 1Travel through the viewport (same range as native view() with a zero inset, cover, vertical axis)
--sv-pin0 → 1Progress across a pinned (sticky) stretch: curtains, rails, scrubbing
--sv-stage-widthpxMeasured inner width of a pinned .sv-stage; the rail uses it instead of the window width
--sv-scene0 → n−1Scene index of a pinned section, eased and snapped
--sv-scenesnScene count, next to --sv-scene: progress is var(--sv-scene) / (var(--sv-scenes) - 1)
--sv-page / --sv-v0 → 1 / ±20 viewport-heights/sOn <html> once anything is tracked, and either some CSS mentions them (a var() read, a declaration outside <html>'s own inline style, even a comment in an inline <style> counts; detection errs toward publishing; a declaration inline on <html> itself does not count, only a var() read there does) or setPageOutputs(true) was called: progress through the document, and signed velocity in viewport-heights per second, clamped to ±20, back to 0 within ~80 ms of the last scroll event
--mx / --my−1 → 1Pointer offset from the element's center, clamped (pointer module)
.sv-liveclassOn while inside the activation band (enter 75%, exit 25% of the viewport); once latches it

Derived by presets and components, not the driver: --sv-r (sv-range slice), --sd and --sv-progress (slider), --sv-state (toggles), --sv-act (sv-acts).

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.

One knob, four ways (de/para)

Same result (a child rising second, 3rem of travel) pick the authoring style that fits the context. You never need the style attribute unless the value is computed in JS at runtime:

contextbefore (works, but…)after (preferred)
HTML / CMS / RSC <p class="sv-rise" style="--sv-order: 1; --sv-distance: 3rem"> <p class="sv-rise" data-sv-order="1" data-sv-distance="3rem">
React <p className="sv-rise" style={{ '--sv-order': 1 }}> <Item order={1} distance="3rem">…</Item>
Tailwind, static value <p class="sv-rise" style="--sv-order: 1"> <p class="sv-rise [--sv-order:1]">
Sequential list an order per child, by hand <div class="sv-stagger"> on the parent, nth-child does it, zero per-child anything
Mapped data (CMS loop) style={{ '--sv-order': i }} data-sv-order={i}. Attributes interpolate; Tailwind classes don't (the JIT scans statically)
sv-range slices style="--sv-from: .3; --sv-to: .7" data-sv-from=".3" data-sv-to=".7"

The attributes (data-sv-order, data-sv-distance, data-sv-from, data-sv-to) are written once as the matching CSS variable on mount by scan()/<ScrollVarsBoot />. Never in the frame loop, zero global CSS. When typed CSS attr() settles cross-browser this mapping becomes pure CSS.

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/onFlow. 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?, root?)scroll the window, or the given inner scroller, to scene i of a pinned section.
setPageOutputs(on)force the document-wide --sv-page and --sv-v on or off instead of the automatic consumer detection.
split(el, { by }) / splitPartsSplitText-lite: word/char spans with --sv-order + --sv-count, aria-safe, restorable. Also zero-wrapper via data-sv-split.
mapRange(t, from, to, ease?)JS twin of sv-range for onTravel/onPin consumers.
clamp / snapProgress / easeOutCubic / refresh / prefersReducedMotion / setMotion / onMotionChangeutilities; the last three read, set and watch the effective motion preference (the OS setting or data-sv-motion="reduce" on <html>).

scrollvars/react ('use client' wrappers. Children stay RSC)

<ScrollVarsBoot />mounts scan() + toggles() once.
<Track> <Reveal> <Parallax> <Item> <Scenes> <Split>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 · scrollvars/debug

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.
scrollvars/debug. Dev overlay: debug() lists every tracked element with its live variables, outlines it, click scrolls to it, plus a perf HUD (FPS, dropped/late frames, worst frame, long-animation-frame blocking where Chrome supports it), { markers: true } for ScrollTrigger-style start/end lines, and a perf lint (default on) flagging a --sv-* read landing in a non-compositable property. ?sv-debug on a page with <ScrollVarsBoot /> mounts it.

Preset vocabulary

Each name is a class; live previews with copy-paste code in the fx gallery. Import only the parts a page uses (gzipped: styles/core.css 3.1 KB · pin 3.4 · slider 1.6 · tilt 0.7 · state 2.3 · ui 1.5).

partclasses
core (entrances)sv-rise sv-fade sv-slide-l sv-slide-r sv-auto sv-stagger sv-skip sv-split sv-split-rise sv-drift sv-spread sv-spread-in sv-view-fade sv-view-rise
pin (scrub)sv-stage sv-curtain-l sv-curtain-r sv-rail sv-deck sv-reading sv-counter sv-range sv-range-rise
slidersv-slider sv-cols sv-active sv-arrow sv-dots sv-dot sv-pause + --sd per slide
statesv-open sv-pop sv-words sv-acts (a scroll-driven acts clock needs core.css too)
uisv-marquee sv-accordion
tiltsv-tilt + --sv-tilt (angle)

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, through a guarded onTravel callback, motion aware and cleaned up on stop(), never a bare per-frame style read with no stop and no reduced-motion branch. The page pays GSAP's bundle then. Do it for the page that needs it, not globally. Both patterns exist as official fx recipes with live previews: GSAP timeline under scrub (author in time-space, consume as a scrub. Still input-driven) and Three.js scene on the pin (the canvas harness with context: null owns the lifecycle, Three owns the WebGL, scroll feeds progress). Hitting the scope ceiling never means rewriting: the driver stays, you plug in what's missing.

Accessibility contract

surfaceguarantees
every entrance and scrub presethiding gated on html.sv-on (no-JS = fully visible; disclosures, sv-pop, Accordion and Modal keep their closed state without JS like the platform's own details and dialog); complete prefers-reduced-motion blocks: entrances render final state, scrub presets settle at end state, deck lays out in flow; every block has a twin under html[data-sv-motion="reduce"], the page's own switch (setMotion()), kept in step by a test and checked rendered in CI
motion preferenceone effective value, the OS setting or the page switch, read live by the driver, the slider's glide (a running glide settles when it flips), canvas effects (fx.reducedMotion) and the React Slider autoplay (starts paused under reduce; the visible control resumes it)
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, a visually hidden role=status outside the track reads "Slide N of M", aria-live=off while rotating; drag never steals plain clicks or focus
Marqueeduplicate copy aria-hidden + inert; pauses on hover and keyboard focus-within; reduced motion stops it
Slider dotsthe kit's dots are 24 by 24 CSS pixel buttons (WCAG 2.5.8, --sv-dot-target) that never shrink, a crowded row wraps, with the visual dot drawn inside (--sv-dot-size); no transition under reduced motion. Measured in CI. A renderDot of your own is yours to size
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
keyboard reacha gate tabs through every gallery page and the home page, forward and back, and requires each focused element to be seen: in the viewport, effective opacity at least 0.5, visibility: visible, not covered at the center of its visible part, still so 250ms later on the way forward. Proved able to fail on a fixture; the pages set scroll-padding-top under their fixed header
reflow and zoomat 320 and 640 CSS pixels of width every gallery page and the home scroll in one direction only, every [data-sv-fit] box fits its stage or has released the pin, and the pinned pages pass the keyboard gate at 320. Proved able to fail on a fixture
axeevery gallery page and the home page pass axe-core (WCAG 2.x A and AA) with zero violations in CI, after boot and again after a walk down the page a viewport at a time, at the bottom, the middle and the top; the gate proves it can fail. A floor, not proof

Browser support: and the answer for older ones

browserfully animatednotes
Chrome / Edge104+ (Aug 2022)native zero-JS sv-view-* tier: 115+ · sv-range needs 112+ · Accordion height animation 129+
Firefox78+ (Jun 2020, :is()/:where())sv-counter 128+ · sv-range 112+
Safari / iOS14.1+ (Apr 2021)sv-counter and sv-range 16.4+
anything older, or no JScontent 100% visible, staticthe html.sv-on guard for no JS. With JS running below the transform floor and without compat(), pin.css's own net keeps the stage, curtains, deck and rail in flow and readable (see below); with compat() installed, curtains and rail keep their fallback animation instead, but a stage holding a static deck still returns to flow, wrapper height included, so every card stays reachable either way; compat()'s rail fallback ignores --sv-rail-start and starts at translateX(0) instead of offscreen, so it is stationary whenever the track's own width equals the viewport

The design rule that makes this table safe to sign off: below the floor nothing breaks. Skip compat() and the page renders complete and static, nothing overlapping or clipped (curtains parted, deck unstacked, .sv-stage back in flow; sv-rail's own exception is below); call it and the page animates instead, on roughly Chrome 61+ / Firefox 60+ / Safari 11+. Animation is progressive enhancement, never a dependency. Presets that lean on newer CSS (sv-range, sv-counter) degrade to their end state individually.

Below the transform floor, with JS still running, styles/pin.css carries its own @supports not (translate: 0) net, for four of its rules: the stage, both curtains, the deck and the rail, all scoped to html:not([data-sv-compat]) except the deck. The curtains sit parted and static rather than animated, the deck unstacks to a static, non-overlapping layout, the rail's track wraps instead of running off the right edge, and the stage resets to flow so nothing is clipped by the stage itself (sv-reading, sv-range and sv-counter need no net of their own, they settle for unrelated reasons). With compat() installed (its own data-sv-compat marker on <html> is the switch) the stage stays pinned instead, and the module's fallback sheet animates the curtains and rail from --sv-pin, measured off that stage; compat()'s own sv-rail fallback still ignores --sv-rail-start and starts at translateX(0) instead of entering from offscreen, so it is stationary whenever the track's own width equals the viewport. A stage holding a static sv-deck returns to flow either way, compat installed or not (the driver marks it data-sv-flow below the floor regardless), so every card stays reachable and nothing sits past a clip. One more caveat until ADU-150 lands: a released stage can also leave a parked curtain panel sitting outside it, extending the document so a reader can scroll sideways to an empty panel.

Older targets: scrollvars/compat, opt-in. On modern browsers it runs three feature checks and exits (free); on old ones it installs ResizeObserver/ IntersectionObserver stubs and a transform:-based fallback stylesheet for the reveal presets (sv-rise, sv-fade, sv-slide-l, sv-slide-r, sv-auto, sv-drift) and the pin presets sv-curtain-l, sv-curtain-r and sv-rail, written without :is()/clamp()/min() (the one max() left, drift's fade, sits behind a plain opacity declaration that old parsers keep). sv-deck unstacks to a static, non-overlapping layout instead of animating (its fly-away slice needs clamp()); sv-spread stays static below the floor too, no fallback rule, its rule parses fine but has no translate/rotate to apply down there. sv-split-rise has no fallback rule either, but its floor is not one line: below :is() support its animating rule, written with :is(), is dropped whole by a parser that predates it, fully static; between :is() support and the individual-transform floor the rule still matches and its opacity declaration still transitions, so the text fades in without rising. With your bundler downleveling the ES2020 dist (Next.js already does), the reveal and pin presets above animate on roughly Chrome 61+ / Firefox 60+ / Safari 11+:

import { compat } from 'scrollvars/compat'
compat()   // once, before anything else

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 data-sv-order={i} (or a style var) 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

Unreleased

1.19.1 (2026-10-01)

Changed

Added

Fixed

Docs

1.19.0 (2026-09-27)

Added

Fixed

1.18.1 (2026-09-25)

Fixed

Docs

Added

1.18.0 (2026-09-21)

Changed

Added

Fixed

1.17.5 (2026-09-15)

Fixed

1.17.4 (2026-09-15)

Fixed

1.17.3 (2026-09-15)

Fixed

1.17.2 (2026-09-14)

Fixed

1.17.1 (2026-09-14)

Fixed

1.17.0 (2026-09-14)

Added

Fixed

Changed

Performance

1.16.1 (2026-09-09)

1.16.0 (2026-09-09)

1.15.2 (2026-09-09)

1.15.1 (2026-09-08)

1.15.0 (2026-09-08)

1.14.2 (2026-09-07)

1.14.1 (2026-09-07)

1.14.0 (2026-09-07)

Review fixes and gallery sections

Tooling (ADU-196)

Tooling (ADU-195)

Docs (ADU-194)

Tooling (ADU-194)

Docs (ADU-193)

Docs (ADU-181)

Tooling (ADU-181)

Docs (round 8, ADU-192)

Docs read against the code merged by the four round-8 code tickets.

Driver (blind review round 8, ADU-191)

React (blind review round 8, ADU-188)

No public surface change: no new export, prop, class or --sv-* variable.

Gallery (blind review round 8, ADU-188)

Slider (blind review round 8, ADU-190)

Tooling (round 7 follow-up, ADU-176)

Testing (round 7 follow-up, ADU-177)

No behavior change: a stub modelled a ResizeObserver that never delivers, which left the mount-time schedule untested rather than passing.

Testing (round 6 follow-up, ADU-161)

No behavior change: three fixtures modelled a browser state no engine is in, which is what let two rounds of tests pass on broken code.

Toggles (round 7 follow-up, ADU-172)

Docs (round 7, ADU-170)

Docs read against the code merged by the five round-7 code tickets.

Compat (blind review round 7, ADU-168)

Presets and no-JS (blind review round 7, ADU-168)

Driver (blind review round 7, ADU-168)

Gallery (blind review round 7)

Click driver (ADU-169)

Pointer (ADU-169)

Presets (round 7, ADU-166)

Presets, React and Gallery (round 7 follow-up, ADU-171)

Tooling (blind review round 6)

Blind review round 3 (Codex gpt-6-astra on commit 677656b): the CSS enhancement contract holds in every documented case. Second pass (verifier findings on the same round): three more defects fixed.

Driver

Presets and no-JS

Click driver

Compat

Testing

Driver (blind review round 4)

Canvas

Installed components (blind review round 3)

Installed components (blind review round 3, second pass)

Installed components (blind review round 3, third pass)

Installed components (blind review round 3, fourth pass)

Installed components (blind review round 4)

Testing (blind review round 4)

Tooling

Slider

Blind review round 3 (GPT-6 Astra), findings 8, 9 and 10, verified in real Chrome with a puppeteer-core probe (5 slides of 100px, container 300px wide, scrollWidth 500).

React

CI

React types

Gallery

Docs

Blind review round 3 (GPT-6 Astra), section 2: wording that had drifted from the code it describes.

React (blind review round 4)

Slider (blind review round 4)

Click driver (blind review round 4)

Presets and no-JS (blind review round 4)

Docs (blind review round 4)

Blind review round 4 (Astra on 7489a11), section 2: eight docs mismatches against the code ADU-129 to ADU-132 shipped.

Driver (blind review round 5)

Presets and no-JS (blind review round 5)

Gallery (blind review round 5)

Blind review round 5 (Astra on 7992458), findings 7c, 10, 11.

Scanner (blind review round 5)

Scanner (blind review round 5, second pass)

Pointer (blind review round 5)

React (blind review round 5)

Testing (blind review round 5)

Docs (round 5, ADU-145)

Docs read against the code merged by the five round-5 code tickets.

Pointer (blind review round 6, ADU-152, live regression)

Testing (round 6, ADU-152 fix pass)

Slider (blind review round 6)

React (blind review round 6)

Scanner (blind review round 6)

Gallery (blind review round 6)

Blind review round 6 (Astra on 3e2c18b), finding 8a. Successor of ADU-144.

Canvas (blind review round 6)

Driver (blind review round 6)

Docs (round 6, ADU-159)

Docs read against the code merged by the seven round-6 code tickets.

Slider (blind review round 7)

React (blind review round 7)

Canvas (round 8, ADU-189)

1.13.0 (2026-09-05)

Second source-level review round (Kimi K3 and Codex gpt-6-astra on a clean clone): every confirmed defect fixed, plus the primitives both reviewers said premium sections would need.

Sections in the fx gallery

Driver

React

Slider

Presets and no-JS

Sticky headers, knobs, interaction

Canvas, debug, CLI, split

Review round 2 (Codex gpt-6-astra, 17 findings, all fixed)

One source for the shared facts

Docs and release

1.12.4 (2026-09-02)

1.12.3 (2026-09-01)

Fixes a regression from 1.7.0's drag-click fix.

1.12.0 (2026-08-27)

The last executable items from the review panel's path-to-9, plus launch readiness.

1.11.1 (2026-08-27)

1.11.0 (2026-08-27)

1.10.0 (2026-08-27)

"Hitting the ceiling never means rewriting". Interop as official recipes, not adapter modules (no new dependencies, the scope boundary stays).

1.9.0 (2026-08-26)

Driven by the third blind-review round (Kimi K3 moved 7 → 8; the remaining criticisms became this release).

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.

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:

Slider:

React:

Canvas & pointer:

Styles & packaging:

1.6.0 (2026-08-24)

1.5.0 (2026-08-24)

1.4.0 (2026-08-24)

1.3.0 (2026-08-24)

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.

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.

1.0.0 (2026-08-21)

0.12.0 (2026-08-20)

0.11.4 (2026-08-20)

0.11.3 (2026-08-20)

0.11.2 (2026-08-20)

0.11.1 (2026-08-20)

0.11.0 (2026-08-20)

0.10.3 (2026-08-20)

0.10.2 (2026-08-20)

0.10.1 (2026-08-20)

0.10.0 (2026-08-20)

0.9.0 (2026-08-20)

0.8.0 (2026-08-20)

0.7.0 (2026-08-19)

0.6.0 (2026-08-19)

0.5.0 (2026-08-19)

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.

0.4.0 (2026-08-18)

0.3.0 (2026-08-18)

0.2.0 (2026-08-17)

0.1.0 (2026-08-17)