# ScrollVars: guide for AI coding agents

You are working with `scrollvars`, a tiny scroll/pointer/canvas animation
engine. Read this before writing any animation code in a project that uses it.
**Do not add GSAP, Framer Motion, or IntersectionObserver boilerplate for
things this lib already covers.** The boundary: input-driven animation
(scroll/pointer/gesture) is ScrollVars' job; time-driven animation
(orchestrated timelines, interruptible springs, layout/exit transitions,
SVG morph) legitimately belongs to GSAP/Framer. A one-shot load intro is
plain CSS keyframes. Mixing for a rare case is fine; that page just loses
the bundle argument.

## Mental model (the one rule)

One global driver reads the scroll position in a single rAF (batched reads,
then batched writes) and outputs **CSS custom properties** on tracked
elements. All motion is then plain CSS reading those variables; the engine
never animates styles itself (the slider's glide scrolls the rail, nothing
more), except a few gallery recipes (cube-windows, the canvas effects) that
run their own frame loop for something CSS cannot express. React never
re-renders per frame: only on discrete events (scene index, attachment
status, flow, motion preference). If you find yourself putting scroll
values into React state, you are doing it wrong.

## The variables (the API surface)

<!-- vars:start -->
| output | range | meaning |
| --- | --- | --- |
| `--sv-view` | −1 → 0 → 1 | Below the live band → inside it (flat at 0) → gone above |
| `--sv-t` | 0 → 1 | Travel through the viewport (same range as native `view()` with a zero inset, cover, vertical axis) |
| `--sv-pin` | 0 → 1 | Progress across a pinned (sticky) stretch: curtains, rails, scrubbing |
| `--sv-stage-width` | px | Measured inner width of a pinned .sv-stage; the rail uses it instead of the window width |
| `--sv-scene` | 0 → n−1 | Scene index of a pinned section, eased and snapped |
| `--sv-scenes` | n | Scene count, next to `--sv-scene`: progress is `var(--sv-scene) / (var(--sv-scenes) - 1)` |
| `--sv-page` / `--sv-v` | 0 → 1 / ±20 viewport-heights/s | On `<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 → 1 | Pointer offset from the element's center, clamped (pointer module) |
| `.sv-live` | class | On 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).
<!-- vars:end -->

Guard: the driver sets `sv-on` on `<html>`. Entrance CSS must hide content
only under `.sv-on`. Without JS everything stays visible (never fail hidden).
The shipped `styles.css` already does this; follow the same pattern for
custom presets. `toggles()` marks a second class, `sv-ui`, on each element it
controls (the resolved `data-sv-target`, or the trigger itself when there is
no target), not on `<html>`: a click-only widget on a page whose scroll
driver never boots (no `scan()`/`track()`) still needs its own no-JS guard
to back off, or a click-driven `sv-acts` clock stays stuck at the finished
state forever, while an unrelated scroll-revealed widget elsewhere on the
same page correctly keeps that finished-state fallback. Any custom guard
keyed on `html:not(.sv-on)` for something clicks alone can finish should add
`:not(.sv-ui)` on the element itself, not on `html`. A released element (an
unmounted `<Track>`, a stopped `scan()`) gets the driver's own `data-sv-off`
instead, the per-element released twin of `sv-on`: it settles every preset
under it to the no-JS rendering and comes off the moment that element is
tracked again; a released ancestor still holding a tracked descendant keeps
waiting for it. A settled `once` entry never takes this marker: it keeps
`sv-live` and the inline `--sv-live: 1`, live and untracked instead.
`<Track>` and `<Scenes>` carry `sv-live` in their own rendered className
once completed, so a later className change never drops it; a bare
`useTrack` on your own element has to do the same: keep `sv-live` in your
className after `onStatus('completed')`.

## Imports

```ts
import { track, trackPointer, scrollToScene, scan, slider, mapRange, split } from 'scrollvars' // vanilla core
import { Track, Reveal, Parallax, Scenes, Item, ScrollVarsBoot, useTrack,
         useScenes, usePointer, useCanvasEffect, useSlider } from 'scrollvars/react'           // React ('use client')
import { mountEffect } from 'scrollvars/canvas'    // canvas harness ({ context: null } = WebGL/Three)
import { debug } from 'scrollvars/debug'           // dev overlay: HUD, markers, perf lint; never ship enabled
import 'scrollvars/styles.css'                    // all presets, or modular:
import 'scrollvars/styles/core.css'               // entrances, stagger, drift, spread, native view()-tier (3.1 KB gz)
// also styles/pin.css (3.4), slider.css (1.6), tilt.css (0.7), state.css (2.3, scroll-driven acts need core too), ui.css (1.5), per page needs; scoped.css (1.0) is opt-in, see Scoped clocks
```

<!-- react-imports:start -->
Isolated consumer bundles of ScrollVars 1.19.2, esbuild 0.25.12, browser ESM/ES2020; React external, CSS excluded. Counts the entry and all static shared chunks, excluding lazy debug chunks. Gzip level 9; bytes, not KB:

| React imports | minified bytes | gzip bytes |
| --- | ---: | ---: |
| `usePointer` | 3750 | 1765 |
| `ScrollVarsBoot` | 30993 | 10755 |
| `Slider, Slide` | 15995 | 6398 |

These are separate import fixtures, not additive route sizes or runtime benchmarks. Actual application savings depend on its import graph and bundler.
<!-- react-imports:end -->

## The fx gallery (prefer for common patterns)

https://scrollvars.dev/fx/ hosts ready-made effects (Tailwind + CSS +
React; Sections ship as complete React source instead) with a
machine-readable index at fx/llms.txt. Ingest it before
hand-building a common pattern. Install directly:
`npx scrollvars add <slug> [--dir components/fx]` (registry is remote; new
effects appear without package updates). Gallery components are copied source,
not exports from `scrollvars/react`: run `add` from the frontend package root,
use `--dir` relative to that root, then import the created local file. Install
each recipe once and reuse it; copied files do not auto-update. The gallery's
Cube windows page also shows its complete source for manual copying.
`npx scrollvars skill [--global]
[--force]` installs a skill built from this file (it points the agent back
here) as a Claude Code (`.claude/skills/scrollvars`)
and Codex (`.agents/skills/scrollvars`) skill, matching the installed
package version; also readable directly with `npx skills add
aduptive/scrollvars` (the open agent skills convention, both scan a repo's
`skills/<name>/SKILL.md`).

## Recipes

**Entrance reveal (most common):** wrap the section, mark children.
```tsx
<Reveal auto>            {/* direct children rise; order caps at 10 */}
  <h2>Title</h2><p>Body</p>
