# ScrollVars guide

[Quick start](../README.md) · [Online docs](https://scrollvars.dev/docs/) · [FX gallery](https://scrollvars.dev/fx/)

## Contents

- [Installation and styles](#install)
- [Bundle sizes](#pay-for-what-you-use)
- [Variables and presets](#mental-model)
- [React and Next.js](#react)
- [Component kit](#the-component-kit-react)
- [Slider](#slider-swiper-featherweight)
- [Canvas](#canvas-effects-scrollvarscanvas)
- [Defaults](#defaults-the-tuned-knobs)
- [Browser support](#browser-support)
- [Scoped clocks](#scoped-clocks-an-opt-in-for-deep-pages)
- [Accessibility](#accessibility)
- [Page outputs](#limit-animation-work-to-its-consumers)

![scrollvars: words arriving one by one on scroll](https://scrollvars.dev/media/readme.gif)


Tiny scroll-driven animation engine for the web: **one rAF loop in, CSS variables out.** Zero dependencies, React layer optional. Measured (JS min+gzip, CSS gzip as shipped): driver 5.7 KB, full core incl. the slider 12.9 KB, styles 10.5 KB for every preset or 3.1 KB for the core part. A typical page ships ~8.8 KB on the wire.

## Why

ScrollVars keeps continuous scroll values outside React and exposes them directly to CSS:

- **One global driver**: a single passive scroll listener, one rAF for all scroll tracking; slider, pointer and canvas schedule their own.
- **Batched read → write phases**. All rects first, all CSS variables after.
- **No framework in the hot path**, React renders zero times per frame
  during scroll (`useScenes`/`useSlider` re-render only on discrete events:
  index, status, flow, motion).
- **Fails visible**: hiding styles are gated on `html.sv-on` (set by the driver), so if JS never loads the page is a normal static page.
- **Reduced motion built into every preset. Accessibility checked in
  CI.** Every preset honors `prefers-reduced-motion` with no code of
  yours, the page gets its own switch (`data-sv-motion="reduce"`,
  `setMotion()`), the kit's carousel follows the APG pattern with a visible
  pause control, and keyboard, axe, reflow and motion gates run against
  this site on pushes to `main` and pull-request events. Each guarantee names the WCAG criterion it
  serves; see Accessibility below. GSAP and Framer Motion are stronger for
  heavy, time-based choreography and leave reduced motion to the author;
  here it is the default.

## 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.

## Physical-device frame budget matrix

Functional browser tests prove the code paths run; they say nothing about
whether a page actually keeps up on a real device. This table is that
evidence: `demo/bench/lab` (a tiny page you open on any device on your
network) driving the same three workloads (`long`, `deep`, `cubes`) the
harness uses elsewhere, reporting frame-time percentiles and the share of
late frames per page, against a budget declared before any number was read.

<!-- devicematrix:start -->
Frame budget declared before reading any number (`LAB_FRAME_BUDGET`, `scripts/docs-data.mjs`): p95 frame time at most 1.5x the device's own measured vsync interval, and at most 1% of frames late (past that same line). A run that misses either line fails, whatever its own animated check reports. The vsync interval is independently calibrated per run (an idle rAF window before the scroll starts, `demo/bench/lab/run-drive.js`) where the raw result has it; a row marked self-calibrated predates that field and instead derives its interval from the median of its own scroll frames, a weaker number since a uniformly slow device can drag its own line down with it. Raw runs: [`demo/bench/lab/results/published/`](https://github.com/aduptive/scrollvars/tree/main/demo/bench/lab/results/published).

| device | browser | commit | calibration | page | p50 ms | p95 ms | p95 worst rep | p99 ms | late % | animated | budget |
| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| Desktop Chrome (macOS) | Chrome 152 | `5e3bb30` | independent | long | 16.7 | 18.3 | 18.4 | 18.6 | 0.0 | 3/3 | pass |
| Desktop Chrome (macOS) | Chrome 152 | `5e3bb30` | independent | deep | 16.7 | 18.4 | 18.4 | 18.6 | 0.0 | 3/3 | pass |
| Desktop Chrome (macOS) | Chrome 152 | `5e3bb30` | independent | cubes | 16.7 | 18.4 | 18.6 | 18.7 | 0.0 | 3/3 | pass |
| Desktop Firefox (macOS, Playwright's Gecko build) | Firefox 155 | `5e3bb30` | independent | long | 16.7 | 17.2 | 17.2 | 17.5 | 0.0 | 3/3 | pass |
| Desktop Firefox (macOS, Playwright's Gecko build) | Firefox 155 | `5e3bb30` | independent | deep | 16.7 | 17.2 | 17.2 | 17.5 | 0.0 | 3/3 | pass |
| Desktop Firefox (macOS, Playwright's Gecko build) | Firefox 155 | `5e3bb30` | independent | cubes | 16.7 | 17.2 | 17.3 | 17.5 | 0.1 | 3/3 | pass |
| Desktop Safari (macOS) | Safari 26.6.2 | `969380a` | independent | long | 17.0 | 18.0 | 18.0 | 18.0 | 0.3 | 3/3 | pass |
| Desktop Safari (macOS) | Safari 26.6.2 | `969380a` | independent | deep | 17.0 | 18.0 | 18.0 | 18.0 | 0.3 | 3/3 | pass |
| Desktop Safari (macOS) | Safari 26.6.2 | `969380a` | independent | cubes | 17.0 | 18.0 | 18.0 | 18.0 | 0.3 | 3/3 | pass |
| iPhone 16e Safari (iOS 18.7) | Safari 26.6.1 | `969380a` | independent | long | 17.0 | 17.0 | 17.0 | 17.0 | 0.0 | 3/3 | pass |
| iPhone 16e Safari (iOS 18.7) | Safari 26.6.1 | `969380a` | independent | deep | 17.0 | 17.0 | 17.0 | 17.0 | 0.0 | 3/3 | pass |
| iPhone 16e Safari (iOS 18.7) | Safari 26.6.1 | `969380a` | independent | cubes | 17.0 | 18.0 | 19.0 | 21.0 | 0.0 | 3/3 | pass |
<!-- devicematrix:end -->

## Install

```bash
npm i scrollvars
# or pin to a git ref (the `prepare` script builds on install):
npm i github:aduptive/scrollvars#v1.15.0   # pin the ref
```

```ts
// app/layout.tsx (or any entry file). Everything:
import 'scrollvars/styles.css'
// …or only what the page uses (modular since 1.1):
import 'scrollvars/styles/core.css'    // entrances, stagger, drift, spread, native view()-tier, 3.1 KB gz
import 'scrollvars/styles/pin.css'     // sv-stage, curtain, rail, deck, reading, counter, range, 3.4 KB gz
import 'scrollvars/styles/slider.css'  // carousel rails, 1.6 KB gz
import 'scrollvars/styles/tilt.css'    // pointer tilt, 0.7 KB gz
import 'scrollvars/styles/state.css'   // toggles, popover/dialog, rotating words, acts (a scroll-driven acts clock needs core.css too), 2.3 KB gz
import 'scrollvars/styles/ui.css'      // marquee, accordion, 1.5 KB gz
import 'scrollvars/styles/scoped.css'  // OPT-IN: the clocks stop inheriting, see Scoped clocks below, 1.0 KB gz
```

## Pay for what you use

The package is fully tree-shakeable (ESM, side-effect-free JS); measured
<!-- sizes:start -->
Named imports for `track` / `track` + `scan`; other rows are complete module entries, measured from dist by `scripts/docs-stamp.mjs` (JS min+gzip, CSS gzip as shipped):

| you import | JS on the wire |
| --- | --- |
| `track` (the driver) | 5.7 KB |
| `track` + `scan` (zero-wrapper mode) | 8.0 KB |
| `slider` | 3.7 KB |
| `trackPointer` | 1.4 KB |
| `mountEffect` (canvas) | 2.7 KB |
| everything in `scrollvars` (the core entry) | 12.9 KB |
| `scrollvars/react` (wrappers + kit, React external) | 18.8 KB |
<!-- sizes:end -->

A typical page (reveals + stagger) ships `track` + `styles/core.css`:
**~8.8 KB gzipped, total.**

## Mental model

<!-- vars:start -->
The driver **tracks** elements and writes these outputs (anything that reads them is a preset):

| 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 -->

The opt-in outputs are not written by default: `--sv-t` needs
`travel: true` (or `data-sv-travel`), `--sv-pin` needs `pin` (or
`data-sv-pin`), `--sv-scene` (and `--sv-scenes` next to it) needs `scenes`
greater than 1 (or `data-sv-scenes="4"`), `--mx`/`--my` need the pointer
module (`trackPointer()` / `usePointer`). Skip the option and the driver
never writes that variable.

Anything that reads them is a preset. The shipped ones:

| Class | Effect |
| --- | --- |
| `sv-rise` / `sv-fade` / `sv-slide-l` / `sv-slide-r` | Entrance transitions, triggered by `.sv-live` |
| `sv-auto` (on the container) | Every direct child rises in DOM order, no classes on children (`sv-skip` opts out); the first 10 children get their own beat (orders 0 to 9), children beyond 10 share order 10 |
| `sv-drift` | Continuous drift tied to `--sv-view`. Follows the finger, no transition |
| `sv-spread` | Centered deck fans out into its flex row, `.sv-spread-in` plays on arrival, or map `--sv-spread` from `--sv-t` (needs `travel: true` on the tracker) to scrub |
| `sv-view-fade` / `sv-view-rise` | Pure CSS, zero JS, where `animation-timeline: view()` exists |
| `sv-deck` | Pinned card pile: each child flies away across its slice of the pin (`--sv-count`) |
| `sv-reading` | Guided reading: word spans lit progressively across the pin (`--sv-count` + `--sv-order`); unread words sit at `--sv-reading-floor` (.55 keeps 4.5:1 on the default dark palette, check your own colors; .13 for drama) |
| `sv-counter` | Integer counted up by the scroll via `@property` + `counter()`. Set `--sv-max` |

`sv-rise` on an element that is itself tracked (`.sv`/`[data-sv]`, whether
it has no separate ancestor tracker, or is nested inside one) fades in
place: the travel is dropped, since the driver measures that same box and
translating it would feed back into its own live/hidden decision. Put
`sv-rise` on a plain (untracked) child of the tracker for the actual rise.

Knobs (set anywhere in CSS or inline; the defaults live at zero specificity, so a `:root` override always wins): `--sv-distance` (travel length), `--sv-order` (stagger position), `--sv-stagger`, `--sv-duration`, `--sv-ease`. Exception: for auto-ordered children `--sv-order` is declared on the child itself, by `.sv-auto > :nth-child(n)`, `.sv-stagger > :nth-child(n)` and `.sv .sv-deck > :nth-child(n)`, and a value inherited from `:root` never applies where the child declares its own. The auto/stagger rules are (0,2,0); deck indexing is (0,3,0), so overriding one takes an inline `style="--sv-order: 3"` or a rule at least as specific: a plain `.card { --sv-order: 3 }` loses (or omit automatic indexing and order by hand).

Pinning: `data-sv-pin="320vh"` (or `pin: '320vh'` / `<Track pin="320vh">`) sets the height and, when the wrapper is static, `position: relative` (authored positioning is kept); put `class="sv-stage"` on the sticky child. That is the whole pinned skeleton, and it returns to flow without JS, under reduced motion, or below the individual-transform floor without `compat()`. Sticky header? `:root { --sv-pin-offset: 64px }`: the stage sits below it and the pin math starts there. The driver reads the stage's computed `top`, so CSS resolves `calc()`, `env()`, percentages and viewport units in the actual layout. Without a `.sv-stage` (custom `onPin` markup), only px, rem (root font-size), em (the wrapper's font-size), vh (svh, lvh and dvh resolve like vh) and vw resolve in the fallback parser; use a stage for other lengths.

For content that might exceed the stage (CMS copy, text zoom), wrap its layout in `<div class="sv-stage"><div data-sv-fit>…</div></div>`. If that inner box exceeds the available height, the driver marks the tracker `data-sv-flow`, restores its authored height and position, and the pin presets return to flow. This stays latched until retracked; `onFlow(boolean)` reports the initial state and fallback so custom media can release `inert`. TimelineScrub and StickySteps include it.

Pin progress starts at the stage's normal-flow position, including content before it. Call `refresh()` after replacing a stage or its fit layout: it resolves the new nodes, transfers resize observations and recomputes the sticky offset and origin. Measuring the origin temporarily disables sticky positioning and restores the authored declaration; ordinary scroll frames use the cached origin.

## React

```tsx
import { Reveal, Parallax, Scenes, Item } from 'scrollvars/react'

// Entrance: children stagger automatically
<Reveal as="section" auto>
  <h2>Title</h2>
  <p>Copy</p>
  <button>CTA</button>
</Reveal>

// Continuous drift
<Parallax distance="10rem">
  <img src="…" alt="" />
</Parallax>

// Knobs are attributes: sugar for the CSS variables (no style ceremony)
<Reveal stagger={140} duration={900}>
  <Item order={0}>first</Item>
  <Item order={1} effect="slide-l" distance="4rem">second</Item>
</Reveal>

// Pinned storytelling (the sticky/keyframes pattern)
<Scenes count={4}>
  {({ scene, goTo }) => (
    <div>
      Slide {scene + 1}
      {/* continuous progress, pure CSS, no re-render: */}
      <i aria-hidden="true" style={{ display: 'block', height: 4, background: 'currentColor', transformOrigin: 'left', scale: 'calc(var(--sv-scene) / 3) 1' }} />
    </div>
  )}
</Scenes>
```

Lower level: `<Track>` (the base component) and `useTrack(options)` / `useScenes(count)`.

`TrackOptions.onStatus(status)` reports one lease's `AttachmentStatus` (exported
from `scrollvars`). The callback is also an option of `useTrack` and `useScenes`,
and a prop on `<Track>` and `<Scenes>`. Hook refs keep their
`React.RefObject<T>` return type and pass directly to JSX refs in React 18 and 19.

| Status | Notification |
| --- | --- |
| `attaching` | Synchronously when the lease starts, before setup and measurement. |
| `active` | After its first successful measured frame and output callbacks. This does not prove that a Section's CSS loaded or its content fits. |
| `completed` | An entrance-only `once` lease has written its final visible output and stopped tracking. It can complete on the first frame without becoming `active`. |
| `released` | Explicit cleanup, replacement or boot release has settled the lease to static content. |
| `failed` | Initialization, measurement, output or a callback failed; rollback has settled the content. |

Each transition notifies once, never per frame or after `released`. Cleanup is
idempotent; a completed entrance stays visible and its cleanup does nothing.
Callback replacement in React uses the latest callback without retracking or
replaying the current status. `track()` still returns its cleanup function.
Use the notification to clear accessibility restrictions on failure or release.
Keep Section-specific fit and computed CSS checks before hiding inactive media.

Motion reversal does not change an active lease's status: reducing motion
mid-flight restores static layouts and reachable media; the travel clock keeps
scrubbing while pin and scene finish at once as the helper-pinned wrapper
returns to flow. Returning to normal motion can resume the layout if it fits.
An overflow fallback marked `data-sv-flow` stays latched even if content shrinks
or motion reverses. Call `track()` again (or remount the React tracker) to retry
after fixing a failure or to re-evaluate latched flow. The three-second Boot
watchdog is terminal for the page session: late tracking, scanning and remounts
remain static, even after missing dependencies arrive. A later track lease
reports `attaching`, then `failed`.

**Zero-wrapper mode:** drop one `<ScrollVarsBoot />` in the root layout and write
plain server components with `data-sv` attributes. No client wrappers anywhere:

```tsx
// app/layout.tsx
<body><ScrollVarsBoot />{children}</body>

// any RSC: stays on the server
<section data-sv data-sv-once>
  <h2 className="sv-rise">Title</h2>
</section>
```

Attributes: `data-sv` (track), `data-sv-once`, `data-sv-pin`, `data-sv-travel`,
`data-sv-scenes="4"`. New nodes from route changes are picked up automatically
(vanilla: `scan()`).

One more attribute is the driver's own, not yours to set: `data-sv-off`, the
released twin of `html.sv-on`. It lands on a released element (an unmounted
`<Track>`, a stopped `scan()`) and comes off the moment that element is
tracked again; it settles every preset under it to the no-JS rendering
(curtains gone, deck unstacked, `sv-range` finished, `.sv-stage` back in
flow). A released ancestor still holding a tracked descendant keeps waiting:
it only takes the marker once nothing inside it is tracked any more. A
settled `once` entry never takes this marker either: it keeps `sv-live` and
the inline `--sv-live: 1`, so it stays live and untracked instead of
released.

`scrollvars/compat`'s `compat()` writes one more, `data-sv-compat` on
`<html>`, when it appends the fallback `<style>` (never a marker you set by
hand). The marker does not verify that the stylesheet applies, for example
under a blocking style CSP; `compat()` has no nonce option. It changes what the below-the-floor net in
`styles/pin.css` releases: see Browser support.

## The fx gallery: copy-paste effects (+ shadcn-style CLI)

A growing library of effects at **https://scrollvars.dev/fx/**. Each
one live, the effects in Tailwind, CSS and React formats and the six
Sections as complete React components, knobs documented, and a
machine-readable [fx/llms.txt](https://scrollvars.dev/fx/llms.txt)
so AI assistants can ingest the whole collection in one request. Install an
effect straight into your project:

```bash
npx scrollvars list
npx scrollvars add coverflow-slider            # → components/fx/CoverflowSlider.tsx
npx scrollvars add marquee --dir src/ui
```

`add` copies source into the directory where you run it. With no `--dir`, it
writes `components/fx/` under the current working directory; `--dir` is also
relative to that directory unless you pass an absolute path. Run it from the
frontend package root, the folder containing that app's `package.json`.
For example, in a monorepo with a Next.js app under `apps/frontend`:

```bash
cd apps/frontend
npm i scrollvars
npx scrollvars add cube-windows --dir src/components/fx
# creates apps/frontend/src/components/fx/CubeWindows.tsx
```

Then, from `src/app/page.tsx`, import the **local file**:

```tsx
import { CubeWindows } from '../components/fx/CubeWindows'
```

Use `@/components/fx/CubeWindows` only if your app defines `@` for `src/`.
`CubeWindows` and other gallery components are copied recipes, not exports of
`scrollvars/react`. Install a recipe once, then reuse its component in several
places with different `box`, `turn`, `className` and `children`; do not run
`add` for every block. A copied file is yours to edit and does not update
automatically when the remote registry changes. The CLI prints its absolute
destination; the [Cube windows page](https://scrollvars.dev/fx/cube-windows.html)
also shows the complete component source for copying without the CLI.

The CLI fetches a remote registry, so the gallery grows without package
releases.

The same CLI installs a skill for AI coding agents:

```bash
npx scrollvars skill              # Claude Code + Codex, current project
npx scrollvars skill --global     # user-level folders instead
```

It copies `skills/scrollvars/SKILL.md`, shipped in the package, into
`.claude/skills/scrollvars` and `.agents/skills/scrollvars`, matching the
installed version. It refuses to overwrite a locally modified copy without
`--force`. The same file also works with the open `npx skills add
aduptive/scrollvars` convention.

For a complete Section, run `npx scrollvars add sticky-steps`, import
`scrollvars/styles/pin.css` in your entry file, then use the installed component:

```tsx
import { StickySteps } from './components/fx/StickySteps'

export function ProductStory() {
  return <StickySteps steps={[
    { title: 'Explore', text: 'Find your starting point.', media: <img src="explore.jpg" alt="Product overview" /> },
    { title: 'Create', text: 'Make it yours.', media: <img src="create.jpg" alt="Product editor" /> },
  ]} />
}
```

## The component kit (React)

<!-- 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 -->

Batteries-included wrappers over the same engine. Less React, less JS,
less CSS than the usual suspects:

```tsx
import { Slider, Slide, Marquee, Accordion, Modal } from 'scrollvars/react'

// the Swiper replacement: breakpoints ARE media queries (or a familiar map):
<Slider perView={{ base: 1.2, md: 2.5, xl: 4 }} gap={16} arrows dots autoplay={5000}>
  {cards.map(c => <Slide key={c.id}><Card {...c} /></Slide>)}
  <Slide span={2}>a wide feature slide</Slide>   {/* per-slide override */}
</Slider>

<Marquee speed={24}>{logos}</Marquee>            // infinite strip, pauses on hover
<Accordion title="Question?" group="faq">…</Accordion>  // native <details>, animated
<Modal open={open} onClose={…}>…</Modal>         // native <dialog> + sv-pop
```

**Customizing the Slider chrome**: three layers, pick your depth:
1. **Var knobs** (`--sv-arrow-size/-inset/-bg/-color/-radius`, `--sv-dot-size/
   -gap/-color/-active`, `--sv-dot-target` (the 24px hit area around the
   visual dot), `--sv-dots-justify/-offset`). Set on `:root` for the
   whole project, or on one slider via className/style.
2. **Stable classes** (`sv-slider-shell`, `sv-arrow[-prev/-next]`, `sv-dots`,
   `sv-dot.on`). Restyle or reposition freely in project CSS.
3. **Full control**: `prevIcon`/`nextIcon`, `renderDot(i, active)`, or turn the
   chrome off and drive an external UI through the ref (`SliderHandle`:
   next/prev/goTo/seek/state) placed anywhere in the page.

`perView` fractional gives the peek (`1.2`); responsive via the map above,
media queries, or Tailwind: `className="sv-cols [--sv-per-view:1.2] md:[--sv-per-view:2.5]"`
(`sv-cols` does the column math whether it sits on the slider itself or one
level up on the Slider shell, where React's `className` prop lands;
`perView` adds the class for you automatically).
Vars cascade, so every knob has a global default and a per-instance (or
per-slide) override. No `loop` in v1: where Swiper's loop is used, a
`<Marquee>` is usually the honest fit.

## Slider (Swiper, featherweight)

Native scroll + scroll-snap do the carousel; the module adds mouse drag,
the active-slide observer and controls. Slides get `--sd` (signed distance
from center, in slide widths) and `.sv-active`. Any CSS reading them
animates the carousel with no per-frame JS of yours (the slider itself measures `--sd` on scroll frames):

```tsx
import type { ReactNode } from 'react'
import { useSlider } from 'scrollvars/react'

export function Carousel({ slides }: { slides: ReactNode[] }) {
  const { ref, next, prev } = useSlider() // or slider(el) in vanilla
  return <>
    <div ref={ref} className="sv-slider">
      {slides.map((slide, i) => <div key={i} className="slide">{slide}</div>)}
    </div>
    <button onClick={() => prev()}>Previous</button>
    <button onClick={() => next()}>Next</button>
  </>
}
```

```css
/* coverflow in two lines */
.slide { scale: calc(1 - min(max(var(--sd), -1 * var(--sd)) * 0.12, 0.3)); opacity: calc(1 - max(var(--sd), -1 * var(--sd)) * 0.35); }
```

Options: `snap: 'mandatory' | 'proximity'`, `drag: false`, `duration` (glide
settle ms; default 600: raise for softer), `axis: 'y'` (vertical),
`onScroll(state)` (full state per frame: active/count/position/progress/
dragging/gliding. Also on the container as `--sv-progress`). 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`.

Plain carousels can use `slider(el, { cssVars: false })`,
`useSlider({ cssVars: false })` or `<Slider cssVars={false}>` when their CSS
does not read `--sd`, `--sv-progress` or `--sv-slide`. This skips future writes
of those three outputs; existing inline values stay intact. Numeric state,
callbacks, active classes and scroll-snap keep working. The default is `true`;
keep it enabled for coverflow or custom CSS progress effects.

Chain two sliders (Swiper's controller/thumbs, one line, unidirectional):

```ts
const thumbs = slider(thumbsEl, { axis: 'y', drag: false })  // author it with scroll-snap-type: none
slider(mainEl, { onScroll: (s) => thumbs.seek(s.progress) })
```

Size, measured: this module 3.7 KB gzip; Swiper 11 bundle
151 KB min / 42 KB gzip (+ 18 KB CSS).

## Interaction states (click)

The third input. One delegated listener turns clicks into classes and
variables; CSS animates: same contract as scroll:

```html
<button data-sv-toggle="sv-open" data-sv-target="#menu">menu</button>
<nav id="menu" class="sv-pop">…</nav>   <!-- animated show/hide, aria-expanded synced -->
```

`<ScrollVarsBoot />` wires it automatically (vanilla: `toggles()`). Presets in
`styles/state.css`:

- `sv-pop`: entry/exit for `[popover]`, `<dialog>` and class-toggled panels
  via `@starting-style` + `allow-discrete` (the modern replacement for the
  checkbox hack. With accessibility the hack never had)
- `sv-words`. Rotating words: a clipped column, slide with `--sv-word: n`

**Multi-act sequences**: `sv-acts` is a time-driven master clock in pure
CSS: a registered custom property transitions 0 → N when the class arrives
(`sv-open` from a click, or `sv-live` from the scroll. Timed choreography
with zero JS). Acts are the same `clamp()` slices the scroll scenes use, so
one idiom drives every timeline; the clock is reversible and interruptible
(class removed mid-flight = the transition retargets, no restart). What it
deliberately doesn't do: branching, per-act JS callbacks, physics. That's
GSAP.

```css
.hero { --a1: clamp(0, var(--sv-act), 1); --a2: clamp(0, calc(var(--sv-act) - 1), 1); }
.hero h1 { opacity: var(--a1); translate: 0 calc((1 - var(--a1)) * 2rem); }
.hero .cards { scale: calc(0.9 + var(--a2) * 0.1); }
```

Three tricks worth knowing before writing any JS: on an element the driver
does not track (a hand-flipped `.sv`, a `toggles()`-driven widget), removing
`sv-live` and adding it back on the next frame replays the whole entrance
system on demand; on a tracked (or released) element the driver pins
`--sv-live` inline, which outranks a rule of your own without `!important`,
so re-tracking is what replays the entrance there instead. A settled
`once` entry carries that same inline value without being tracked or
released, so the class trick alone cannot replay it there; re-tracking
still can, exactly as on a tracked element. Two shapes it cannot replay:
entrance CSS of your own that hard-codes its duration instead of reading
`--sv-duration`/`--sv-stagger`, and `--sv-duration` or `--sv-stagger`
declared on a DESCENDANT rather than inherited from the tracked element,
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, and from inside a rAF callback they
dip and reverse instead. `:has()` puts
state anywhere (`body:has(#tab-2:checked) .panel-2`); the Popover API
opens/closes with zero JS. One-shot intros on load are plain CSS keyframes.
Timed multi-act sequences are `sv-acts` (above); branching, physics or
callback-heavy timelines remain GSAP's turf.

## Pointer tilt

Same philosophy, different input: the pointer becomes `--mx`/`--my` (−1..1 from each card's center):

```tsx
const ref = usePointer()          // or trackPointer(container) in vanilla
<div ref={ref} className="grid">
  <div className="sv-tilt">…</div>   {/* tilt + glare from styles.css */}
</div>
```

## Video scrub & WebGL

`onTravel` fires on every driver frame while the element is near the viewport, with the raw 0..1 value. Feed it to whatever JS needs to follow the scroll. Track it 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
import { useTrack, useScenes } from 'scrollvars/react'

export function FrameSequence({ frameCount, drawFrame }: { frameCount: number; drawFrame: (index: number) => void }) {
  const ref = useTrack<HTMLDivElement>({
    onTravel: t => drawFrame(Math.round(t * Math.max(0, frameCount - 1))),
  }) // frame sequence, never video.currentTime
  return <div ref={ref}>Scroll to explore the sequence.</div>
}

export function Story() {
  const { ref, scene, active } = useScenes<HTMLDivElement>(4, { pin: '400vh' })
  // active is false on the server, without JS, after a failed attach, after
  // flow, or under reduced motion: the scene index alone is not reachable
  // then, so render every scene (`<Scenes>` does the same internally). This
  // sample remounts on the active/stacked switch (a plain index key in both
  // branches); `<Scenes>` itself freezes a stable "current" key across the
  // switch so a running video or a focused input survives it, see its
  // source if your own hook usage needs that too.
  return (
    <div ref={ref}>
      <div className="sv-stage">
        {active
          ? [<p key={scene}>Scene {scene + 1}</p>]
          : Array.from({ length: 4 }, (_, i) => (
              <p key={i}>Scene {i + 1}</p>
            ))}
      </div>
    </div>
  )
}
```

## Canvas effects (`scrollvars/canvas`)

For ambient canvas work (particle spheres, generative heroes) the simulation is
yours. The harness handles the lifecycle everyone rewrites badly: resize, DPR
cap, delta-time loop, **pause when offscreen or the tab is hidden**,
`prefers-reduced-motion`, cleanup. Drawing space is CSS pixels.

```tsx
import { useRef } from 'react'
import { useCanvasEffect } from 'scrollvars/react'

export function Orbit() {
  const angle = useRef(0)
  const ref = useCanvasEffect({
    frame: (fx, dt) => {
      if (!fx.ctx) return // context: null effects own the canvas
      fx.ctx.clearRect(0, 0, fx.width, fx.height)
      if (!fx.reducedMotion) angle.current += dt
      fx.ctx.beginPath()
      fx.ctx.arc(fx.width / 2 + Math.cos(angle.current) * 40, fx.height / 2, 8, 0, Math.PI * 2)
      fx.ctx.fill()
    },
  })
  return <canvas ref={ref} width={320} height={180} aria-hidden="true" />
}
```

Vanilla: `mountEffect(canvas, { setup, frame })` returns
`{ pause, resume, destroy }`. Feed it scroll/pointer inputs from the driver
(`onTravel`, `--mx`) through your own closure. The modules stay decoupled.

## Vanilla

```ts
import { track } from 'scrollvars'

const untrack = track(el, { scenes: 4, onScene: (i) => console.log('scene', i) })
```

## Defaults (the tuned knobs)

| Knob | Default | Where |
| --- | --- | --- |
| Live band | enter 75% / exit 25% of viewport | driver (`sv-live`, `--sv-view` ramps) |
| Scene snap dead-zone | 0.4 (`snap` option; `false` disables) | driver `scenes` |
| Entrance distance | `--sv-distance: 6rem` | styles.css |
| Stagger step | `--sv-stagger: 90ms` | styles.css |
| Entrance duration | `--sv-duration: 800ms` | styles.css |
| Slider glide settle | `duration: 600` ms (exponential lerp) | slider |
| Slider wheel-quiet window | 200 ms | slider |
| Canvas DPR cap | 2 (`dprCap`) | canvas harness |

## Sequenced scrub (`sv-range`): choreography without a timeline

The routine reason a timeline library gets pulled into a scroll page is not
springs. It is "A animates over 0–40% of the pin, B over 30–70%, C over
60–100%". `sv-range` derives a per-child `--sv-r` (0..1) from a slice of the
parent clock (`--sv-pin` when pinned, else `--sv-t`, which needs
`travel: true` / `data-sv-travel`):

```html
<div data-sv data-sv-pin="320vh">
  <div class="sv-stage">
    <div class="sv-range sv-range-rise">
      <h2 style="--sv-from: 0; --sv-to: .4">First</h2>
      <p style="--sv-from: .3; --sv-to: .7">Second</p>
      <p style="--sv-from: .6; --sv-to: 1">Third</p>
    </div>
  </div>
</div>
```

`sv-range-rise` is the ready-made flavor (rise + fade per range; the fade
starts at `--sv-range-floor`, .55 by default, which keeps text at 4.5:1
on the tested pair (#e8efe6 on #14211a) while a child waits for its slice; a muted
or accent color needs a higher floor, and 0 gives the dramatic look at the
cost of that audit); or consume
`--sv-r` yourself: always as `var(--sv-r, 1)`: the derivation needs calc()
division by a variable (Chrome 112 / Safari 16.4 / FF 112) AND `@property`
(Chrome 85 / Safari 16.4 / FF 128) together for full support. The two
floors split: 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 initial or
inherited value, not to 1; the `var(--sv-r, 1)` you write is habit, not
the reason older engines settle, and never fires on your range children
either way, since `--sv-r` is always set. The JS twin is
`mapRange(t, from, to, ease?)` for `onTravel`/`onPin` consumers (canvas,
WebGL uniforms). Overlapping ranges are fine: that is the point.

## Nested scrollers & custom live band

`track(el, { root: scrollerEl })` measures against an inner scroll container
instead of the window, brand-center layouts with inner panels stop being a
disqualifier (the capture-phase listener already hears those scrolls; `root`
makes the geometry agree). A root with borders is measured from its client
box, inside the border, so `track()`'s pin math and `scrollToScene()`'s
scroll target share one origin. `enter`/`exit` (fractions, defaults
0.75/0.25) tune the live band per element. Also as `data-sv-enter="0.6"` /
`data-sv-exit="0.2"` in zero-wrapper mode and as props on `<Track>`/
`<Reveal>`. A `.sv-stage` pinned inside a root reads `--sv-stage-height`
instead of the default `100vh`: set it to the root's own height.

## When to use what

The honest boundary: ScrollVars maps **inputs to values**. If the animation
happens because the *user did something* (scroll, pointer, gesture), it does
the job at a fraction of the cost. If it happens because *time passes*, use
the tools built for that.

| You need | Use |
| --- | --- |
| Reveals, parallax, pinned stories, scrubbing, carousels, tilt, camera paths | **ScrollVars** |
| Orchestrated timelines (`tl.to(a).to(b, "-=0.2")`), elastic/bounce easings, SVG morphing, animating arbitrary JS values | **GSAP** |
| Interruptible spring physics, layout/`layoutId` "magic motion", exit animations on React unmount, `whileDrag` gestures | **Framer Motion** |
| A one-shot intro that plays on load | plain **CSS keyframes** (before reaching for a library) |

Known gaps inside ScrollVars' own territory (candidates for 1.x, tell us if
you hit them): declared smooth-scroll (Lenis) interop and a `loop` mode for
the slider. (Pinning got its helper in 1.13, text splitting shipped in 1.12.)
(Nested scrollers work since 1.7. The driver listens in the capture phase.)

Mixing is fine: GSAP for one intro timeline + ScrollVars for everything
scroll-driven coexist without conflict. That page just gives up the bundle
argument.

## Browser support

The floor is set by two things: the dist ships ES2020 (optional chaining) and
the presets use individual transform properties (`translate:`/`rotate:`/`scale:`).

| Browser | Fully animated | Notes |
| --- | --- | --- |
| Chrome / Edge | **104+** (Aug 2022) | `sv-view-*` native zero-JS tier: 115+ · `sv-range` needs 112+ · `sv-acts` needs 85+ |
| Firefox | **78+** (Jun 2020, `:is()`/`:where()`) | `sv-counter` preset needs 128+ (Jul 2024) · `sv-range` needs 112+ · `sv-acts` needs 128+ |
| Safari / iOS | **14.1+** (Apr 2021) | `sv-counter` preset needs 16.4+ (Mar 2023) · `sv-range`/`sv-acts` need 16.4+ |
| Anything older, or no JS | content visible, static | Below the transform floor without `compat()`, stages return to flow, curtains hide and rails wrap. With `compat()`, curtains and rails keep their fallback animation; stages containing static decks return to flow. |

The component kit (Modal, Accordion, `sv-pop`, `sv-acts`) additionally 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. Under reduced motion the driver zeroes `--sv-view`, and the travel clock keeps scrubbing (scroll-linked, not motion); a helper-pinned stage returns to flow, so its pin and scene clocks finish at once. `<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. Entrances show their final state.

Below the transform floor, with JS still running, `styles/pin.css` releases
stages, hides decorative curtains and wraps rails when `compat()` is absent.
Decks unstack. With `compat()` installed, curtains and rails keep their
fallback animation and pinned geometry. A tracked stage containing a deck
returns to flow through `data-sv-flow`, including its wrapper height, so every
static card stays reachable. Compat's rail still ignores `--sv-rail-start`
and starts at `translateX(0)`; it is stationary when its width fits the stage.

Installed StickySteps only applies `inert` and `aria-hidden` to inactive shots
after `onStatus('active')`, its first fit evaluation and computed CSS checks
confirm the crossfade layout is active. It starts static, even while another
section has booted the driver. Before the first fit evaluation it temporarily
stacks the candidate shots without accessibility hiding, so intrinsic images
are measured in the crossfade layout instead of the taller static rows.
Failed enhancement, release, reduced motion and fit-to-flow clear layout and
accessibility hiding so every static shot remains reachable.
Missing or throwing mutation observation also keeps the Section static and
releases any partially acquired observer or motion subscription.
Later controller failures also stop observation and motion updates, attempt every
cleanup, restore normally flowing media and clear React's rendered accessibility
state. They report once and ignore queued callbacks; explicitly remount the
Section to retry. Replacement copy keeps its candidate stage constrained until
the driver can record overflow and latch flow. The
[packed acceptance gate](acceptance/packed-sections.md) checks real media and CMS
replacement, and saves attributable results and failure diagnostics in CI.

Scroll initialization and attachment roll back partial setup on failure.
Missing or throwing ResizeObserver keeps content static; a missing or throwing
optional IntersectionObserver runs without culling. A failed scan releases
only its own tracking, split and pointer leases and settles unprocessed content.
Overlapping owners keep their resources. Measurement, output and callback
failures release the affected tracker, restore its pin geometry and report the
original error once; healthy entries continue. Retry a recoverable failure by
explicitly tracking or scanning again. `track()` still returns a cleanup function.
`<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 setup also roll back partial listeners,
observers, subscriptions and queued work before rethrowing the original error.
Runtime measurement, output and callback failures stop only that instance and
report the original error once (`reportError`, or `console.error` as a fallback).
The one exception is `toggles()`: a failing click rolls back only that write
and reports once, but the instance itself keeps running 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.
Cleanup is idempotent, including inside callbacks and during slider gestures;
late observer and frame deliveries cannot revive a released instance.
Explicit cleanup attempts every release before rethrowing its first cleanup error;
React reports cleanup errors locally.
Call the module again to retry a recoverable failure.

Slider cleanup restores its owned styles, classes and tabindex without
overwriting later author changes. Child replacement restores departed slides'
owned outputs and classes immediately on mutation delivery and releases their
ownership records. Author the native rail layout (for example,
`class="sv-slider"`) in markup so it remains scrollable before and after enhancement.
Pointer cleanup likewise restores its owned coordinates and leave class.
A failed toggle operation restores its previous class, state variable and ARIA;
successful open/closed state survives controller release. Shared controllers
retain their marker until the last release, and pending transition holds are restored.
Canvas setup, resize and frame failures stop the loop and restore authored sizing
without changing fallback children; `destroy()` restores that sizing too, which
resets the bitmap (`pause()` keeps the last frame). Consumer drawing and external
resources still belong to the consumer; return their disposer from `setup`.
React auxiliary hooks contain attachment and cleanup errors locally and can retry
on target or option replacement. Failed autoplay stops its own timer; failed Modal
promotion keeps the requested state as a static panel. No public API changed.

Any successful tracker or scan acknowledges Boot, including an empty route; a
failed scan settles its own content visible instead of waiting on Boot at all.
Its prepaint watchdog releases hiding after three seconds without that
acknowledgement. Expiry is terminal for that page session: late tracking,
scanning and remounting remain static, even if mutation observation is unavailable.
The inline script requires both observers to exist before hiding and accepts
the request nonce through `<ScrollVarsBoot nonce={nonce} />` under a strict CSP.
If Boot's toggle setup throws after scanning, Boot also settles the page
terminally and releases the scroll trackers it had already initialized.

**Extended floor**: `scrollvars/compat`, an opt-in module for legacy
targets. On modern browsers it runs three feature checks (ResizeObserver, IntersectionObserver, individual transforms) and exits (free);
on old ones it installs a ResizeObserver stub (viewport-resize backed), an
always-visible IntersectionObserver stub, 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.
Text splitting itself still works down to the same floor: `split()`
no longer depends on `Array.prototype.flatMap`, missing on Chrome 61-68 and
Safari 11. Combined with your bundler downleveling the ES2020 dist (Next.js
already does per browserslist), the reveal and pin presets above animate
on roughly Chrome 61+ / Firefox 60+ / Safari 11+; `sv-deck`,
`sv-spread`, `sv-counter` and `sv-view-*` stay static or
progressive. Call it once, before anything else:

```ts
import { compat } from 'scrollvars/compat'
compat()
```

Per-module gates, if you need finer grain: driver = ES2020 + ResizeObserver
(Safari 13.1); presets = individual transform properties (Chrome 104 /
Firefox 78 / Safari 14.1); canvas harness adds IntersectionObserver
(Safari 12.1); slider/pointer = Pointer Events (Safari 13). The design rule
that makes the table safe for companies: **the supported presets fail visible when their feature gates are unmet.**
Consumer code and the React version have their own JavaScript/browser requirements;
transpilation alone does not polyfill runtime APIs. Skip `compat()` for static rendering; nothing
overlapping or clipped (curtains parted, deck unstacked, `.sv-stage` back
in flow; `sv-rail`'s own exception is above); call it and the page animates instead, on roughly
Chrome 61+ / Firefox 60+ / Safari 11+. Animation is progressive
enhancement, never a dependency.

## Declarative pointer and timing knobs

`scan()` / `<ScrollVarsBoot />` also owns `[data-sv-pointer]` containers, including an element passed as the scan root. Empty `data-sv-pointer` delegates to `.sv-tilt`; `data-sv-pointer=".card"` selects another target. Added subtrees attach automatically; removal or stopping the scan releases their listeners and pointer values. Attributes are read on mount, not watched for changes.

`data-sv-duration="800ms"`, `data-sv-stagger="100ms"` and `data-sv-ease="ease-out"` map to their CSS variables. Timing attributes take CSS units; React's numeric timing props take milliseconds.

`<Slider nonce={nonce} perView={{base: 1, md: 3}}>` passes the nonce to its generated responsive stylesheet. Autoplay pauses on hover, stops on keyboard focus until explicitly resumed, and sets its live region to polite while paused. `<Marquee>` includes a keyboard-operable “Pause animation” toggle (`aria-pressed`); it stays static without its click driver. The bare `.sv-marquee` CSS class remains an ambient animation: supply a pause control when using it directly. `toggles()` synchronizes an existing `aria-pressed` instead of `aria-expanded` for pressed-state buttons.

## License

MIT

## 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.

## Accessibility

What the library guarantees on its own surfaces, each line covered by a test
in this repository (most of them browser invariants in CI), with the WCAG
success criterion it serves. It is not a conformance claim for your site.

- **Readable without JavaScript.** Every entrance and scrub preset hides
  only under `html.sv-on`, which the driver sets. No JS, or an error before
  boot, and those render as a normal static page with everything visible.
  Disclosures keep their closed state without JS, as the platform's own
  `<details>` and `<dialog>` do: an `sv-pop` panel, an Accordion item or a
  Modal that is closed stays closed, so essential content goes outside them
  or opens by default (`sv-open`, `open`).
- **Reduced motion everywhere, from two sources** (2.3.3 Animation from
  Interactions). Every preset sheet has a `prefers-reduced-motion` block:
  entrances render their final state, scrub presets settle, the deck and the
  rail return to flow, parallax stands still, the marquee stops. Each of
  those blocks has a twin under `html[data-sv-motion="reduce"]`, the page's
  own switch, because the OS setting is one many people never find.
  StatsCountup shows final numbers under either control even before activation;
  click-driven `sv-acts` still follows its open/closed state.
  `setMotion('reduce' | 'auto')` sets it, `onMotionChange(fn)` reports it,
  `prefersReducedMotion()` reads the effective preference, and the driver,
  the slider's glide, canvas effects (`fx.reducedMotion`) and the React
  `<Slider>` autoplay all follow it live. Persisting the choice is your job:
  set it before the first frame (an inline script in `<head>` that reads
  your store and sets the attribute) and nothing animates first and calms
  down later. scrollvars.dev does exactly that: the switch in its header,
  and every reduced-motion rule of the site's own CSS carries the twin.
- **Moving content can be stopped** (2.2.2 Pause, Stop, Hide). The marquee
  pauses on hover and on keyboard focus within it. `<Slider autoplay>`
  pauses during pointer and touch gestures, on hover, focus, offscreen and hidden tab, renders a visible pause
  control, and starts paused under reduced motion; the control still resumes
  it, that is the user asking. Releasing or cancelling a gesture starts a full
  autoplay countdown; focus and explicit pause still require the resume control.
- **Carousel semantics** (4.1.2 Name, Role, Value; 2.1.1 Keyboard).
  `<Slider>` follows the APG carousel pattern: `role="region"`,
  `aria-roledescription="carousel"`, a `label`, slides announced "i of n",
  labeled arrows and dots (the current dot has `aria-current` and `aria-disabled`), keyboard on the track, a `role="status"`
  region outside the track that announces "Slide N of M" while rotation is paused by the person (hover, focus, the
  control), `off` while it rotates or is merely suspended off screen. Sliders
  retain native scrolling and snap, with custom mandatory-snap wheel settling
  and keyboard navigation. Pins use native page scrolling and sticky positioning.
- **Targets** (2.5.8 Target Size, Minimum). Every dot the kit renders is a
  24 by 24 CSS pixel button that never shrinks (a crowded row wraps), with
  the visual dot drawn inside it: `--sv-dot-target` sizes what you hit,
  `--sv-dot-size` what you see. A `renderDot` of your own is yours to size.
- **Animated text stays text** (1.3.2 Meaningful Sequence). `split()` keeps
  the full text as a visually hidden first child and marks the animated
  spans `aria-hidden`. It flattens markup, so give it plain text, never a
  link or an emphasis.
- **State stays in sync** (4.1.2). `toggles()` keeps `aria-expanded` (or
  `aria-pressed`) on every trigger of a target; Modal and Accordion are
  native `<dialog>` and `<details>`, so focus, Escape and exclusivity come
  from the platform.
- **Focus can be seen** (2.4.7 Focus Visible, 2.4.11 Focus Not Obscured). A
  gate tabs through every gallery page and the home page, forward and back,
  and at every stop requires the focused element to be inside the viewport,
  at an effective opacity of at least 0.5 down its ancestors, `visibility:
  visible`, not covered at the center of its visible part, and still so
  250ms later on the way forward. Content an entrance or a pin reveals is
  reached that way: the
  browser scrolls the focused element into view, the section goes live, the
  entrance runs. The gate proves it can fail on a fixture (a link under a
  fixed header on the way back, a link inside a box that stays at opacity
  0) and that `scroll-padding-top` clears the first.
- **Reflow and zoom** (1.4.10 Reflow, 1.4.4 Resize Text). At 320 CSS
  pixels of width and at 640 (a 1280 window at 200 percent), every gallery
  page and the home page scroll in one direction only, after boot and after
  a scroll through, and every `[data-sv-fit]` box either fits its stage or
  has released the pin (`data-sv-flow`, the fit contract), so nothing
  pinned is clipped; the pinned pages also pass the keyboard gate at 320.
  Proved able to fail on a fixture with a fixed-width band.
- **axe, as a floor** (WCAG 2.x A and AA rules). Every gallery page and the
  home page pass axe-core with zero violations in CI, audited after boot
  and again after a walk down the page a viewport at a time (so every
  section has gone live), at the bottom, the middle and the top. The gate proves it can fail (an image
  without alt, a button without a name). A floor, not proof: axe sees
  names, roles, contrast and structure, never whether the page makes sense
  to a screen reader user; that pass is a person's.

### VoiceOver pass (manual, author-run)

An accessibility-tree proxy (`demo/bench/harness/a11y-tree-gate.mjs`, in CI
on every push and pull request) checks structure: names, roles, expanded
and pressed state, focus targets. It is not a person listening. This
checklist is that person's pass, run by the author (Andrea) on the current
`main`, macOS Safari and iOS Safari, both with VoiceOver. Empty result
cells mean not yet run: they are filled in as the pass happens, never
guessed at ahead of it.

Every row below runs against `/a11y/` (`demo/a11y/index.html`): one page,
the real kit, the shipped `styles.css`, the same fixture
`a11y-tree-gate.mjs` exercises. It replaces the earlier rows that pointed
at gallery previews (a vanilla `slider()` with no `aria-roledescription`,
a `<dialog>` no page actually shipped, an Accordion no row named): a
correct library has to pass what a person actually hears on this page,
not on a page that could never pass in the first place (D3/P1).

**macOS Safari + VoiceOver**

| # | Component (page) | Steps | Expected announcement | Result | Date, commit |
| --- | --- | --- | --- | --- | --- |
| 1 | Slider (`/a11y/` "Slider" section) | VO-Right/Left through the carousel; then VO-Space the next arrow | "Photos, carousel", each stop "N of 3, slide"; activating next moves the current-dot mark to slide 2 and a status region announces "Slide 2 of 3" | pass | 2026-09-28, `969380a` |
| 2 | Modal (`/a11y/` "Modal" section) | Activate "Open modal"; VO reads the dialog; press Escape | "dialog" on open; focus and VO cursor return to "Open modal" on close | pass | 2026-09-28, `969380a` |
| 3 | Accordion (`/a11y/` "Accordion" section) | VO-Space on the `summary` | "collapsed"/"expanded" toggles with the state, "FAQ question" read each time | pass | 2026-09-28, `969380a` |
| 4 | Disclosure (`/a11y/` "Disclosure" section, `sv-pop`) | VO-Space "Toggle panel" | "expanded"/"collapsed" matches the panel's visible state | pass | 2026-09-28, `969380a` |
| 5 | Split text (`/a11y/` "Split text" section) | VO-Right onto the heading | "Words arrive one by one" read once, not one word per stop | pass | 2026-09-28, `969380a` |
| 6 | Scenes fallback (`/a11y/` "Scenes" section) with reduced motion on | VO-Right down the section | "Scene 1 of 3", "Scene 2 of 3", "Scene 3 of 3", in order, none skipped | pass | 2026-09-28, `969380a` |
| 7 | Marquee pause (`/a11y/` "Marquee" section) | VO-Space the pause button | "Pause animation, button" before, "pressed" after, label unchanged | pass | 2026-09-28, `969380a` |
| 8 | RotatingWords pause (`/a11y/` "Rotating words" section) | VO-Right onto the component, then VO-Space the pause button | The full phrase list read once as plain text; "Pause rotation, button" before, "pressed" after, label unchanged | pass | 2026-09-28, `969380a` |

**iOS Safari + VoiceOver**

| # | Component (page) | Steps | Expected announcement | Result | Date, commit |
| --- | --- | --- | --- | --- | --- |
| 1 | Slider (`/a11y/` "Slider" section) | Swipe right/left through the carousel; then double-tap the next arrow | "Photos, carousel", each stop "N of 3, slide"; activating next moves the current-dot mark to slide 2 and a status region announces "Slide 2 of 3" | pass | 2026-09-28, `969380a` |
| 2 | Modal (`/a11y/` "Modal" section) | Double-tap "Open modal"; swipe inside the dialog; use the close control (iOS VoiceOver has no Escape key) | Dialog announced on open; focus returns to "Open modal" on close | pass | 2026-09-28, `969380a` |
| 3 | Accordion (`/a11y/` "Accordion" section) | Double-tap the `summary` | "collapsed"/"expanded" toggles with the state | pass | 2026-09-28, `969380a` |
| 4 | Split text (`/a11y/` "Split text" section) | Swipe onto the heading | "Words arrive one by one" read once | pass | 2026-09-28, `969380a` |
| 5 | Scenes fallback (`/a11y/` "Scenes" section) with Reduce Motion on | Swipe down the section | "Scene 1 of 3", "Scene 2 of 3", "Scene 3 of 3", in order | pass | 2026-09-28, `969380a` |
| 6 | Marquee pause (`/a11y/` "Marquee" section) | Double-tap the pause button | Announced as a button with a pressed state that flips, label unchanged | pass | 2026-09-28, `969380a` |
| 7 | RotatingWords pause (`/a11y/` "Rotating words" section) | Double-tap the pause button | Announced as a button with a pressed state that flips, label unchanged | pass | 2026-09-28, `969380a` |

Your side of it, as a checklist:

- [ ] CSS of your own that reads `--sv-view`, `--sv-t`, `--sv-pin`, `--mx` or
      `--my` is motion: give it both guards, `@media (prefers-reduced-motion:
      reduce)` and `:where([data-sv-motion="reduce"])`, and offer the switch
      (`setMotion()`) somewhere visible, stored, applied before first paint.
- [ ] Nothing essential lives only behind a reveal the scroll position
      opens, or only in a scene the pin shows; the no-JS render is the test.
- [ ] Anything that moves on its own (a marquee, an autoplay) has a way to
      stop that works from the keyboard; keep the kit's pause control.
- [ ] Rotating words and counters keep the readable text in the DOM and mark
      the animated copy `aria-hidden`; `split()` gets plain text only.
- [ ] Text dimmed "until you get there" still meets 4.5:1 (3:1 when large)
      at its dimmest: compute the floor, mixing every nested opacity.
- [ ] Under a fixed or sticky header, `scroll-padding-top` on `html` equals
      the header's height, or a focused element scrolled to the top edge
      lands under it.
- [ ] Your own controls are 24 by 24 CSS pixels at least (`renderDot` is
      yours to size); scrollable code blocks carry `tabindex="0"`.
- [ ] At 320 CSS pixels of width the page scrolls in one direction only;
      pinned content that can grow (CMS copy, text zoom) sits in
      `[data-sv-fit]` so the pin releases instead of clipping.
- [ ] Run axe on the finished page, then walk it once with a screen
      reader: axe is a floor, and this list is one too.

Where this follows published guidance: the visually hidden copy in
`split()` uses the technique Sara Soueidan describes in
[Inclusively Hiding and Styling Checkboxes and Radio Buttons](https://www.sarasoueidan.com/blog/inclusively-hiding-and-styling-checkboxes-and-radio-buttons/),
the slider's status region follows her
[Accessible notifications with ARIA Live Regions](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-1/),
and its arrows and dots her [Accessible Icon Buttons](https://www.sarasoueidan.com/blog/accessible-icon-buttons/).
The motion rules follow Val Head's
[Designing Safer Web Animation for Motion Sensitivity](https://alistapart.com/article/designing-safer-web-animation-for-motion-sensitivity/)
and WCAG 2.3.3.

## 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. What it
cannot see: a rule edited in place, or one inserted through the CSSOM with no
element added at the same time. A page that does that calls
`setPageOutputs(true)`.

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: [Physical-device frame budget matrix](#physical-device-frame-budget-matrix), above.
