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)
| name | range | meaning |
|---|---|---|
--sv-view | −1 → 1 | signed position vs the live band (0 while inside) |
--sv-t | 0 → 1 | travel through the viewport (native view() semantics) |
--sv-pin | 0 → 1 | progress across a pinned (sticky) stretch |
--sv-scene | 0 → n−1 | eased, snapped scene index of a pinned section |
--sv-r | 0 → 1 | per-child slice of the clock (sv-range choreography) |
--mx / --my | −1 → 1 | pointer 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 / prefersReducedMotion | utilities. |
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 / useCanvasEffect | hooks 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).
| part | classes |
|---|---|
| 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 |
| slider | sv-slider sv-cols sv-active sv-arrow sv-dots sv-dot sv-pause + --sd per slide |
| state / ui | sv-open sv-pop sv-words sv-marquee sv-accordion sv-tilt |
Coming from GSAP + ScrollTrigger
| you write in GSAP | here | notes |
|---|---|---|
scrollTrigger: { trigger, scrub } | data-sv-travel → style with --sv-t | CSS consumes the var; no tween object |
pin: true | tall wrapper + position: sticky + data-sv-pin | the 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') scrubbed | sv-range: --sv-from/--sv-to per child | overlapping slices = the choreography |
ScrollTrigger.batch() reveals | sv-auto / sv-stagger | nth-child order for free |
scroller: el | root: el | nested scroll panels |
onUpdate(self.progress) | onTravel/onPin callbacks | canvas, WebGL, video scrub |
| timelines with springs/exits/SVG morph | keep GSAP | time-driven work is out of scope here — honestly |
When NOT to use scrollvars
- Time-driven orchestration — interruptible springs, layout/exit transitions, SVG morphing, choreographed intros on a clock: GSAP or Motion are the right tools.
- One load-in animation — plain CSS keyframes; no library at all.
- App-like gesture physics (drag with momentum between arbitrary states) — Motion.
- Chromium-only projects that can require the native tier — pure CSS scroll-driven
animations, no JS; scrollvars'
sv-view-*tier is exactly that where supported.
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
| surface | guarantees |
|---|---|
| every preset | hiding 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 |
| Slider | APG 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 |
| Marquee | duplicate copy aria-hidden + inert; pauses on hover and keyboard focus-within; reduced motion stops it |
| Modal / Accordion | native <dialog> / <details> — focus management, Escape, exclusivity from the platform |
| pinned scenes | native scroll is never hijacked — the driver only reads; snap is optional and never mandatory on pins |
Troubleshooting
| symptom | cause → fix |
|---|---|
| content hidden until first scroll | entrance CSS not gated on .sv-on — use the shipped presets or copy their guard pattern |
| Tailwind arbitrary class does nothing for mapped data | the 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 run | tracked element lives in a nested scroller — pass root: scrollerEl so geometry matches (the listener already hears it) |
| rotate/translate combo lands wrong | individual transform properties apply in fixed order (translate→rotate→scale) — radial math needs the transform: shorthand |
| parallax rubber-bands | never 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 state | engine 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 sections | an 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).- Offscreen culling. Entries far outside a one-viewport margin skip the
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.
scrollvars/debug— the devtools story: a dev overlay listing every
import('scrollvars/debug').then(m => m.debug()).
- Honest numbers, mechanically enforced. The demo footer version and
- Benchmark: medians of 5 runs (was 3) and a precisely-stated headline
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.sv-range— sequenced scrub choreography without a timeline. Each
.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.
- Custom root scroller + configurable live band.
track(el, { root })
enter/exit options — also
data-sv-enter/data-sv-exit and <Track> props — replace the
hard-coded 75%/25% band.
- Reproducible benchmark.
demo/bench/harness(puppeteer-core + CDP)
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.
- The style-recalc curve, published — including where it loses.
?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.
- APG carousel contract for
<Slider>.role=region+
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.
- First React-layer tests —
renderToStaticMarkupin plain node (also
<Scenes> prop hygiene, inert marquee
duplicate. react/react-dom join as devDependencies.
- Human docs at /docs/: quickstart, the six variables, every export,
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:
- Nested scrollers now work: the scroll listener runs in the capture
- Travel/pin math uses the rendered box (
rect.height) instead of
scrollHeight — progress reaches 1 on fixed-height elements with
overflowing content.
onceis fire-and-forget: after going live, entries with no
getBoundingClientRect. --sv-view freezes at its last value.
scan()/toggles()no longer throw during SSR (default params were
Slider:
- RTL support: positions normalize to logical coordinates (0 → range
dir="rtl".
- Drag no longer eats clicks: a 5px movement threshold separates clicks
- Arrow keys typed into inputs inside slides no longer move the carousel.
- A glide interrupted by touch (or with
drag: false) no longer leaves
- The scripted glide respects
prefers-reduced-motion(jumps instead).
React:
<Slider ref>works — the forwarded handle is a stable proxy that
null).
<Scenes>honors its declared props: tracking options reach the
onScene/as/VarProps work, and nothing leaks to the DOM.
useSliderforwardsonScroll(it was typed but dropped).- Responsive
perViewbreakpoints emit in ascending order ({xl: 4, md: 2}
md win at desktop widths).
- Autoplay pauses while keyboard focus is inside the slider (WCAG 2.2.2)
- The Marquee duplicate is
inert, so its links aren't tabbable.
Canvas & pointer:
- Canvas re-applies its backing size when the monitor's DPR changes.
- Pointer tilt can't get stuck by a leave racing the queued rAF.
- Legacy-guard consistency: the canvas module uses the same optional
matchMedia listener calls as the driver.
Styles & packaging:
- Nested tracked sections: a live ancestor no longer reveals entrance
.sv that hasn't gone live (plain CSS3 selectors).
- Reduced motion on
sv-decklays cards out in flow instead of leaving
- The compat drift preset carries an
opacity: 1fallback wheremax()
sideEffectsglob is**/*.css—styles/*.cssimports survive
engines.node >= 18;pretestbuilds before testing;preparebuilds
npm i github:aduptive/scrollvars works).
- README: documented stagger default corrected to 90ms (matches the CSS).
1.6.0 — 2026-08-24
- The fx gallery (/fx/ on the demo site): a growing library of
scripts/fx-build.mjs); 8 seed effects.
- shadcn-style CLI:
npx scrollvars list/npx scrollvars add <slug>
1.5.0 — 2026-08-24
- React component kit:
<Slider>/<Slide>(the Swiper replacement:
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).
- CSS:
sv-colscolumn sizing (--sv-per-view, fractional = peek;
--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
sv-spread— the deck-to-grid pattern: children live in their final
--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
sv-acts— multi-act timelines in pure CSS: a registered custom
--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
- Click states — the third input:
toggles()(wired by ScrollVarsBoot) —
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.
- Presets (
styles/state.css):sv-pop— popover/dialog/panel entry-exit
@starting-style + allow-discrete; sv-words — rotating words
(clipped column, --sv-word: n), promoted from the pizza demo.
1.1.0 — 2026-08-23
- Modular stylesheets:
scrollvars/styles/core.css(1.2 KB gz —
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.
- README "Pay for what you use": measured per-import JS costs — a typical
1.0.0 — 2026-08-21
- API freeze. The 0.x surface ships as-is: driver (
track,scan,
slider, trackPointer, scrollToScene, refresh), React layer
(Track/Reveal/Parallax/Scenes/Item, ScrollVarsBoot, hooks, VarProps
attribute API), scrollvars/canvas, scrollvars/compat, styles.css presets.
SliderStatetype now exported from the root.- Packaging:
sideEffectsscoped to CSS (bundlers no longer risk
prepublishOnly runs build + tests. README gains a Defaults table.
- Demo tooling:
npm run demo:sync/demo:deploy(dist-inline sync with
0.12.0 — 2026-08-20
scrollvars/compat— opt-in legacy module: ResizeObserver stub
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
- Keyboard navigation glides: arrow keys (axis-aware), Home and End go
tabindex=0) so this
works in Safari too.
0.11.3 — 2026-08-20
- Mouse drag:
preventDefault()on pointerdown — native text selection was
0.11.2 — 2026-08-20
- Wheel-assist only reacts when the gesture's dominant axis matches the
goTo(nearest) and made multi-slide travel feel impossible. Quiet
window 160 → 200ms.
0.11.1 — 2026-08-20
- Mouse drag survives leaving the container: move/up listeners live on
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
- Slider state & chaining:
state()andonScroll(state)expose
--sv-progress; new seek(progress) for followers — two sliders
chain in one line (Swiper controller/thumbs, unidirectional).
axis: 'y': vertical sliders (.sv-slider-y), same API.- Size reference measured: this module 3.1 KB min / 1.4 KB gzip vs Swiper 11
0.10.3 — 2026-08-20
- Glide is now an exponential lerp (velocity ∝ remaining distance) instead of
duration calibrates the settle time.
0.10.2 — 2026-08-20
- Rapid
next()/prev()clicks accumulate: relative steps count from the
0.10.1 — 2026-08-20
- Drag release and trackpad pan now glide too: snap is suspended via inline
scroll-snap-type: none.
- Grab cursor only on draggable instances (
sv-draggable) —drag: false
0.10.0 — 2026-08-20
- Slider glide: own eased scrollLeft animation (ease-out) with a
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
slider()/useSlider— featherweight Swiper: native scroll +
--sd per slide (signed
distance from center) + .sv-active, next/prev/goTo. .sv-slider CSS
in styles.css.
- Demo case 10/11: regional scroll-snap (onLive toggles the magnet) and the
0.8.0 — 2026-08-20
onPincallback on TrackOptions and the React layer — raw 0..1 across
- Demo code samples audited against the current API:
--sv-yleftover fixed,
useScenes
misuse replaced by useTrack({ scenes }).
0.7.0 — 2026-08-19
- Zero-wrapper mode:
scan()tracks every[data-sv]element (options
data-sv-once/pin/travel/scenes) and follows DOM mutations;
<ScrollVarsBoot /> wraps it for Next.js layouts — pages stay 100% RSC.
- Presets promoted from the demo:
sv-deck(pinned card pile),
sv-reading (guided reading), sv-counter (@property + counter()).
- ESM-correct relative imports (
.jsextensions) — dist now runs in plain
0.6.0 — 2026-08-19
--sv-viewreformulated: now the signed position relative to the live
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.
LICENSEfile and this changelog.
0.5.0 — 2026-08-19
- Attribute API for React (Felipe's feedback):
order,distance,
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.
sv-railfixed on wide windows: starts one viewport offscreen right, ends
min() keeps it moving when the track fits).
0.4.0 — 2026-08-18
scrollvars/canvas:mountEffect/useCanvasEffect— lifecycle
AGENTS.md(agent-facing docs, shipped in the npm package) and
test/canvas.test.mjs.
0.3.0 — 2026-08-18
- Pointer module:
trackPointer/usePointerwrite--mx/--my
sv-tilt
preset (3D tilt + glare).
0.2.0 — 2026-08-17
- Vocabulary finalized:
--sv-view,--sv-t,--sv-pin,--sv-scene,
.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
- First cut: single global driver (one passive scroll listener + one rAF,