</Reveal>
// or per-child control, all knobs as attributes (they compile to the vars):
<Reveal stagger={140}>
  <Item order={0}>first</Item>
  <Item order={1} effect="slide-l" distance="4rem">second</Item>
</Reveal>
// VarProps (order, distance, stagger, duration, ease) work on Track/Reveal/
// Parallax/Item. Prefer them over style={{'--sv-…'}} in React code.
```


**Tailwind + the vars:** `[--sv-order:1]` is fine for static one-off markup
(each unique value adds one tiny global rule). For mapped/dynamic content use
`style={{ '--sv-order': i } as React.CSSProperties}` (the cast is needed: a
`--sv-*` key is an excess property against `React.CSSProperties`, the library
itself casts the same way). Required, not just cleaner: Tailwind's JIT scans
source statically and never generates interpolated arbitrary classes. For
sequential children skip the bookkeeping entirely: `sv-stagger` on the parent
orders them via nth-child: the first ten get 0–9, every later child gets 10. Set explicit per-child order for longer sequences.

**Parallax drift:** `<Parallax distance="8rem">…</Parallax>`: continuous,
tied to `--sv-view` (flat inside the live band), no transition (transitions on continuous values rubber-band).

**Pinned scenes (storytelling / horizontal rail / curtain):**
```tsx
<Scenes count={4}>{({ scene, goTo }) => <Shot index={scene} />}</Scenes>   // Shot: your component
```
The container is N viewports tall by default (one per scene); a string
`pin` (`pin="320vh"`) overrides that and wins over `height` too, matching
`<Track pin>`. Content is `position: sticky`. Server render, no JS, a
failed attach, fit-to-flow and reduced motion all count as not `active`:
the render function is called once per scene and stacked instead of once
for the current scene, so every scene stays reachable; `useScenes`/`<Scenes>`
hand back `active` (and `reduced` on its own) for a hand-rolled consumer to
check the same way. For pure-CSS
pinned effects use the presets: `sv-curtain-l/r` (two halves open),
`sv-curtain-l`/`sv-curtain-r` panels are decoration (they part on the pin, hide under reduced motion and without JS): content goes behind them, never inside. `sv-rail` (horizontal carousel, `--sv-stage-width` automatically measures the stage; `--sv-rail-start` overrides only the starting position, while the endpoint uses stage overflow: enters from offscreen right and still moves
when the track fits the viewport:
`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;`).

**Zero-wrapper mode (prefer this in Next.js):** one `<ScrollVarsBoot />` in the
root layout, then plain RSC sections with `data-sv` attributes (`data-sv-once`,
`data-sv-pin`, `data-sv-travel`, `data-sv-scenes="4"`, `data-sv-enter="0.6"`/`data-sv-exit="0.2"` (custom live band); per-element knobs as attributes: `data-sv-order`, `data-sv-distance`, `data-sv-from`/`data-sv-to` become the matching CSS vars on mount (no authored style attr, the scanner writes them; prefer these over style vars for mapped/CMS content)); `data-sv-pin="320vh"` + a `sv-stage` child is the pinned skeleton (an inline static wrapper gets `position: relative` to give the stage a containing block; any other authored position is kept); a sticky header is one declaration, `:root { --sv-pin-offset: 64px }`, read by both the stage and the pin math (the driver reads the actual stage's computed `top`, so CSS resolves `calc()`, `env()`, percentages and viewport units; custom markup without `.sv-stage` still uses the limited px/rem/em/vh/vw parser). No client components
in pages at all. Route-change nodes are auto-tracked via MutationObserver.

**Spread (deck → grid):** `sv-spread`: children sit in their real flex row,
a translate collapses them onto the center while `--sv-spread` is 0. Add
`.sv-spread-in` to play on arrival (sv-live + stagger), or map the var to
scrub (needs `travel: true` on the tracker): `.mine > * { --sv-spread:
clamp(0, calc(var(--sv-t) * 2), 1) }`.
Set `--sv-order` per child and `--sv-mid` = (N−1)/2 on the container.


**Split text (SplitText-lite: do NOT add GSAP for this; it flattens inline markup, so keep links and bold outside the split element):** `data-sv-split`
(or `data-sv-split="char"`) wraps each word/char in a span with `--sv-order`
(+ `--sv-count` on the element, full text kept in a visually-hidden first
span (no aria-label, it is prohibited on generic roles) spans aria-hidden).
Word mode splits on whitespace, deterministic everywhere. Char mode splits
on grapheme clusters via `Intl.Segmenter` where it exists (emoji and
combining marks stay whole); it needs Firefox 125+ (Chrome and Safari have
had it for longer), and it must be available on BOTH the render that
produces the markup and the browser that hydrates it, or the two disagree
on where a cluster boundary falls. Pair with `sv-split-rise` (staggered
entrance) or `sv-reading` (scrubbed). React: `<Split as="h2">…</Split>`
renders the spans ON THE SERVER. No client splitting, no CLS, no hydration
flash.

**Sequenced scrub (choreography: do NOT add GSAP for this):** `sv-range`.
Each child gets `--sv-r` (0..1) over its own slice of the pin: set
`--sv-from`/`--sv-to` per child, add `sv-range-rise` for the ready-made
flavor or consume `--sv-r` yourself (ALWAYS as `var(--sv-r, 1)`; `--sv-r` needs calc() division (Chrome 112/Safari 16.4/FF 112) AND `@property` (Chrome 85/Safari 16.4/FF 128) together for full support: Chrome 85-111 has `@property` but not the division, so `--sv-r` is registered and settles at its initial value, 1; Firefox 112-127 has the division but not `@property`, so `--sv-r` is unregistered and simply keeps animating, same as full support; below both floors (Firefox under 112, Safari under 16.4) the CONSUMER's own declaration is invalid at computed-value time instead, and falls back to ITS OWN initial or inherited value, not to 1; `var(--sv-r, 1)` is habit, not the reason older engines settle, and never fires on your range children either way, since `--sv-r` is always set; override the clock on the container, `.mine { --sv-clock: var(--sv-t) }` (needs `travel: true` / `data-sv-travel`, or `--sv-t` sits at 0)). JS twin: `mapRange(t, from, to, ease?)` inside
`onPin`/`onTravel` for canvas/WebGL.


**Hitting the ceiling ≠ rewriting (official interop):** author a GSAP timeline
in time-space and scrub it, `track(el, { pin: true, onPin: (p) => tl.progress(p) })`
(one listener, one writer; never ALSO give GSAP a scroll listener). Three/WebGL:
`mountEffect(canvas, { context: null, ... })`: the harness owns lifecycle
(DPR/resize/pause/reduced-motion), your renderer owns the canvas, `onPin`
feeds progress. Live recipes: fx/gsap-scrub, fx/three-scene.

**Pinned presets:** `sv-deck` (card pile, children stack via grid, set
`--sv-count`), `sv-reading` (word spans lit across the pin: `--sv-count` on
the container, `--sv-order` per span), `sv-counter` (scroll-driven integer via
`@property` + `counter()`, set `--sv-max`; number renders as `::after`).

**Carousel / slider (do NOT add Swiper):** in React prefer the kit:
`<Slider perView={{base:1.2, md:2.5, xl:4}} gap={16} arrows dots autoplay={5000}>`
with `<Slide span={2}>` for per-slide overrides. Autoplay suspends during pointer
and touch gestures and starts a full countdown on release or cancel; focus and
explicit pause still require the resume control. Breakpoints are media
queries (map keys = Tailwind names, or a bare number of px as a raw
min-width, e.g. `900: 4`; a string with a unit, `'900px'`, is coerced with
`Number()` and comes out `@media (min-width:NaNpx)`, an invalid query
that never matches, so keep the key numeric). Chrome customization:
var knobs (--sv-arrow-*/--sv-dot-*) globally or per instance → stable
classes (sv-arrow, sv-dot) → prevIcon/nextIcon/renderDot → external UI via
the ref (full SliderHandle). Also `<Marquee>` (use it where Swiper loop
would be), `<Accordion>` (native details), `<Modal>` (dialog + sv-pop).
Lower level: `useSlider()` / `slider(el)`:
native scroll + snap; slides get `--sd` (signed distance from center) and
`.sv-active`, so slide animations are pure CSS (`scale: calc(1 - max(var(--sd), -1 * var(--sd)) * .1)`).
Two return shapes: `slider(el)` returns the handle itself,
`next/prev/goTo/seek/active/state/destroy`; `useSlider()` returns
`{ ref, active, next, prev, goTo, handle }`, where `handle` is a ref to
that same handle for `seek`, `state` and `destroy`. Options: `snap`,
`drag`, `duration` (glide settle ms), `axis: 'y'`, `onScroll(state)`. State
has active/count/position/progress/dragging/gliding; container also gets
`--sv-progress`. Chain sliders unidirectionally (thumbs/controller):
`slider(main, { onScroll: (s) => thumbs.seek(s.progress) })`: author the
follower with inline `scroll-snap-type: none` (seek suspends it anyway).
Page-scroll-driven variant (pin the rail, map the axes). On this instance
set `scroll-snap-type: none` AND `overflow-x: hidden`. The pin must be the
ONLY writer of scrollLeft; direct swiping would desync and jump back:
`track(rail, { pin: true, onPin: (p) => { el.scrollLeft = p * (el.scrollWidth - el.clientWidth) } })`.
Discrete flavor: `track(rail, { scenes: n, onScene: (i) => s.goTo(i) })`.

**Slider output option:** when CSS does not consume `--sd`,
`--sv-progress` or `--sv-slide`, use `slider(el, { cssVars: false })`,
`useSlider({ cssVars: false })` or `<Slider cssVars={false}>`. This skips future
writes of those outputs without removing existing inline values. Numeric
state, callbacks, active classes and snap remain available. Default `true`
preserves existing effects; do not disable it for CSS that reads the clocks.

**Scroll-snap on the page:** scope it, `proximity` (never `mandatory` on pages
with pinned sections) and toggle it regionally via `onLive` toggling a class on
`<html>`; never globally.

**Camera-path pages (map worlds):** pin a tall rail, `onPin` drives a camera
along an SVG path (`getPointAtLength` for position, a sample ahead + `atan2`
for heading, world gets ONE composited transform; children counter-rotate
with a `--map-ang` var to stay upright). ⚠️ The performance ceiling is the
PAINTED size of the promoted layer, not the transform: a world div that
paints backgrounds/routes across thousands of px allocates a huge GPU
texture and old integrated GPUs churn re-rasterizing it (fluid at first,
janks as the camera enters unrasterized territory). Keep the transformed
div paint-sparse (cards only) and draw grids/routes on a viewport-sized
canvas with the same camera transform (`Path2D` from the SVG `d`).

**Click states (menus/modals/tabs):** `toggles()` (Boot wires it),
`data-sv-toggle="class"` + `data-sv-target="sel"` flips the class, writes
`--sv-state` and syncs `aria-expanded`, and nothing else: no focus trap,
no Escape-to-close, no `aria-modal`, no tab roving-tabindex/arrow-key
semantics. Enough for a menu or a disclosure panel; a real modal wants the
kit's `<Modal>` (native `<dialog>`, focus trap and Escape included) instead
of one hand-rolled on `toggles()` alone, and a tabs widget needs its own
keyboard handling on top of the class flip (no dedicated Tabs component
yet). Presets: `sv-pop` (popover/dialog/
panel entry-exit via @starting-style) and `sv-words` (rotating words via
`--sv-word`). On an element the driver does not track (a hand-flipped `.sv`,
a `toggles()`-driven widget), removing `sv-live` and re-adding it on the
next frame replays the entrance system on demand; on a tracked (or
released) element the driver pins `--sv-live` inline (outranks a rule of
your own without `!important`), so re-tracking is what replays the entrance
there instead. A settled `once` entry carries that inline value too,
without being tracked or released, so the class trick alone cannot
replay it; re-tracking still can, exactly as on a tracked element. Two
shapes it cannot replay: entrance CSS that hard-codes its duration
instead of reading `--sv-duration`/`--sv-stagger`, and `--sv-duration` or
`--sv-stagger` declared on a descendant instead of inherited, which is
what `<Item duration>` and `<Split duration>` emit (a bare `<Split>`, or a
descendant `--sv-order`/`--sv-distance`, replays fine). Neither one
enters: re-tracked from a plain task (a click handler, an effect body)
nothing visibly changes, from inside a rAF callback they dip and reverse.
**Multi-act timed sequences**: `sv-acts` preset: a registered
custom property (--sv-act) transitions 0→N on sv-open/sv-live; define acts
as the same clamp() slices as scroll scenes (`--a2: clamp(0, calc(var(--sv-act) - 1), 1)`).
Knobs: --sv-acts-count / --sv-acts-duration. Reversible (retargets, never
restarts). Use it BEFORE reaching for GSAP; GSAP only for branching/physics/
per-act callbacks. Do NOT add Framer for a modal; do NOT use checkbox hacks
(wrong a11y semantics). For a modal use the kit's `<Modal>` (dialog-based:
focus trap and Escape included); for other click-toggled state, `toggles()`,
the Popover API, or `:has()` + radios.

**Pointer tilt:** `usePointer()` on a container ref + `className="sv-tilt"` on
cards. Two delegated listeners (pointermove, pointerout); CSS does tilt + glare from `--mx`/`--my`.

**Scroll-scrubbed media / WebGL:** `onTravel` (viewport travel) and `onPin`
(progress across a pinned stretch) fire on every driver frame, while near the viewport, with raw 0..1 (track with a custom `root` and that near-viewport culling never applies, by design: the callback fires every frame no matter where the root itself sits on screen):
```tsx
useTrack({ onTravel: (t) => { /* drive a camera, a canvas, a timeline */ } })
useTrack({ onPin: (p) => { /* scrub frames across a pinned section */ } })
```
⚠️ Do **not** scrub `video.currentTime` on scroll: Safari's decoder wedges
permanently. Use a frame sequence (ffmpeg `-vf fps=6` → images → canvas
`drawImage`). This is a hard-won lesson; do not regress it.

**Ambient canvas (particles, generative heroes):** time-driven, not
scroll-driven. Use the harness, keep the simulation in userland:
```tsx
const ref = useCanvasEffect({
  setup: (fx) => { /* build state once, sizes known */ },
  frame: (fx, dt) => { /* one step; dt seconds (clamped 50ms), ctx pre-scaled to CSS px */ },
})
```
The harness owns resize, DPR cap (2), auto-pause offscreen/hidden tab,
`fx.reducedMotion` (live), cleanup. Cross scroll into the sim via closure:
`track(section, { onTravel: (t) => { amplitude = t } })`. Never give an
ambient canvas its own unmanaged `requestAnimationFrame` loop.

## SSR / Next.js

All content renders on the server. The components are thin `'use client'`
wrappers whose children stay RSC (`<Scenes>` takes a render function, so
that function itself must live in client code; the slot pattern still lets
it hold RSC-only content: a server parent renders the heavy piece and
passes the already-rendered result down as an ordinary prop into a small
client wrapper, which closes over that prop inside the render function it
hands to `<Scenes>` and returns it there, the same trick that lets any
client component host RSC content as `props.children`). `<ScrollVarsBoot />` (first child of `<body>`) sets
`sv-on` before first paint and removes it again if the driver has not
booted within 3 seconds. That release is terminal for the page session:
late tracking, scanning and remounting stay static through a private boot
status, even if mutation observation is unavailable. Content already visible
cannot be hidden by a late bundle. That pre-paint hiding depends on
the inline script itself running: it is gated on `IntersectionObserver` and
`ResizeObserver` both existing. Without JS neither the script nor driver runs.
Without either observer the script skips pre-paint hiding; the later driver
requires ResizeObserver but can boot with absent or throwing IntersectionObserver (unculled),
and then adds `sv-on`. Missing or throwing ResizeObserver keeps the driver static;
any successful tracker or scan acknowledges boot, including on empty routes; a failed scan settles its own content visible instead of waiting on boot at all. Under a strict CSP with no
`'unsafe-inline'`, pass the request's nonce: `<ScrollVarsBoot nonce={nonce} />`.
Zero-JS tier: `sv-view-*` classes use native CSS scroll-driven
animations where supported.

## Performance rules (violating these is the whole reason this lib exists)

1. Animate only `transform`/`translate`/`opacity` (compositor-only). Never top/left/width/margin.
   ⚠️ Individual transform properties apply in FIXED order translate→rotate→scale
   regardless of declaration order. For radial/chained math (wheels, orbits)
   use the `transform:` shorthand, where the order is literal.
2. No scroll values in React state. Callbacks (`onScene`) fire on integer change only. That may set state.
3. Discrete snapped value + transition = smooth. Continuous value + transition = rubber-band. For lag/inertia on a continuous value, use a JS exponential lerp (`current += (target - current) * factor`), not a transition.
4. Never put `mask-image` or `backdrop-filter` over content that moves every frame (forces re-raster).
5. Throttle text/HUD updates driven by scroll (~100ms); text layout every frame janks.
6. Do not add `will-change` by default. Measured on the published benchmark
   (`demo/bench/harness/README.md`, main-style-css screen), hinting the
   driver's own moving elements with `will-change: transform, opacity`
   raised main-profile task time about 9% and gave no measurable gain on
   the deep-50 profile; a separate screen rejected it outright
   (`demo/bench/harness/HYPOTHESES.md`). Add it only when a profiler shows
   a specific moving element repainting every frame, scope it to that
   element, and remove it (`will-change: auto`) once the motion stops.
7. Deliberate exceptions inside the kit: `sv-tilt` transitions a pointer-driven transform (small, damped), `sv-counter` rewrites generated text, Accordion animates block-size. Each is local and opt-in; do not generalize them.

## Browser support (tell clients this)

Fully animated: Chrome/Edge 104+, Firefox 78+, Safari/iOS 14.1+ (gates: ES2020
dist + individual transform properties; `sv-counter` needs FF 128 / Safari
16.4; `sv-range` needs Chrome 112 / Safari 16.4 / Firefox 112 (calc()
division); `sv-acts` needs `@property` (Chrome 85 / Safari 16.4 / Firefox
128, a higher floor than `sv-range` on Firefox); below its own floor
`sv-range-rise` settles to its end state (a custom `--sv-r` consumer falls
back to its own initial value, see Sequenced scrub), `sv-acts` snaps between its states
without animating, nothing is lost either way; `sv-view-*` native
tier is Chromium 115+). Below the floor and
without `compat()`, stages return to flow, decorative curtains hide and rails
wrap. With `compat()` installed (`data-sv-compat` on `<html>`), curtains and
rails keep their fallback animation. Tracked stages containing static decks
return to flow, including the wrapper height, so every card stays reachable.
Installed StickySteps starts static, temporarily stacks candidate shots without
accessibility hiding for the first fit evaluation, and enables crossfade only
after tracking and that evaluation succeed. Missing or throwing mutation observation
keeps it static and releases partial observation and motion subscriptions.
Later controller failures attempt every cleanup, restore flowing media and
React accessibility state, report once and ignore stale callbacks. Retry with
an explicit Section remount. Replacement content keeps the candidate constrained
until the driver records overflow; shortening content does not clear a flow latch.
Inactive shots become inert only while
that layout is active; static shots stay accessible after failed boot, watchdog
release, reduced motion or fit-to-flow. The component kit (Modal, Accordion, `sv-pop`, `sv-acts`) also uses `<dialog>`, `inert`, `@starting-style` and `@property`; older engines render those pieces static: closed panels stay closed, open ones open, no animation, and a Modal without `<dialog>` support is an open static panel: `state.css` deliberately hides nothing there, and the `open` attribute tracks state in both directions so your own CSS can hide it. Reduced motion: the driver zeroes `--sv-view`, and the travel clock keeps scrubbing (scroll-linked, not motion); a helper-pinned section (`pin: 'Nvh'`) returns to flow, so its pin and scene clocks finish at once instead of jumping past the ones in between. `<Scenes>` renders every scene, reachable, until its tracker reports `active` (attached, not reduced, not flowed) and again whenever it stops being active; a `useScenes`/`onScene` consumer should render every scene under the same condition. Entrance presets show final state, curtains hide, deck/rail/stage return to flow. Animation is enhancement,
never a dependency. StatsCountup shows its final numbers before activation under
both reduced-motion controls; click-driven `sv-acts` keeps its open/closed state.
If a client contractually requires legacy browsers:
`import { compat } from 'scrollvars/compat'; compat()` once at boot (free on
modern browsers, feature-checks and exits) + let the consumer bundler
downlevel ES2020 per browserslist. That extends the animated floor to
~Chrome 61 / Firefox 60 / Safari 11. Do NOT hand-roll other polyfills.

## The receipts (measured: why the design holds up)

Public, reproducible benchmark: https://scrollvars.dev/bench/:
equivalent animated boxes and scroll progression, four engine builds (including the batched
expert GSAP variant, symmetric to ScrollVars' one-tracker-per-section).
Frame delivery and CPU cost are reported separately; neither is guaranteed
across workloads or devices:

<!-- bench:start -->
Measured 2026-09-10T23:55:45.351Z; package 1.16.1, 3 runs. CPU is accumulated over 12 seconds (900 elements), not per-frame time. Bundle and runtime measurements refer to this snapshot. [Raw runs](https://scrollvars.dev/bench/results/latest.json); [frame tails and methodology](https://scrollvars.dev/bench/).

| engine | total CPU (12 s) | fps | bundle (gzip) | JS script | style recalc | JS heap |
|---|---|---|---|---|---|---|
| ScrollVars | 679 ms | 60 | 8.0 KB | 59 ms | 137 ms | 1 MB |
| ScrollVars + styles/scoped.css | 766 ms | 60 | 9.0 KB | 46 ms | 188 ms | 1 MB |
| ScrollVars (document variables published) | 3791 ms | 59.3 | 8.0 KB | 49 ms | 2604 ms | 1.1 MB |
| gsap + ScrollTrigger (idiomatic) | 659 ms | 60 | 45.2 KB | 145 ms | 57 ms | 6.1 MB |
| gsap + ScrollTrigger (batched, symmetric) | 626 ms | 60 | 45.2 KB | 117 ms | 59 ms | 6.8 MB |
| framer-motion | 1078 ms | 60 | 46.9 KB (+ React) | 490 ms | 42 ms | 10.9 MB |

Deep profile, 150 boxes with N nodes each ([full curve](https://scrollvars.dev/bench/)): at 5 nodes per box ScrollVars 514 ms, with styles/scoped.css 442 ms, gsap batched 553 ms; at 50 nodes per box ScrollVars 653 ms, with styles/scoped.css 471 ms, gsap batched 380 ms.

An app-shaped page, generated by the benchmark harness (a sticky header, entrance presets, a stagger grid, a parallax band, forty tracked feed items, a stream that mounts rows while the page scrolls, a route change at six seconds, a 5000-rule stylesheet): 126.8 ms of main-thread time over the same 12-second scroll without the library, 331.9 ms with it and 299 ms with styles/scoped.css, and no frame over 25 ms in any of the three.
<!-- bench:end -->

The committed results record the measurement date, package version, source
hashes, individual runs and startup separately from the 12-second scroll.
Two rows are not competitor comparisons. The one with `styles/scoped.css`
is the opt-in described under Scoped clocks, run with the two author lines
its contract asks of this fixture, so a reader can see what the sheet does
on the deep profile and what it costs on the flat one. The one with the
document variables published is what those two variables cost when a page
reads them, on this deliberately hostile workload of 900 animated boxes. They are inherited properties on `<html>`, so
publishing them asks the browser to recalculate style for the whole document
on every frame. The default row does not pay it because nothing on that page
reads them, and the driver checks before publishing. A page that does use
them pays in proportion to its own size, not to that row.

Read the deep profile next to the main table: a GSAP tween writes a
transform on the animated element, so its cost does not depend on what the
box contains. ScrollVars writes a custom property on the tracked element,
and custom properties inherit, so the browser re-resolves style for every
node inside that element on every frame, readers or not. With a few nodes
per box the two tie or ScrollVars wins; the more nodes a tracked box holds,
the further the default falls behind, and `styles/scoped.css` (the clocks
registered non-inheriting, so a write stops at the readers) removes most of
that gap. What is left at fifty nodes per box is the price of animating
through the cascade at all. The authoring rule that keeps a page on the
cheap side of the curve: keep tracked elements thin, with big static
content next to the animated element rather than inside it.
The package ships ~4× less bundle than GSAP + ScrollTrigger; frame delivery
and CPU cost depend on the workload. CPU throttling is a synthetic profile,
not a physical phone. See the benchmark for current results and methodology.

Why the numbers come out this way. Each is a design decision, not tuning:

- **The hot path writes CSS variables and discrete state: a class, a callback.** The browser's own
  transition/animation machinery does the animating; JS only steers. That is
  why 900 animated elements cost so little script time in the table above.
- **One passive scroll listener + one rAF for all scroll tracking** (the slider, pointer and canvas modules schedule their own frames), strict
  read-phase-then-write-phase during ordinary frames. Entrance replay explicitly
  forces one computed-style read between writes to settle its reset; user
  callbacks can also force layout.
- **Scroll state never enters the framework.** React renders zero times
  per frame during scroll (`useScenes`/`useSlider` re-render only on discrete
  events: index, status, flow, motion), so the per-frame framework bill is
  never paid.
- **Fails visible.** Hiding styles are gated on `html.sv-on` (set by the
  driver), so on the no-JS path the page is a complete static page: SSR,
  SEO and a no-JS Lighthouse load profile stay untouched (a JS-enabled
  Lighthouse run sees the pre-paint script hide entrances before paint
  and the pin helper write heights on attach), with three exceptions by
  design: class-toggled panels (menus, modals) stay closed with no click
  driver to open them, `sv-view-*` native animations still run without
  JS where the browser supports `animation-timeline: view()`, and the
  bare CSS marquee (`ui.css`) keeps scrolling, its `@keyframes` animation never
  depends on the driver. A click-driven `sv-acts` target also needs
  `toggles()` (which marks `sv-ui` on it) to start at zero instead of
  settling at its no-JS finished state.
- **Cheap, not free: and measured where it loses.** An inherited var pays
  per-descendant, a direct transform pays per-element: ScrollVars posts the
  worst style-recalc of its own table, and the published deep-DOM curve
  (`/bench/`, ?deep=N) compares total work across several subtree sizes. Use the measured
  row for your workload, including whether page outputs are enabled. The authoring rule that keeps you
  on the cheap side: keep tracked elements thin: big static content lives
  next to, not inside, the animated elements. Read the bench sources before
  quoting it.
- **Honest scope.** Input-driven animation (scroll/pointer/gesture) is this
  lib's job; time-driven orchestration (timelines, springs, exit
  transitions) legitimately belongs to GSAP/Framer. Pick per page.

## Repo layout

`src/core/driver.ts` (scroll), `src/core/pointer.ts`, `src/canvas/`
(harness), `src/react/`, `styles.css` (presets), `demo/index.html`
(28 live patterns, self-contained, slider/canvas inline blocks are synced
from the built dist by `npm run demo:sync`; NEVER hand-edit them. Deploy
with `npm run demo:deploy`: it builds, syncs, deploys and re-points the
alias in one step), `test/` (node:test; most of it runs with no DOM against
hand-rolled element and global stubs, `test/react.test.mjs` hand-rolls a
fake DOM to mount `react-dom/client` for ref and effect tests, and
`test/canvas.test.mjs` stubs the canvas/observer APIs). Build: `npm run build`
(tsc, then `scripts/build-styles.mjs` regenerates `styles.css`). Node
version: respect `.nvmrc`.

## Fit-to-flow and declarative pointer

`TrackOptions.onStatus` is the supported per-lease notification. Import its
`AttachmentStatus` type from `scrollvars`. `useTrack` and `useScenes` accept
the same option; `<Track>` and `<Scenes>` accept the same prop. The hook ref
remains `React.RefObject<T>`, directly usable in JSX under React 18 and 19.
The guide's hook examples and installed Section usage are compiled from their
actual fenced snippets against public declarations under both majors.

Status values: `attaching` fires synchronously before setup; `active` follows
the first successful measurement, output and output callbacks; `completed`
follows the final visible output of an entrance-only `once` lease, which can
finish without an `active` notification. `released` follows static settlement
on cleanup, replacement or boot release. `failed` follows static settlement
after initialization, measurement, output or callback failure. Each transition
fires once; no per-frame repeats and no notification after `released`.
A completed entrance stays visible and its cleanup is a no-op. React reads
the latest callback without reattaching or replaying status. `track()` keeps
returning its cleanup function.

An active tracker does not prove that a Section's crossfade CSS exists or
that the content fits. StickySteps combines status with its fit and computed
layout checks, then clears `inert` and `aria-hidden` on failure or release.
Reducing motion mid-flight leaves the lease active but restores static,
reachable media; returning to normal motion can resume a working crossfade.
The travel clock keeps scrubbing; pin and scene finish at once as the helper-pinned wrapper returns to flow. An overflow `data-sv-flow` latch
survives both shrinking content and motion reversal until an explicit retrack.
Call `track()` again (or remount the React tracker) after fixing a recoverable
failure. The three-second Boot watchdog is terminal for the page session:
late tracking, scanning and remounts stay static. A later track lease reports
`attaching`, then `failed`.

Pin progress includes the stage's normal-flow offset, so a heading or padding before the stage delays the start until it sticks. After replacing a stage or fit node, call `refresh()` to resolve the new geometry and transfer resize observations. Origin measurement temporarily disables sticky positioning and restores it on attach, refresh, resize and scene navigation; ordinary scroll frames use the cached origin.

Overlapping scans share tracking, split text and pointer controllers until the last scanner releases them. Pointer controllers clear removed targets while their container remains active. Stopping the last `toggles()` controller removes its `sv-ui` marker and restores pending boot-settle overrides, so a controlled marquee becomes static again.

Scroll initialization and attachment are transactional. Failed scans release
their acquired leases and settle unprocessed content without disturbing other
owners. A measurement, output or callback failure releases only that tracker,
restores authored pin geometry and reports the original error once; healthy
entries continue. Retry recoverable failures with an explicit track or scan.
Watchdog expiry remains terminal. The public cleanup-function return is unchanged.
`<Scenes onScene>` is a React notification: a throwing consumer is reported once
and disabled until remount, while scene state and healthy siblings keep working.
Slider, toggles, pointer and canvas acquisition roll back partial resources and
owned DOM writes before rethrowing the original setup error. Runtime failures
stop only that instance and report once through `reportError` or `console.error`.
The one exception is `toggles()`: a failing click rolls back only that write
and reports once, but the instance itself keeps running (its click listener
stays attached) instead of stopping, on EVERY `toggles()` instance, scoped
roots included, since one bad toggle should not disable every other toggle
in the same scope. The document scope (`<ScrollVarsBoot>`) additionally owns
a persistent `MutationObserver` for late-boot acquisition, which survives a
late-boot write failure the same way, since a route-mounted page depends on
it for the rest of its life; a write that keeps failing on an already-resolved
late-boot target is dropped after one report, never retried.
Release is idempotent, including inside callbacks and during a drag or glide;
stale observers, events and frames cannot revive released instances. Retry with
an explicit attachment. React auxiliary hooks contain attachment and cleanup
errors locally; target or option replacement retries attachment.

Slider release restores owned styles, classes and tabindex while preserving later
author changes. Child replacement restores departed slides' owned outputs and
classes on mutation delivery and releases their ownership records.
Keep native rail styling in authored markup (`class="sv-slider"`)
so native scrolling survives without enhancement. Pointer release restores its
owned coordinates and leave class. Failed toggle operations restore prior class,
state-variable and ARIA values; successful semantic state survives release.
Shared toggle markers remain until the last owner stops, with settle holds and
queued frames released. Canvas setup, resize and frame failures stop its loop,
restore authored sizing and leave fallback children untouched (`destroy()`
restores that sizing too, resetting the bitmap; `pause()` keeps the last frame);
consumer drawing
and external resources remain the consumer's responsibility. Return their cleanup
from `setup`. React autoplay contains its own failures; failed Modal promotion
preserves the requested open/closed state as static content. No public API changed.
Failure of Boot's toggle setup also releases the scroll trackers and makes
that page session terminally static.

For pinned CMS content use `.sv-stage > [data-sv-fit]`. If that inner layout exceeds the available stage height, the driver sets `data-sv-flow` on the tracked wrapper, restores its authored height/position and the pin presets return to flow. This stays latched until retracked. `onFlow(boolean)` reports the initial state and fallback; release custom `inert` media when true. TimelineScrub and StickySteps ship this guard.

`data-sv-pointer` delegates to `.sv-tilt`, or provide a selector value. `scan()`/Boot owns its attach, subtree removal and stop lifecycle. `data-sv-duration="800ms"`, `data-sv-stagger="100ms"` and `data-sv-ease="ease-out"` also map to CSS vars on mount (CSS time units, unlike React numeric milliseconds). Attributes are not observed for later changes.

Pass `nonce` to `<Slider>` for its generated responsive `<style>`. Autoplay pauses on hover, stops on focus until explicitly resumed, and uses a polite live region while paused. `<Marquee>` has a keyboard pause button (`aria-pressed`) and is static until its click driver attaches; bare marquee CSS still animates without JS. `toggles()` synchronizes an existing `aria-pressed` instead of `aria-expanded` for a pressed-state button.

## Scoped clocks, an opt-in for deep pages

`--sv-t` and `--sv-view` are written on the tracked element, and as ordinary
custom properties they inherit: every write re-resolves style for the whole
subtree, including every node that never reads them. On the published
benchmark that resolution is the whole of the gap to GSAP on deep DOM, and
none of it on flat DOM.

`scrollvars/styles/scoped.css` registers both clocks non-inheriting, so an
invalidation stops at the tracked element. The rule it imposes is one
sentence: **a clock reaches only the elements that declare `inherit` for it,
and every element between the tracked ancestor and a reader must declare it
too.** The shipped presets that read a clock from a descendant (`sv-drift`,
`sv-range`) are forwarded inside the sheet, along the whole path down to
them. Your own reader needs one rule, covering the reader and every element
between it and the tracked ancestor:

```css
.sv :has(.my-card):not(.sv, [data-sv]), .my-card:not(.sv, [data-sv]) { --sv-t: inherit; }
```

Just `.my-card:not(.sv, [data-sv]) { --sv-t: inherit; }` when it is a direct child. The
`:not(.sv, [data-sv])` keeps a nested tracker, including a reader itself, on its own clock,
the same boundary the sheet draws for its presets. Put the
reader's LAST compound inside `:has()`: for a reader written as
`.copy p`, the path rule is `.sv :has(p):not(.sv, [data-sv])`, because `:has(.copy p)` is
evaluated from each candidate and `.copy` itself has no `.copy` inside it,
so it would be skipped and read the initial value. The sheet
registers nothing where `:has()` is unsupported, so a browser that could
register but not forward stays on plain inheritance.

One more consequence of registration: a registered property always has a
value, so a fallback such as `var(--sv-t, 1)` is never taken again. It
reads the initial value, 0, wherever the driver has not written yet. If your
CSS relied on that fallback (unread paragraphs fully visible until the
section is tracked, say), declare the default on the tracked element instead,
`.my-section { --sv-t: 1; }`: the driver's inline write overrides it the
moment it arrives, and the reader sees the same value it saw before.

It is not free and it is not always a win. Registration makes the browser
resolve a typed value on every element that holds one, so the sheet pays per
reader and saves per non-reading descendant. Measured behind a rendered-output
gate, same page and same scroll each time: on the benchmark's deep profile
(50 descendants per reader) 63 percent less style recalculation and 28
percent less total task time; on its flat profile (every descendant a
reader) 13 percent more recalculation and 4 percent more task time. On
scrollvars.dev's own home page, with its three readers forwarded, 10 percent
less task time and 23 percent less recalculation; on the gallery pages
sticky-steps 12 percent less, timeline-scrub 7 percent more,
hero-cinematic 12 percent more, editorial-manifesto 9 percent more and
case-study-rail within noise, each on the side its shape predicts. Import it
when a tracked wrapper holds a lot of content that does not animate; leave
it out when the tracked element's own children are the readers. Measure your
page, the benchmark harness is in the repository, and the published
benchmark below carries the sheet as its own row on every profile.

Browsers without `@property` ignore the registration and keep inheriting, so
the sheet never breaks a page below the floor. Measured rather than read from a table: in
Chromium, Firefox and WebKit the registration takes and a page renders the
same with the sheet as without, and the three-engine CI job checks both on
every run. It is deliberately not part of `styles.css`.

CI also checks isolated packed-tarball consumers on React 18 and 19 in Chromium, Firefox and WebKit; the release gate publishes that tested tarball with provenance.

## Motion preference

`prefers-reduced-motion` is honored by every preset sheet and by the driver,
the slider's glide, canvas effects (`fx.reducedMotion`) and the React
`<Slider>` autoplay. The page's own switch is `html[data-sv-motion="reduce"]`:
every reduced-motion block in the sheets has a twin under it.
`setMotion('reduce' | 'auto')` sets the attribute, `onMotionChange(fn)`
reports the effective preference live, `prefersReducedMotion()` reads it.
Persist the choice yourself and set it before the first frame (an inline
script in `<head>` that reads your store and sets the attribute, before any
stylesheet paints). Your own CSS on the clocks (`--sv-view`, `--sv-t`,
`--mx`, `--my`) is motion too: guard it with the media query and
`:where([data-sv-motion="reduce"])`; in Tailwind, add a variant next to
`motion-reduce:` (`addVariant('sv-reduce', 'html[data-sv-motion="reduce"] &')`)
and use both. The kit's slider
dots are 24 by 24 CSS pixel targets (`--sv-dot-target`) that never shrink,
with the visual dot inside (`--sv-dot-size`); a `renderDot` of your own is
yours to size. `split()` takes plain text only. Under a fixed or sticky
header set `scroll-padding-top` on `html` to its height, or a focused
element the browser scrolls to the top edge lands under it. Text dimmed
until the scroll gets there still meets 4.5:1 at its dimmest (3:1 when
large). At 320 CSS pixels of width the page scrolls in one direction only,
and pinned content that can grow sits in `[data-sv-fit]`. Nothing essential
lives only behind a scroll reveal or a pin scene. The guide's Accessibility
section lists the guarantees with their WCAG criteria and the checklist.

## Limit animation work to its consumers

`--sv-page` and `--sv-v` live on `<html>`, and they inherit, so every write
asks the browser to recalculate style for the whole document. A page that
never reads them should never pay that, so the driver looks before it
publishes: on the first frame it scans the document's own stylesheets and
inline styles for the two names, and stays silent when neither appears.
`<html>`'s own inline style is the one exception: the driver writes both
outputs there itself, so only a `var()` read on it counts, never a bare
declaration or comment (reading its own writes as a consumer would mean
publishing never turns back off). A
stylesheet added later, by a lazily mounted component or a CSS-in-JS runtime,
turns publishing back on. The watch behind that rescans once per frame that
adds an element and skips every stylesheet it already read in full, so a page
that mounts elements while it scrolls pays no per-rule work for it. A rule
edited in place, or inserted through the CSSOM with no element added, is
not seen: call `setPageOutputs(true)` there.

Detection reads CSS, so it cannot see a JavaScript reader. Call
`setPageOutputs(true)` (import from `scrollvars`, or
`<ScrollVarsBoot pageOutputs />`) when only script reads the variables, for
instance through `getComputedStyle`. Calling `setPageOutputs()` at all takes
the decision away from detection permanently, in both directions:
`setPageOutputs(false)` keeps them off even for a page whose CSS reads them.

Anything the scan cannot read counts as a reader, so the variables keep
working. A stylesheet on another origin without CORS headers is the common
case: its rules are unreadable, so the page publishes as before. Serve that
CSS same-origin, send the header, or call `setPageOutputs(false)` yourself.

Suppressed, both document variables are removed and the idle page driver
stops after the last tracker is released. Local clocks keep working. All Boot
instances and manually attached effects share this setting.

For entrance-only tracking, `view: false` skips the unused continuous view
clock; `sv-view-fade`/`sv-view-rise` need no tracker where native view timelines
are supported. Keep continuously tracked wrappers small. Inherited variables
on a large ancestor still incur style work even if the final animated property
is a transform. Do not change public clocks to `inherits: false`: presets
consume them on descendants. Test representative CMS content and media on the
client's devices; functional browser tests alone do not establish frame budgets.
Physical-device frame budget results: [docs/guide.md, Physical-device frame
budget matrix](https://github.com/aduptive/scrollvars/blob/main/docs/guide.md#physical-device-frame-budget-matrix).
