The human reference
One rAF in, CSS variables out. This page is for people; AIs get llms.txt. Live patterns: the demo (view-source is the spec) and the fx gallery (copy-paste, three formats each).
Quickstart
npm i scrollvars # or pin: npm i github:aduptive/scrollvars
Next.js (zero-wrapper, recommended): one boot in the root layout, then plain server components with data attributes.
// app/layout.tsx
import 'scrollvars/styles/core.css' // or styles.css for everything
import { ScrollVarsBoot } from 'scrollvars/react'
// <ScrollVarsBoot /> once inside <body>
// any page: no client components needed:
<section data-sv data-sv-once>
<h2 className="sv-rise">Title</h2>
<p className="sv-rise" data-sv-order="1">Copy</p>
</section>
Per-element knobs are attributes too: data-sv-order,
data-sv-distance, data-sv-from/data-sv-to become the
matching CSS variables on mount. No style attribute, safe for mapped CMS content, zero
global CSS. (React props on <Item>/<Reveal> compile the
same way; sv-stagger on a parent needs no per-child anything.)
Vanilla: import { scan } from 'scrollvars'; scan(): same attributes.
React components: <Reveal auto>, <Track pin>,
<Scenes count={4}> when you want props instead of attributes.
The variables (what the driver writes)
| 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).
Plus the class .sv-live (inside the band; enter/exit lines default 75%/25%,
tunable per element via enter/exit or
data-sv-enter/data-sv-exit) and the no-JS guard: hiding styles apply
only under html.sv-on, so without JavaScript the page renders complete.
One knob, four ways (de/para)
Same result (a child rising second, 3rem of travel) pick the authoring style that fits the context. You never need the style attribute unless the value is computed in JS at runtime:
| context | before (works, but…) | after (preferred) |
|---|---|---|
| HTML / CMS / RSC | <p class="sv-rise" style="--sv-order: 1; --sv-distance: 3rem"> |
<p class="sv-rise" data-sv-order="1" data-sv-distance="3rem"> |
| React | <p className="sv-rise" style={{ '--sv-order': 1 }}> |
<Item order={1} distance="3rem">…</Item> |
| Tailwind, static value | <p class="sv-rise" style="--sv-order: 1"> |
<p class="sv-rise [--sv-order:1]"> |
| Sequential list | an order per child, by hand | <div class="sv-stagger"> on the parent, nth-child does it, zero per-child anything |
| Mapped data (CMS loop) | style={{ '--sv-order': i }} |
data-sv-order={i}. Attributes interpolate; Tailwind classes don't (the JIT scans statically) |
| sv-range slices | style="--sv-from: .3; --sv-to: .7" |
data-sv-from=".3" data-sv-to=".7" |
The attributes (data-sv-order, data-sv-distance,
data-sv-from, data-sv-to) are written once as the matching CSS
variable on mount by scan()/<ScrollVarsBoot />. Never in the
frame loop, zero global CSS. When typed CSS attr() settles cross-browser this
mapping becomes pure CSS.
Every export
scrollvars (vanilla core)
track(el, opts?) | drive one element; opts: view/travel/pin/scenes/snap/once/root/enter/exit + onLive/onScene/onTravel/onPin/onFlow. Returns untrack. |
scan(root?) | zero-wrapper mode: tracks every [data-sv], MutationObserver keeps route changes covered. |
slider(el, opts?) | featherweight carousel on native scroll+snap; returns next/prev/goTo/seek/active/state/destroy. |
toggles(root?) | click states: data-sv-toggle/data-sv-target → class + --sv-state + aria-expanded. |
trackPointer(el, opts?) | pointer module: writes --mx/--my on .sv-tilt matches. |
scrollToScene(el, i, n, smooth?, root?) | scroll the window, or the given inner scroller, to scene i of a pinned section. |
setPageOutputs(on) | force the document-wide --sv-page and --sv-v on or off instead of the automatic consumer detection. |
split(el, { by }) / splitParts | SplitText-lite: word/char spans with --sv-order + --sv-count, aria-safe, restorable. Also zero-wrapper via data-sv-split. |
mapRange(t, from, to, ease?) | JS twin of sv-range for onTravel/onPin consumers. |
clamp / snapProgress / easeOutCubic / refresh / prefersReducedMotion / setMotion / onMotionChange | utilities; the last three read, set and watch the effective motion preference (the OS setting or data-sv-motion="reduce" on <html>). |
scrollvars/react ('use client' wrappers. Children stay RSC)
<ScrollVarsBoot /> | mounts scan() + toggles() once. |
<Track> <Reveal> <Parallax> <Item> <Scenes> <Split> | attribute API, order/distance/stagger/duration/ease props compile to the vars. |
<Slider> <Slide> <Marquee> <Accordion> <Modal> | the component kit (see the accessibility contract below). |
useTrack / useScenes / useSlider / usePointer / useCanvasEffect | hooks under the components. |
scrollvars/canvas · scrollvars/compat · scrollvars/debug
mountEffect(canvas, effect). Lifecycle harness for ambient canvas scenes
(DPR cap, delta-time loop, auto-pause offscreen/hidden, reduced-motion flag).
scrollvars/compat: opt-in legacy floor (~Chrome 61/FF 60/Safari 11): RO/IO stubs +
transform-fallback CSS.
scrollvars/debug. Dev overlay: debug() lists every tracked element with its live
variables, outlines it, click scrolls to it, plus a perf HUD (FPS, dropped/late frames, worst frame,
long-animation-frame blocking where Chrome supports it), { markers: true } for ScrollTrigger-style
start/end lines, and a perf lint (default on) flagging a --sv-* read landing in a non-compositable
property. ?sv-debug on a page with <ScrollVarsBoot /> mounts it.
Preset vocabulary
Each name is a class; live previews with copy-paste code in
the fx gallery. Import only the parts a page uses
(gzipped: styles/core.css 3.1 KB · pin 3.4 · slider 1.6 · tilt 0.7 · state 2.3 · ui 1.5).
| part | classes |
|---|---|
| core (entrances) | sv-rise sv-fade sv-slide-l sv-slide-r sv-auto sv-stagger sv-skip sv-split sv-split-rise sv-drift sv-spread sv-spread-in sv-view-fade sv-view-rise |
| pin (scrub) | sv-stage sv-curtain-l sv-curtain-r sv-rail sv-deck sv-reading sv-counter sv-range sv-range-rise |
| slider | sv-slider sv-cols sv-active sv-arrow sv-dots sv-dot sv-pause + --sd per slide |
| state | sv-open sv-pop sv-words sv-acts (a scroll-driven acts clock needs core.css too) |
| ui | sv-marquee sv-accordion |
| tilt | sv-tilt + --sv-tilt (angle) |
Coming from GSAP + ScrollTrigger
| you write in GSAP | here | notes |
|---|---|---|
scrollTrigger: { trigger, scrub } | data-sv-travel → style with --sv-t | CSS consumes the var; no tween object |
pin: true | tall wrapper + position: sticky + data-sv-pin | the sticky skeleton is yours (explicit, SSR-safe) |
start/end: 'top 80%' | enter/exit options (fractions) | band lines, not arbitrary expressions |
tl.to(a).to(b, '-=.2') scrubbed | sv-range: --sv-from/--sv-to per child | overlapping slices = the choreography |
ScrollTrigger.batch() reveals | sv-auto / sv-stagger | nth-child order for free |
scroller: el | root: el | nested scroll panels |
onUpdate(self.progress) | onTravel/onPin callbacks | canvas, WebGL, video scrub |
| timelines with springs/exits/SVG morph | keep GSAP | time-driven work is out of scope here. Honestly |
When NOT to use ScrollVars
- Time-driven orchestration. Interruptible springs, layout/exit transitions, SVG morphing, choreographed intros on a clock: GSAP or Motion are the right tools.
- One load-in animation. Plain CSS keyframes; no library at all.
- App-like gesture physics (drag with momentum between arbitrary states), Motion.
- Chromium-only projects that can require the native tier. Pure CSS scroll-driven
animations, no JS; ScrollVars'
sv-view-*tier is exactly that where supported.
Interop: ScrollVars alongside GSAP on one page
They don't conflict. Different writers on different properties. Keep each element owned by
exactly one engine. GSAP can also consume the vars for the rare mixed case, through a
guarded onTravel callback, motion aware and cleaned up on stop(), never
a bare per-frame style read with no stop and no reduced-motion branch. The page pays GSAP's
bundle then. Do it for the page that needs it, not globally.
Both patterns exist as official fx recipes with live previews:
GSAP timeline under scrub (author in time-space, consume as
a scrub. Still input-driven) and Three.js scene on the pin
(the canvas harness with context: null owns the lifecycle, Three owns the WebGL,
scroll feeds progress). Hitting the scope ceiling never means rewriting: the driver stays, you
plug in what's missing.
Accessibility contract
| surface | guarantees |
|---|---|
| every entrance and scrub preset | hiding gated on html.sv-on (no-JS = fully visible; disclosures, sv-pop, Accordion and Modal keep their closed state without JS like the platform's own details and dialog); complete prefers-reduced-motion blocks: entrances render final state, scrub presets settle at end state, deck lays out in flow; every block has a twin under html[data-sv-motion="reduce"], the page's own switch (setMotion()), kept in step by a test and checked rendered in CI |
| motion preference | one effective value, the OS setting or the page switch, read live by the driver, the slider's glide (a running glide settles when it flips), canvas effects (fx.reducedMotion) and the React Slider autoplay (starts paused under reduce; the visible control resumes it) |
| Slider | APG carousel: role=region + aria-roledescription=carousel + label prop; slides annotated "i of n"; arrows/dots labeled; keyboard: arrows/Home/End on the focusable track, arrow keys inside form fields stay theirs; autoplay pauses on hover, keyboard focus, offscreen and hidden tab, renders a visible pause/resume control, a visually hidden role=status outside the track reads "Slide N of M", aria-live=off while rotating; drag never steals plain clicks or focus |
| Marquee | duplicate copy aria-hidden + inert; pauses on hover and keyboard focus-within; reduced motion stops it |
| Slider dots | the kit's dots are 24 by 24 CSS pixel buttons (WCAG 2.5.8, --sv-dot-target) that never shrink, a crowded row wraps, with the visual dot drawn inside (--sv-dot-size); no transition under reduced motion. Measured in CI. A renderDot of your own is yours to size |
| Modal / Accordion | native <dialog> / <details>. Focus management, Escape, exclusivity from the platform |
| pinned scenes | native scroll is never hijacked. The driver only reads; snap is optional and never mandatory on pins |
| keyboard reach | a gate tabs through every gallery page and the home page, forward and back, and requires each focused element to be seen: in the viewport, effective opacity at least 0.5, visibility: visible, not covered at the center of its visible part, still so 250ms later on the way forward. Proved able to fail on a fixture; the pages set scroll-padding-top under their fixed header |
| reflow and zoom | at 320 and 640 CSS pixels of width every gallery page and the home scroll in one direction only, every [data-sv-fit] box fits its stage or has released the pin, and the pinned pages pass the keyboard gate at 320. Proved able to fail on a fixture |
| axe | every gallery page and the home page pass axe-core (WCAG 2.x A and AA) with zero violations in CI, after boot and again after a walk down the page a viewport at a time, at the bottom, the middle and the top; the gate proves it can fail. A floor, not proof |
Browser support: and the answer for older ones
| browser | fully animated | notes |
|---|---|---|
| Chrome / Edge | 104+ (Aug 2022) | native zero-JS sv-view-* tier: 115+ · sv-range needs 112+ · Accordion height animation 129+ |
| Firefox | 78+ (Jun 2020, :is()/:where()) | sv-counter 128+ · sv-range 112+ |
| Safari / iOS | 14.1+ (Apr 2021) | sv-counter and sv-range 16.4+ |
| anything older, or no JS | content 100% visible, static | the html.sv-on guard for no JS. With JS running below the transform floor and without compat(), pin.css's own net keeps the stage, curtains, deck and rail in flow and readable (see below); with compat() installed, curtains and rail keep their fallback animation instead, but a stage holding a static deck still returns to flow, wrapper height included, so every card stays reachable either way; compat()'s rail fallback ignores --sv-rail-start and starts at translateX(0) instead of offscreen, so it is stationary whenever the track's own width equals the viewport |
The design rule that makes this table safe to sign off: below the floor nothing
breaks. Skip compat() and the page renders complete and static, nothing
overlapping or clipped (curtains parted, deck unstacked, .sv-stage back in flow;
sv-rail's own exception is below); call it and the page animates instead, on
roughly Chrome 61+ / Firefox 60+ / Safari 11+. Animation is progressive enhancement, never a
dependency. Presets that lean on newer CSS (sv-range, sv-counter)
degrade to their end state individually.
Below the transform floor, with JS still running, styles/pin.css carries its
own @supports not (translate: 0) net, for four of its rules: the stage, both
curtains, the deck and the rail, all scoped to html:not([data-sv-compat]) except
the deck. The curtains sit parted and static rather than animated, the deck unstacks to a
static, non-overlapping layout, the rail's track wraps instead of running off the right edge,
and the stage resets to flow so nothing is clipped by the stage itself (sv-reading,
sv-range and sv-counter need no net of their own, they settle for
unrelated reasons). With compat() installed (its own data-sv-compat
marker on <html> is the switch) the stage stays pinned instead, and the
module's fallback sheet animates the curtains and rail from --sv-pin, measured
off that stage; compat()'s own sv-rail fallback still ignores
--sv-rail-start and starts at translateX(0) instead of entering from
offscreen, so it is stationary whenever the track's own width equals the viewport. A stage
holding a static sv-deck returns to flow either way, compat installed or not
(the driver marks it data-sv-flow below the floor regardless), so every card
stays reachable and nothing sits past a clip. One more caveat until ADU-150
lands: a released stage can also leave a parked curtain panel sitting outside it, extending
the document so a reader can scroll sideways to an empty panel.
Older targets: scrollvars/compat, opt-in. On modern browsers it runs
three feature checks and exits (free); on old ones it installs ResizeObserver/
IntersectionObserver stubs and a transform:-based fallback stylesheet for
the reveal presets (sv-rise, sv-fade, sv-slide-l, sv-slide-r, sv-auto, sv-drift) and the pin presets sv-curtain-l, sv-curtain-r and sv-rail, written
without :is()/clamp()/min() (the one
max() left, drift's fade, sits behind a plain opacity
declaration that old parsers keep). sv-deck unstacks to a static,
non-overlapping layout instead of animating (its fly-away slice needs
clamp()); sv-spread stays static below the floor too, no fallback
rule, its rule parses fine but has no translate/rotate to apply
down there. sv-split-rise has no fallback rule either, but its floor is not one
line: below :is() support its animating rule, written with :is(), is
dropped whole by a parser that predates it, fully static; between :is() support
and the individual-transform floor the rule still matches and its opacity
declaration still transitions, so the text fades in without rising. With your bundler
downleveling the ES2020 dist (Next.js already does), the reveal and pin presets above animate on roughly
Chrome 61+ / Firefox 60+ / Safari 11+:
import { compat } from 'scrollvars/compat'
compat() // once, before anything else
Troubleshooting
| symptom | cause → fix |
|---|---|
| content hidden until first scroll | entrance CSS not gated on .sv-on. Use the shipped presets or copy their guard pattern |
| Tailwind arbitrary class does nothing for mapped data | the JIT never generates interpolated class names. Use data-sv-order={i} (or a style var) for dynamic values; static one-offs like [--sv-order:1] are fine |
| animation inside a modal/panel doesn't run | tracked element lives in a nested scroller. Pass root: scrollerEl so geometry matches (the listener already hears it) |
| rotate/translate combo lands wrong | individual transform properties apply in fixed order (translate→rotate→scale). Radial math needs the transform: shorthand |
| parallax rubber-bands | never put a transition on a property driven by a continuous var. Transitions are for state flips (sv-live), continuous motion is direct |
--sv-r stuck at end state | engine lacks calc() division by var (needs Chrome 112/Safari 16.4/FF 112). Expected degradation; keep consuming as var(--sv-r, 1) |
| style-recalc heavy on giant sections | an inherited var pays per-descendant. Keep tracked elements thin: big static content lives next to, not inside, the animated elements (measured curve on /bench/) |
Changelog
Unreleased
1.19.1 (2026-10-01)
Changed
- The
addCLI now prints the absolute destination of the copied component, so running it inside a frontend package in a monorepo makes the import target clear. The Cube windows gallery recipe and guide now put installation before the local React import, explain--dirrelative to the current package, show the full component source for manual copying, and distinguish gallery recipes from package exports.
Added
- fx gallery recipe: "Wall to floor fold" (
wall-floor-fold), a copy-paste-only Pinned scenes effect, zero bytes in the core. Content scrolls down a wall and, at the fold line, continues onto a floor in perspective toward the viewer, driven entirely by the pin helper's--sv-pin(no scroll math of its own). The floor is a second, non-interactive copy of the same content,aria-hiddenandinerttogether so it never reaches assistive tech or the tab order; the wall stays the only readable copy under no JS and under reduced motion, where the effect goes flat and the floor never renders. Geometry (depth, eye, floor angle, fold line, travel) is exposed as--sv-fold-*custom properties for tuning. Ships CSS, Tailwind and React panes plus an installedWallFloorFoldcomponent (npx scrollvars add wall-floor-fold).
Fixed
ScrollVarsBootand responsiveSliderno longer report hydration mismatches in Chromium when their script/style tags use strict-CSP nonces. Chromium deliberately hides active nonces from parsed DOM attributes, so React compared empty attributes with request nonces during hydration; both elements now mark that browser-normalized difference as expected while retaining the server nonce and pre-paint execution.- A click owned by a scoped
toggles()instance also syncsaria-expandedon every other live instance's matching triggers, including<ScrollVarsBoot>'s document scope; a sibling write that threw during that sync used to rethrow into the sibling's own lifecycle guard, which stopped it entirely (click listener removed,MutationObserverdisconnected), for the rest of the page's life, over a write it never owned. The sibling failure now rolls back and reports once without stopping the sibling. verdictForPage's coverage check counted DISTINCT rep ids without checking which ones they were, so rep ids{0, 1, 99}passed a declaredreps: 3though repetition 2 never ran. It now requires the exact id set0..reps-1. Its per-delta check also rejected a rep only when EVERY delta was non-positive, so a rep with some zero orNaNdeltas mixed among good ones passed (a zero dilutes the late-frame percentage;NaN <= 0is false). Every delta must now be finite and strictly positive. The published Chrome and Firefox lab captures still pass.scrollvars/debug's HUD never calibrated its refresh interval on a page sustained below 50Hz from load (no delta ever fast enough to lock the reference), so it showed "calibrating… refresh unknown" forever, hiding the fps it could already measure. After 5 seconds with no qualifying delta the HUD now reports the measured fps with "refresh unverified" instead of staying silent; a healthy stream that calibrates normally is unaffected.- A tracked child of
sv-auto(<section data-sv class="sv-auto"><p data-sv>, itself.svor[data-sv]) translated its own box while hidden, the same geometry feedback loopsv-rise's self-tracked case already had fixed: the driver measures that same (now-moved) box to decide live/hidden, so near the exit line hiding moved it back INTO the activation band and flipped it live again, measured 65 spurious flips scrolling through and back. The child translate rule instyles/core.cssand the compat fallback sheet now excludes a child that is itself tracked, the same:where(:not(.sv):not([data-sv]))shapesv-risealready used; it still fades via the opacity rule. Verifier fix: the exclusion added to the compat fallback sheet's RESET rules (the reduced-motion block, thedata-sv-motion="reduce"twin, the:focus-withinoverride) also stopped them matching the tracked child, which then kept its entrance opacity/transition instead of settling, measured opacity 0 with an 800ms transition under reduced motion. Each reset rule now also carries a plain (:is()/:where()-free, matching compat's own parse floor) unexcluded arm for the tracked case. - A keyboard focus landing inside
sv-view-fade/sv-view-risenear the entry edge of the viewport could stay transparent: Chrome scrolls a partly-visible focus target just into view and stops (it never centers one it already counts as visible), so the native scroll-driven entrance animation can still be mid-range when the focused box lands on screen, measured opacity 0.00 (sv-view-fade) and 0.28 (sv-view-rise), with and without JavaScript (WCAG 2.4.7/2.4.11).styles/core.css's@supports (animation-timeline: view())block now adds a:focus-withinoverride that stops the animation and restores full opacity, no translate. - The home page's own hand-kept copy of the preset tier (
demo/index.html) had no:focus-withinoverride for its entrance presets, unlike the shippedstyles/core.csssince PR #112: its only focusable entrance child, the canvas code block in#amb-section, could be tabbed onto mid-fade while the section was not yet live. Added the same override to the page's own copy. - A self-tracked
sv-rise(the tracked element carries the class itself, not a child, either with no separate ancestor tracker or nested inside one) translated its own box while hidden, and the driver measures that same box to decide live/hidden: near the exit line, going hidden moved the box back INTO the activation band, which made it live again, in a loop (a regression introduced with the compound self-tracked entrance selectors, unreleased). Measured: 34 spurious flips scrolling down through the exit line and 30 back up for the plain case, 32 each way for a self-trackedsv-risenested inside another tracker. The self-tracked case (either shape) now fades in place, with no translate, instyles/core.cssand the compat fallback sheet; a plain (untracked) child of the tracker still gets the full rise. styles/pin.css's no-JS resets for curtains, rail and deck are written with:is(): an engine with individual transforms but no:is()(Firefox 72-77, below the README floor of Firefox 78) drops the whole rule, so without JS a curtain stayed closed, a deck stayed stacked and a rail stayed offscreen. Plain-selector twins now sit next to each:is()rule.scrollvars/debug's HUD calibrated its refresh interval from the fastest frame delta in its opening second with no check that the window was quiet: a page sustained at 30fps from load (every other vsync missed on a 60Hz display) calibrated its own drop rate as "healthy", reporting 0 dropped frames. The HUD now only locks its calibration onto a delta fast enough to prove 50Hz or better; a sustained slow opening keeps showing "calibrating" instead of a false-healthy reading.styles/pin.css's page-switch layout release (stage release, curtains hidden, rail wrapped) is written behind:where(), so the whole rule dropped on an engine without:where()/:is()support, the same floorscrollvars/compat's own fallback sheet targets. On that floor, switching todata-sv-motion="reduce"only reset the compat curtains'transformtonone, their CLOSED position, so a curtain past its opening scroll position stayed covering the content for good and the stage never released. The compat fallback sheet now carries a:where()/:is()-free twin of the same three resets.- A self-tracked
sv-spread-in(the tracked element IS the spread, no separate ancestor) never matched the scrub and transition rules, which required a separate.sv/[data-sv]ancestor: the cards stayed stacked at the container's center indefinitely, overlapping each other. The scrub, transition and both reduced-motion rules now also match the compound (self) case, the same gap already closed for the other entrance presets. - A focused marquee item could be translated well outside the visible strip, with no way to scroll it back into view:
:focus-withinonly paused the track's slide, and a focused link mid-loop sat wherever the animation left it (WCAG 2.4.7/2.4.11). While the TRACK itself now holds focus,.sv-marquee-track:focus-withinpresents it statically and wrapped with the duplicate hidden, mirrored in the gallery's CSS pane. Keyed on the track, not.sv-marquee, so focusing a controlled marquee's pause button does not reflow the strip. - A focused control inside
.sv-driftkept the scroll fade: a fully visible link at the viewport edge read opacity 0.51, never hidden but still dim under WCAG 2.4.7..sv-drift:focus-withinnow restores full opacity, instyles/core.cssand the compat fallback sheet. CaseStudyRail's fallback below thecqwcontainer-query floor turned the stage into a horizontal scroller with notabindexand non-interactive cards, leaving every card past the first unreachable by keyboard in engines that do not auto-focus scrollers (WCAG 2.1.1). It now wraps the rail in flow instead, like the no-JS layout.- The document scope's late-marquee
MutationObserverpre-check,hasUnleasedTrack(), gated the WHOLE callback body, so on a page with NO marquee anywhere a plain[data-sv-toggle]trigger mounted after boot (an SPA menu button, the common route-mount shape) never ranbootTriggerat all: it carried noaria-expandedand no initial--sv-stateuntil its own first click (WCAG 4.1.2, a regression introduced by the marquee-free perf pass that first added this pre-check). The callback now also runs when the batch's ownaddedNodescontain a new trigger, marquee or not, at the cost of onematches()/querySelector()pair per added element (a batch that adds no element at all, text or attribute-only, still costs nothing). Measured withtest/toggles-mutation-cost.mjs, a 500-element unrelated insert on a marquee-free page: before, 0ms (skipped entirely, at the cost of the bug above); after, 0.138-0.172ms median over 7 runs on a quiet machine (load 0.9/1.4/1.8, well under the load-check rule's ceiling of 6). - The compound (self-tracked) entrance selectors added for
sv-rise,sv-fade,sv-slide-landsv-slide-rmatched the entrance rules but not either reduced-motion override (the OS media query or thedata-sv-motion="reduce"twin), instyles/core.cssand the compat fallback sheet: a self-tracked entrance (the tracked element IS the preset, no separate ancestor) stayed hidden and then animated a full travel distance under reduce, a regression against README's and AGENTS.md's "every preset sheet honors reduced motion". Both overrides, in both sheets, now also match the compound case. sv-split-risenever matched a self-tracked element (<h2 data-sv data-sv-split class="sv-split-rise">, the tracked element IS the split preset): its entrance rule required a separate ancestor.sv/[data-sv], so the spans stayed at opacity 1, fail-visible but never actually animating. The entrance rule and both reduced-motion overrides now also match the compound case.- The staggered-reveal and split-reveal gallery CSS panes pasted the entrance hide, transition and reduced-motion rules from
styles/core.cssbut not its:focus-withinoverride: a reader who used only the pasted CSS left a focused entrance child (or a focused split heading) waiting out its stagger delay and transition instead of appearing at once (WCAG 2.4.7). Both panes now carry the override, placed after the reduce blocks. demo/bench/harness/a11y-tree-gate.mjs's negative counterpart for the slider's status region read the status node's own accessiblename, which Chrome always reports empty (the actual text sits on a child), so the check passed whether or not the announced text changed and proved nothing about the detector. Both the real check and its negative and positive counterparts now share onestatusSays()helper that walks the whole status subtree, like the real check does.toggles()'sresolve()looked up adata-sv-targetclass selector against the WHOLE scope: under a document scope (<ScrollVarsBoot>, the css pane's owntoggles()), two controlled marquees sharing the class selector.sv-marquee-trackboth resolved to the FIRST track on the page, so a second marquee's own pause button stayed hidden and, if clicked programmatically, paused the first marquee instead. A trigger inside a.sv-marqueewrapper now resolves its target inside that wrapper first, falling back to the scope for every other trigger.- The document scope's late-marquee
MutationObserverpre-check asked only "is any live track unleased", so a track inserted in one task and its pause button in a LATER one (two separate mutation batches) found every track already leased on the button's own batch and returned early: the track never gotsv-ui, and the button stayed hidden behind ui.css's.sv-marquee-track.sv-ui + .sv-marquee-pauseguard forever. The pre-check now also passes when a live pause button's resolved track lackssv-ui, still zero iterations on a marquee-free page. - The docs page's GSAP interop example used
gsap.ticker.addto read--sv-tdirectly with|| 0, the same fail-hidden pattern already removed from the gallery's tailwind pane: on release the driver clears the property, sending a.from({ opacity: 0 })timeline back to its hidden start, with no reduced-motion guard, and reduced motion keeps--sv-tupdating so GSAP keeps moving too. The snippet is dropped from the paragraph, which already links the guardedgsap-scrubrecipe two lines below. useScenes/useSlider"re-render only on a discrete index change" was repeated at AGENTS.md's own "why the numbers come out this way" section and twice indocs/guide.md, missed by the gate that only checked AGENTS.md's earlier sentence (already corrected). All three now say what is actually true: re-render only on discrete events (index, status, flow, motion).- AGENTS.md and the guide said any CSS mention of
--sv-page/--sv-v, "avar()read, a declaration, even a comment", turns publishing on; the driver excludes its own inline writes on<html>from that check (only avar()read there counts, never a bare declaration), or a rescan would read its own output as a consumer and never unpublish. Both surfaces now name the exception. - The React
<Slider>announced nothing when a person navigated to another slide: its rail carriedaria-live="polite", but navigation only ever wrotescrollLeft, a class and a custom property, none of which is text a live region reads out. A visually hiddenrole="status"region now sits outside the rail, reading "Slide N of M" afternext/prev/goTo/a dot click, silent (aria-live="off") while autoplay rotates on its own; the rail no longer carriesaria-liveitself. The vanillaslider()has no live region of its own and is unchanged. - The React
<Slider>'s rail rendered with notabIndex, so before hydration, without JS, or after a failed enhancement, the server-rendered carousel was keyboard-reachable only in engines that focus a scrollable region themselves; Safari does not, leaving slides past the first unreachable. The rail now renderstabIndex={0}directly.slider()already leaves an existingtabindexattribute alone, so hydration does not double-write it. styles/core.css's focus override for a focused Split heading targeted.sv-split-rise > span:focus-within, a selector that can never match: the word spans arearia-hiddenand never hold focus, only their container does. Focusing a<Split tabIndex>heading left every span at opacity 0. Fixed to.sv-split-rise:focus-within > span, keying off the container's own:focus-within.- Every entrance preset selector (base hide, translate and the focus-visibility override, in
styles/core.cssand the compat fallback sheet) required the preset class to be a DESCENDANT of.sv/[data-sv]: a self-tracked element, where the tracked element IS the preset (<pre class="sv sv-rise" tabindex="0">, ordata-svandsv-riseon the same node), never matched at all, so it stayed at its unstarted, partially-faded state indefinitely, focus included. Every rule now also matches the compound (self) case. toggles()'s document-scopeMutationObservercallback wrote late boots (a route-mounted marquee's track and pause button) into the same standingtransaction/siblingWritesjournals a click uses, only ever reset by the NEXT click anywhere in the document. Under<ScrollVarsBoot>, a page navigated without a click (programmatic routing, popstate) kept every late-booted write strongly referenced indefinitely, and a throw partway through one delivery's batch left the earlier writes of that same batch in place, unlike setup and click, which both roll back. The callback now gets its own fresh journals per delivery, rolls back on a throw and resets infinally, exactly likeonClick.- A
[data-sv-toggle]trigger mounted before itsdata-sv-targetever resolved (a menu button rendered ahead of the nav it controls, a common SPA mount order) never gotaria-expandedor--sv-stateeven once the target arrived in a later mutation batch:bootTrigger()just returns on an unresolved target, and only newly inserted triggers were ever retried. The document scope now keeps a per-instance set of unresolved triggers, retried on every later batch until each one resolves (dropped once it does, or once it disconnects), so a trigger and its target arriving in two separate batches now agrees with the class from the first frame, matching the one-batch case already fixed. The deliberate setup-time skip (markedsv-uionly on first click when neither the trigger nor aMutationObservercan retry it) is unchanged. Retrying every pending trigger inside ONE shared transaction meant a single trigger's write throwing rolled back a SIBLING pending trigger's own successful resolution in the same delivery too, and stopped the retry loop outright, so any trigger later in iteration order never even got tried. Each pending trigger now retries in its own transaction: one failure cannot touch a sibling's writes or block the rest of the batch. A trigger whose target resolves but whose write keeps throwing (an already-resolved target will not heal itself; there is no backoff machinery) is dropped from the pending set and reported once, instead of being retried, and reported again, on every later mutation forever. - Both
toggles()'s document-scopeMutationObservercallback and its click handler rolled back a failing write and then rethrew, whichlife.guardturns into a permanentstop()of the WHOLE document scope over one bad write: the click listener is removed and theMutationObserverdisconnects for the rest of the page's life, contradicting the module's own "the document scope never stops" design. Both now report the error directly instead of rethrowing, so a later click on a working trigger, or a later-mounted trigger, still works. - Removing an already-offscreen marquee's whole subtree (an SPA route swap taking its page away) got no
IntersectionObserverdelivery for it (an already non-intersecting target reports no further transition on removal) and never opened the document scope'sMutationObserverpre-check either (no unleased track, no new trigger, no pending trigger): the lease, the sharedIntersectionObserverand thevisibilitychangelistener sat alive over a detached subtree until the user's next click, tab switch or marquee registration. A removal-only batch (any mutation record whoseremovedNodesis non-empty) now sweeps every leased marquee for detachment itself, before the pre-check can bail; a page with no leased marquee at all pays nothing extra. toggles()'s document-scopeMutationObservercallback booted every NEW trigger of one delivery inside one shared transaction: a write throwing on one trigger rolled back a sibling trigger's already-written state too, and stopped the loop outright, so any trigger later in the batch's iteration order never even got tried, all reading noaria-expanded/--sv-stateeven though only one of them ever failed. Each new trigger of a delivery now boots in its own transaction, the same isolation the pending-trigger retry already had: one failing write is dropped and reported once, and every sibling trigger of the same batch still boots normally.- The shared marquee
IntersectionObserverandvisibilitychangecallbacks ran with no failure boundary of their own: a throw from a patched or exotic element'sclassList(the only way either callback can throw at all) would have stopped the batch before settling any marquee after it. Both are now wrapped per marquee, so a throwing track is reported once and every other marquee in the same batch still settles, matching AGENTS.md's "one bad toggle should not disable every other toggle in the same scope" for these two shared, instance-independent callbacks.sweepDetachedMarquees(), called from both of those callbacks and from the document scope'sMutationObserver(its own C2 removal sweep above), had the same gap one level deeper: a throw pruning one detached track reached theMutationObserver'slife.guardand stopped the WHOLE document scope, or skipped every OTHER entry of the sameIntersectionObserverbatch. Each pruned track is now guarded inside the sweep itself.
Docs
- AGENTS.md said any custom
--sv-rconsumer, not only the ready-madesv-range-risepreset, settles to its end state below thesv-rangefloor, contradicting the more precise Sequenced scrub paragraph a few lines above (a custom consumer falls back to its own initial or inherited value instead). The floor sentence now namessv-range-riseand points to that paragraph. docs/guide.mdand the generated docs page still called the slider's TRACK the live region after the rail lostaria-livein favor of a separaterole="status"region outside it. Both now describe the status region instead.- AGENTS.md and
docs/guide.mdsaid runtime failures "stop only that instance" across every stateful module, which used to be true oftoggles()too (a throwing click killed its listener for good, on any instance, and the document scope'sMutationObserverfor late-boot as well). Both now carve out the exception described above, corrected in a follow-up pass to name everytoggles()instance, not only the document scope: a probe against a scoped root's own click handler found the same report-and-continue behavior there.
1.19.0 (2026-09-27)
Added
- Author-side evidence for the outstanding "path to 8/10" review items (docs and CI only, no public surface change): an accessibility-tree proxy gate (
demo/bench/harness/a11y-tree-gate.mjs, wired intonpm run test:e2e) that reads Chrome's accessibility tree over the Slider, Modal, Accordion, toggles, split text, Scenes fallback and the Marquee pause control, every check proved able to fail on its own deliberately broken analog; a dated, fill-in VoiceOver checklist for the author's manual macOS and iOS pass (docs/guide.md, Accessibility section); and a physical-device frame budget matrix (docs/guide.md, stamped fromdemo/bench/lab/results/published/*.jsonby the newscripts/lab-tables.mjs) with a frame budget declared before any number is read (LAB_FRAME_BUDGET,scripts/docs-data.mjs), linked from AGENTS.md's performance paragraph. Desktop Chrome and Firefox runs are committed; Safari desktop and iPhone Safari are explicit pending placeholders, not fabricated rows. The lab (demo/bench/lab/run-drive.js) now calibrates each run's vsync interval independently (an idle rAF window before the scroll starts), instead of deriving it from the same scroll frames the budget then judges; the two committed runs predate that field and are labelled self-calibrated in the table rather than silently upgraded. The table also reports each page's worst rep alongside its median. - A served accessibility checklist fixture,
/a11y/(demo/a11y/index.html, built by the newscripts/a11y-build.mjsinnpm run demo:sync): the real kit (Slider, a real<dialog>Modal, asv-popdisclosure, Accordion, split text, a real<Scenes>in its stacked fallback, a controlled Marquee and RotatingWords with its pause control), mounted against the shippedstyles.css, deployed at that path. The VoiceOver checklist indocs/guide.mdnow points every row at this page instead of gallery previews that could never pass it (a vanilla slider with no carousel semantics, a<dialog>no page shipped, an Accordion no row named).a11y-tree-gate.mjsruns against the same page: shipped CSS loaded, the Slider's announced position checked after activating "next", and a real<Scenes>fallback checked under reduced motion, each with its own negative counterpart (docs and CI only, no public surface change). scrollvars/debug'sdebug()gained three new options, all off by default excepthudandlint, which is exactly what shipped before minus nothing (the tracked-element panel stays as it was):hud(default true) mounts a performance HUD (FPS, dropped and late frames against the measured refresh interval, the worst frame of the last 5 seconds, and, wherePerformanceObserversupportslong-animation-frame(Chrome), blocking time with the top script attribution; elsewhere it says so instead of showing zeros);markers(default false) draws, for every tracked element, ScrollTrigger-style start/end lines for its travel and, for pinned elements, its pin stretch, labelled and positioned in page coordinates so they scroll with the page and recompute on resize;lint(default true) scans same-origin stylesheets once for a--sv-*read landing in a property that cannot be composited (width, height, the inset family, margin, padding, font-size, filter, backdrop-filter, box-shadow, background-position, clip-path, mask), directly or through one level of custom-property indirection, and reports selector, property and why, once, in the overlay and the console; a cross-origin sheet is reported as skipped, never silently ignored.DebugOptionsgainedhud,markersandlint(minor, additive).- A
.sv-marquee-tracknow pauses itself off screen and with the tab in the background (one shared IntersectionObserver and one shared visibilitychange listener across a page's toggles() scopes and marquees), and resumes on return, independent of the user's own pause button. New class:.sv-marquee-offscreen(public, additive). - The stamped
scrollvars/reactgzip size is now measured with esbuild's real code splitting, entry chunk only: the dev-only?sv-debugoverlay (loaded on demand, never on a page that does not ask for it) is no longer counted toward it. The published number drops from its pre-split figure for this reason, a measurement fix, not because the React layer shrank; see the stamped number indocs/guide.mdfor the current one andscripts/docs-data.mjs'smeasureSizes()for the split build. - A public, installable skill for AI coding agents:
skills/scrollvars/SKILL.md, generated innpm run demo:syncfrom AGENTS.md's own text (mental model, the fail-visible guard, imports, the fx gallery, the performance rules), so it can never hand-drift from the guide it wraps. Shipped in the npm package (files).npx scrollvars skill [--global] [--force]installs it for Claude Code (.claude/skills/scrollvars) and Codex (.agents/skills/scrollvars), matching the installed package version; refuses to overwrite a locally modified copy without--force, idempotent otherwise. Also installable with the opennpx skills add aduptive/scrollvarsconvention (reads a repo'sskills/<name>/SKILL.md). The site hero and the top of the README now carry the one install command. The release gate fails if the packed tarball lacks the skill or its version does not matchpackage.json. toggles()'s document scope now acquires a.sv-marquee-controlledmounted after it starts (a route-mounted marquee under a live<ScrollVarsBoot>, most of the time): aMutationObserver, owned by that one long-lived instance only, wires the late track and its pause button through the same lease-counted paths setup uses, released onstop(). Measured at about 0.16ms of script per mutated frame on a page appending one such wrapper every frame for 120 frames (demo/bench/harness/toggles-mutation-cost.mjs); a custom-root scope (a<Marquee>component's owntoggles(node)) gets no such observer, since it already owns its one track for its whole lifetime. A page with no marquee at all pays an O(1) check per mutation batch instead of a subtree scan: the callback walks the livedocument.getElementsByClassName('sv-marquee-track')collection (empty, zero iterations, on a marquee-free page) and only scans an inserted subtree when it holds a track not already leased, an identity check rather than a bare count, so a route swap that removes one marquee and inserts another in the same mutation batch still wires the new one even though the count never changed.
Fixed
- The physical-device lab table (
scripts/lab-tables.mjs) judged the MEDIAN repetition against a calibration averaged across the whole file, so one bad rep, an errored rep or a missing page could hide behind the others. Every repetition is now judged against its ownvsyncMs(falling back per-rep to that rep's own median when the field predates calibration), and a page needs full coverage (every declared repetition present, each with usable deltas and its own animated check passing) to pass at all.verdictForPageis exported and covered bytest/lab-tables.test.mjs. The table still displays the median and worst rep. verdictForPage's coverage check countedruns.lengthagainst the declared repetition count, so rep ids[0, 0, 1](the same repetition captured twice, the third never run) read as full coverage. It now counts DISTINCT rep ids. A rep whose deltas are all zero or negative, whose ownvsyncMsis non-positive, or whose self-calibrated median vsync comes out non-positive now fails outright as unusable instead of passing against a degenerate (zero or negative) budget.- The a11y-tree checklist proxy's "Slider announces the new current slide after activating next" check was a false positive: every slide's static "N of 3" label is in the tree from the first render, so the check passed whether or not "next" did anything. It now asserts the real per-activation signal, the active dot's
aria-currentmoving from slide 1 to slide 2, proved able to fail against a no-op "next" counterpart. The VoiceOver checklist row for the Slider now names this signal explicitly. - The
gsap-scrubgallery recipe'srequires.min(1.17.0) let older releases install a component that passesonStatusto<Track>, an API that shipped in 1.18.0; on an older install the prop silently lands on the DOM and the fail-visible settle never fires. Raised to 1.18.0. Themarqueerecipe'srequires.min(1.9.0) had the same shape for the pause button, which shipped in 1.14.0; raised to 1.14.0. - The GSAP tailwind pane's "zero-wrapper" alternative used
gsap.ticker.addto read--sv-pindirectly: on release the driver clears the property,parseFloat('')isNaN, and the|| 0fallback sends a.from({ opacity: 0 })timeline back to its hidden start, with no reduced-motion guard. Dropped in favor of the css pane's guardedtrack()/onPincallback, which already covers zero-wrapper pages. - The installed RotatingWords pause button flipped its label ("Pause rotation" / "Resume rotation") while also carrying
aria-pressed, which reads as a self-contradiction to a screen reader ("Resume rotation, toggle button, selected"). The label is now fixed ("Pause rotation"), matching the gallery preview, the kit's<Marquee>pause and the WAI-ARIA APG toggle-button pattern. - A keyboard focus landing inside an entrance preset (
sv-rise,sv-fade,sv-slide-l,sv-slide-r,sv-auto,sv-split-rise) stayed invisible until its stagger delay and transition ran, up to 1.7s for the eleventh staggered child: a WCAG 2.4.7 failure, since a focus indicator on an opacity-0 element is not visible.styles/core.css(and the compat fallback sheet) now carry a:focus-withinoverride, placed after the entrance rules, that settles a focused entrance item or one that contains the focused control to fully visible at once, no delay, no transition. docs/guide.md'suseSceneshook example keyed its stacked branch's current scene byi === scene, the exact identity bug<Scenes>itself was fixed to avoid: the example now uses a plain index key throughout.AGENTS.mdclaimed React "never re-renders per frame, only on discrete index changes"; completion, attachment, flow and motion changes also re-render, none of them an index change. It also claimed the scoped clocks sheet "can only make [a page] faster where it is understood", contradicted by its own measurements two paragraphs above (a flat profile 4 percent slower, several gallery pages slower): both sentences now state only what is actually guaranteed. The guide carried the same scoped clocks sentence and is fixed the same way.- The VoiceOver checklist (
docs/guide.md) said RotatingWords had "no pause control yet"; the pause button shipped in both the preview and the installed component. The checklist now steps through it instead. <Scenes>'s stacked branch (reduced motion, a failed attach, a flowed stage) keyed its "current" fragment byi === scene: the driver keeps reporting scene changes while stacked, so React moved that key between scenes on every one, destroying a focused input or a playing video in a scene the reader had not left. The stacked branch now freezes which index carries "current" the moment the component goes stacked; no key in that branch depends on the live scene, and the active/stacked switch keeps the guarantee it already had (PR #92).<Track>and<Scenes>droppedsv-liveon a completedoncereveal the next time their ownclassNameprop changed: the driver settles a completed entry outside its tracked set and stops re-asserting the class, so a React className rewrite (which replaces the wholeclassattribute) wiped it. Both components now carrysv-livein their own rendered className onceonStatus('completed')fires, resetting on the next'attaching'. A bareuseTrackconsumer has to do the same on its own element (documented in AGENTS.md).- Every GSAP scrub recipe (the installed
GsapScrubcomponent, the rendered gallery preview, and the copy-paste CSS/React panes) only ever moved its timeline throughonPin: under reduced motion, or once the tracker failed or was released,onPinnever fires again, so a.from({ opacity: 0 })timeline stayed stuck at progress 0 forever, a fail-hidden. Every surface now settles to progress 1 on the effective motion preference and on a failed/released tracker, scrubbing only while attached (requires.minbumped to 1.17.0 for theonMotionChangeimport). RotatingWordsrotated forever with no way to stop it (WCAG 2.2.2), and the marquee gallery's preview and CSS/Tailwind panes taught the bare, uncontrolled.sv-marqueeshape with no pause button.RotatingWordsnow renders a keyboard-operable, persistentaria-pressedpause button; the marquee preview and panes render the controlled markup (sv-marquee-controlledplussv-marquee-pause), matching the<Marquee>component.hero-cinematic's own CSS/Tailwind panes (which also embed a bare marquee strip) got the same button.- A
<link rel="stylesheet">whose ownerrorevent the driver observed (a 404, a reset connection, an unreachable host, while the sheet was still pending) kept publishing--sv-page/--sv-von<html>forever for every page with no consumer at all:sheetReadsPageOutputs()'s catch (an unreadable sheet is uncertainty, so it defaults to "assume it reads them") could not distinguish that observed failure from an opaque cross-origin sheet, sofoundlatched true and the watch that would notice a later, corrected href never installed. An observed failure now turns outputs off; correcting the link'shref/rel/media/disabledis judged again. A batch that corrects two links at once now forgets both, not only the first (the attribute-mutation watcher broke out of its records loop early). Deliberately NOT fixed the same way: a link whose sheet throws without the driver ever observing itserror(measured in real Chrome, the sheet can already be a throwing object by the first frame that asks, before any listener attaches) keeps the conservative default, publishing for the rest of the session. A same-origin<link>can throw oncssRulesfor a reason that is not a load failure at all: a 302 redirect to a cross-origin CSS file keeps a same-originhref, firesload, and still throws (CORS opacity on the redirected response), common on reverse-proxied or versioned CDN setups; an earlier version of this fix read any same-origin throw as a failure and broke exactly that case. Wrongly publishing costs a per-frame write; wrongly silencing breaks rendering, so the conservative default wins whenever the driver itself never saw the failure. - The gallery's GSAP React recipe (
gsap-scrub) timed a.from('.stage > *', ...)selector against markup that renders.sv-stage: pasted as shown, the timeline animated nothing. It now scopes to the stage's own ref. - AGENTS.md's
--sv-rparagraph,styles/pin.css's two comments anddocs/guide.md's explanation were wrong in two of the four engine cells (a round-1 fix that reworded the claim without checking each cell against the two floors, calc() division and@property): Chrome 85-111 has@propertybut not the division, so--sv-ris registered and settles at its initial value, 1; Firefox 112-127 has the division but not@property, so--sv-ris unregistered and simply keeps animating, the same as full support; only below BOTH floors (Firefox under 112, Safari under 16.4) is the consumer's own declaration invalid at computed-value time, falling back to its own initial or inherited value, not to 1. Worded to carry the matrix in all four copies. The--sv-page/--sv-vVARS row also read as ambiguous between "(tracked and CSS) or setPageOutputs" and "tracked and (CSS or setPageOutputs)" (the latter is what the driver does); reworded to read one way only. - The
data-sv-fitoverflow check compared the fit node's own height against the whole stageclientHeight, never counting where the fit node itself starts inside the stage (padding-top, a heading above it, its own margin): a box that fit its own height could still clip againstoverflow: hidden(WCAG 1.4.4/1.4.10). The check now adds the fit node'soffsetTop(an offset-chain read, not a bounding rect) when the stage is itsoffsetParent, falling back to the old comparison for a consumer stage without the preset. - A marquee track detached from the document while already off screen got no further IntersectionObserver delivery (no intersection transition to report), so the per-target prune in the IO callback never ran for it: it stayed in the lease bookkeeping, observed, until the next tab switch. The lease map is now swept for detached tracks on every IO delivery batch, every new marquee registration and the document scope's own click handler, no
MutationObserverneeded. - Without native
inert(Firefox 78-111, Safari 14.1-15.4, inside the README floor),StickyStepsstill appliedinert/aria-hiddento inactive shots, and the pause-controlledMarquee's duplicate strip still turned on: without real support the attribute is inert in name only, so links inside an invisible shot or an animated duplicate stayed in the tab order (WCAG 2.4.7/2.4.11). Both surfaces gate on'inert' in HTMLElement.prototypenow:StickySteps(installed and gallery copies) stays static with every shot reachable, andtoggles()never marks a controlled marquee tracksv-ui, sostyles/ui.csskeeps its strip static and the duplicate hidden. toggles()'s marquee release closure captured its track directly; a Boot-owned document scope that never stops kept that closure, and the detached track it held, for the app's life. It now goes through the sameweakRef()helper the markers use, falling back the same way whereWeakRefis unavailable.useScenescarried itsflowedflag from one lease into the next: a replacement target (anyuseTrackdependency change) with no[data-sv-fit]child and no flowed ancestor never gets a re-announcedonFlow, so a lease that once overflowed stayedactive: falseforever, even once it fit cleanly.flowednow resets ononStatus('attaching'), before the new lease's ownonFlowcan land.- Below the individual-transform floor with no
compat(), only a stage carrying a static deck was released to flow: a plain pinned stage (the common Scenes/Sections shape) kept reportingactivewhile pin.css released the stage underneath it anyway, so the pin span collapsed to about one pixel and<Scenes>rendered only its current scene, the rest unreachable. Every stage below the floor with nocompat()now flows too (data-sv-flow,onFlow(true)), matching what a deck-bearing stage already did. <Scenes>rendered only the current scene outside the enhanced, pinned state: on the server, without JS, after a failed attach, and after a fit-to-flow release, the scene index sat at 0 (or jumped straight to it after a helper-pinned stage collapsed its span), so scenes 1..N-1 were never in the DOM, the same content-unreachable shape the reduced-motion fix above closed for one cause and left open for the others.useScenesnow hands backactive(attached, not flowed, not reduced), and<Scenes>renders every scene, stacked, whenever it is not active, matching what it already did for reduced motion; server and first client render agree, sinceactivestarts false.ScenesStategainedactive(minor, additive). The guide'sStoryexample and four doc sentences now use it instead ofreducedalone.- Under reduced motion, a helper-pinned
<Scenes count={4}>collapsed its pin span to about one pixel, so--sv-scene/onScenejumped from 0 to N-1 in one pixel and the guide's own example (Slide {scene + 1}) never rendered the middle scenes.useScenes/<Scenes>now hand back areducedflag, and<Scenes>calls its render function once per scene and stacks the result under reduced motion, so every scene stays reachable.ScenesStategainedreduced(minor, additive). Four doc sentences that claimed the pin/scene clocks keep scrubbing under reduced motion now say a helper-pinned section returns to flow and its pin/scene clocks finish at once, matching what the driver already did. - Marquee's offscreen watch had first-registrant-owns semantics: a second
toggles()scope registering the same.sv-marquee-track(the common<ScrollVarsBoot>+<Marquee>shape) never got its own release wired, so stopping the scope that registered first stranded the track observed forever (a leak on every client-side navigation away from its page), and stopping the OTHER scope stripped the offscreen class and observer from a track the first scope still needed. Fixed with a per-track lease count: unobserve, delete and remove the class only when the last lease goes, and a track that has left the document is pruned (unobserved, its lease dropped) on its next IntersectionObserver delivery or visibility pass, so a document-wide scope that never stops cannot keep a removed track leased forever. A lease also carries a generation number, so a track that is detached, pruned and later reattached under a new scope cannot have its fresh lease released by a stale stop() from before the prune. - Emptying a populated slider (every slide removed) left
state().activepointing at a detached node withcount: 0, so repopulating at the same index fired noonSlide. An empty list now resetsactiveto-1and releases the owned--sv-slide. - A disabled
<link rel="stylesheet">enabled later never turned page outputs on: the consumer watch only observedchildList, so an attribute change (disabledflipping) queued no rescan. It now also observesdisabled/rel/href/mediaon every<link>. - A tracker under a hidden custom scroll root (
display: none) wrote--sv-t: NaNand passedNaNtoonTravel;computeTravelnow holds at 0 when the viewport and element height are both zero, the same guardcomputeViewandpinSpanalready had. - Debug markers ignored the pinned stage's normal-flow origin (a heading or padding before the stage), so both lines were off by exactly that much; they also picked up a nested tracker's stage instead of their own.
pinLinestakes the origin now, and the overlay resolves the owned stage the same way the driver does. - The debug HUD calibrated its refresh rate from the frames it was judging, so a sustained half-rate page (every other vsync) read as a 30Hz display with 0 dropped frames. It now calibrates once, over a quiet opening window, and judges every later frame against that frozen interval; a frame spanning a backgrounded tab is neither a calibration sample nor a dropped one.
- The debug panel used
backdrop-filter, which re-rasters every scroll frame and would have the HUD in the same document measure that cost as the page's own. Solid background now, no filter. npx scrollvars addwith no slug fetched the effect registry before printing usage, so an offline run reported a registry error instead of the command's own help text.- The fx gallery's React panes had six uncast
style={{ '--sv-*': ... }}object literals (TS2353in a strict consumer'stsc, the same shape a previous release already fixed in the installed components); all cast toReact.CSSPropertiesnow, and a new gate checks every pane. - The marquee CSS pane's reduced-motion block only stopped the animation; the real stylesheet also releases the strip's width and hides the duplicate copy, or a pasted marquee under reduce keeps clipping content past the viewport edge. The pane now matches, and the pause covers
:focus-withinlike the real sheet. - The coverflow slider's CSS and Tailwind panes used
abs()(Chrome 104, above this project's stated floor); rewritten to the installed component'smax(x, -1 * x)equivalent, and the Tailwind pane gained a reduced-motion guard it never had. - The docs page's browser-support text still described a rail that stayed unwrapped below the transform floor and a deck that clipped past the stage with
compat()installed; both were fixed in earlier releases (the rail wraps inpin.css's own net, a deck-holding stage returns to flow regardless ofcompat()). Rewritten from the current code. - Three doc inaccuracies: "even a comment counts" toward page-output detection is true only for an inline
<style>, never a linked sheet;--sv-t's claimed parity with nativeview()did not account for its zero inset; the browser-floor line omittedsv-rangeandsv-acts(both settle to their end state below the floor, nothing is lost). - "
npx scrollvars skillinstalls this file" was imprecise: it installs a skill built from the file, which points the agent back here. - Perf phase 1, stop work nobody sees. React
<Slider autoplay>already skipped a tick while off screen or with the tab hidden; thesetIntervalitself now stops there too and restarts on return, instead of waking up every interval on a background tab full of never-visible carousels. A correctness fix ("do no work off screen"), not a headline win: measured on the perf lab'slong.html(marquee and slider both present) the frame cadence is unchanged on this machine, as expected for background work that was already cheap to skip; the actual stopping is what a new test asserts directly, an exactsetInterval/clearIntervalcount. - The site hero's "use it with your AI agent" pill wrapped
npxonto its own line at narrow widths. The command now sits in its ownwhite-space: nowrapspan while the label around it wraps freely; verified at 320, 375 and 1280 with real mobile/desktop viewport emulation (no horizontal overflow, the command never breaks across lines). - AGENTS.md's performance rule 6 recommended
will-change: translateon every scroll-moved element, which our own published benchmark contradicts: hintingwill-change: transform, opacityon the driver's own moving elements raised main-profile task time about 9% and gave no measurable gain on the deep-50 profile (demo/bench/harness/README.md, main-style-css screen), and a separate screen rejected it outright (demo/bench/harness/HYPOTHESES.md). Rewritten from the measured facts: do not addwill-changeby default; add it only when a profile shows a specific moving element repainting every frame, and remove it once the motion stops. The skill and llms.txt follow, generated from the same text.
1.18.1 (2026-09-25)
Fixed
- A failed scan (a throwing ResizeObserver at init, an attachment that throws mid-sweep, or a later mutation batch whose attach throws) left its unprocessed
[data-sv]content hidden at opacity 0 forever wheneverhtml.sv-onwas up: the marker-only settlement (data-sv-off) never lifted--sv-live, and only--sv-livegates the entrance presets.settleUntracked()and the failed-init branch ofattach()now write the inline--sv-live: 1too, deferred the same way thedata-sv-offmarker already is (once nothing tracked is left inside the element);releaseEntry()keeps its own immediate, unconditional write, since a released element's own entrance never depended on a nested tracker's state. A scan that has failed also keeps its mutation observer connected, in settle-only mode, so a[data-sv]node inserted afterward (a CMS block, a route change) settles visible too instead of staying hidden with no observer left to release it. clearReleased()cleared the pendingdata-sv-offmarker on retrack but not the pending--sv-livelift the fix above added: an element settled throughsettleUntracked()while a nested tracker was still live, then retracked before that descendant released, stayed queued for the lift forever, a harmless (same value,--sv-live: 1) but permanent memory retention leak.clearReleased()now drops the element itself, never an ancestor, from that set too.- fx gallery:
CoverflowSliderwrapped every top-level child in its own<Slide>, so a fragment of slides collapsed into one and a child that was already a<Slide span={2}>had its--sv-spanland on an inner div the column rule never sizes by. Fragments are opened recursively now (keyed the same way the kit's own rail does), and an authoredSlidegetscf-slideadded to it instead of a second wrapper. - An outer pinned section that fell back to document flow (its fit box overflowed) released its own
.sv-stagebut left a nested tracker's tall wrapper and static, clipping stage behind:[data-sv-flow] .sv-stage(styles/pin.css) releases every descendant stage, not only the outer one's, so the driver now latches flow on every tracked entry nested inside the one that just overflowed too. - The CLI silently installed from the public registry whenever an explicit
SCROLLVARS_REGISTRYfailed to fetch, which could write a public component under the same slug a private registry meant to own. An override now exits 1 naming the URL and the error instead of falling back. scan()'s removal handling matched a detached node's CURRENT attributes, so strippingdata-sv/data-sv-split/data-sv-pointerbefore removing the node left its tracker, split or pointer lease registered forever. Removal is now driven by the scan's own lease maps (released whenever the element no longer sits inside the scanned scope), independent of what it carries by the time the observer fires.slider()read whether an element's effectivescroll-snap-typewas alreadynonebefore adding the.sv-sliderclass that itself declares one, so an unclassed element (any element not already carrying the class before the call) always read a plainnoneand had its custom drag/wheel settle permanently disabled. The read now happens after the class lands.mapRange()floored a reversed range's span at an epsilon, so every reversed range answered 0 instead of the same ratiosv-range's CSS twin computes:mapRange(.75, 1, 0)returned0, now.25;mapRange(.25, 1, 0)returned0, now.75, matchingstyles/pin.css'sclamp(0, (t - from) / (to - from), 1)exactly. It now divides by the real span, and steps attoonly whenfrom === tohas no ratio to compute at all.toggles()'s click handler resolved a trigger'sdata-sv-targetunguarded, so one click on a malformed selector (a CMS typo) threw, tore down the whole instance, and froze every toggle in that scope in whatever state it was in.click()now tolerates an unparsable selector the same wayboot()already does: it skips that trigger and keeps running.- A tracker attached under an ancestor that had already switched to document flow (a React
<Track>retrack, a CMS block mounted later, a manual retrack) never latched itself: its own stage was already released by[data-sv-flow] .sv-stage, so it could never measure an overflow of its own, and its tall pin wrapper sat orphaned under a now-static stage.attach()now checks for a flowing ancestor and latches immediately. - fx gallery:
cube-windowsdeclaredrequires.min: '1.9.0'while its installed source importsonMotionChange(1.17.0), so the CLI told a consumer on an older install "ok" and the import failed at runtime; the gallery preview's inline script readSVbefore the engine bundle that defines it had loaded, throwingSV is not definedand silently never mounting the effect; and the React component's effect constructed aResizeObserverunguarded, so a browser below the individual-transform floor withoutcompat()threw and unmounted the whole root instead of rendering the content whole.requires.minis now1.17.0, the preview destructuresSVinside itsloadhandler, and both the vanilla and React mount code return early without aResizeObserver(no clip-path, content whole, same contract as every other fallback path). Recategorized fromSectionstoPointer(it renders through the three-format effect pipeline, not the Section preview pipeline). - fx gallery:
DeckSpreadandSequencedScrubwrapped every top-level child individually, the same bugCoverflowSliderhad (round-15 fix, untwinned): a fragment of two cards collapsed into one fan slot / one scrub window, and--sv-midcentred on a card that was not counted right. Fragments are opened recursively now, keyed the same way the coverflow rail already does. refresh()and the resize path refreshed a pinned entry's stage origin one entry at a time, each one togglingposition: static, readingoffsetTopup the chain, then restoring: N pinned entries forced N style recalculations on any document-height change (lazy images, fonts, a feed), not one. The read now happens for every entry after every entry's static override is in place, restoring only once all reads are done, the same read-all-then-write-all shape the pin helper already uses.slider()treated an authoredtabindex="-1"(a follower rail deliberately kept out of the tab order) the same as notabindexat all, since both read-1through.tabIndex, and overwrote it with0. The check now reads the attribute.- The CLI joined an absolute
--dirunderprocess.cwd()(join('/a', '/b')is/a/b), writing the installed file somewhere under the current directory instead of the given absolute path. It now resolves the path instead of joining it. - A
<link rel="stylesheet" disabled>has no parsed sheet either, the same uncertainty signal as one still loading, but nothing ever fires itsload/error: the driver treated it as pending forever and kept--sv-page/--sv-vpublishing for the whole session even with no other consumer. A disabled link is no longer counted as pending. slider()on an empty rail firedonSlide(0)for a slide that did not exist, so when slides arrived later the real first one never got its ownonSlide(0)(index 0 already read as "active"). An empty rail no longer claims an index.
Docs
- Corrected two out-of-date descriptions of the failed-scan and page-outputs behavior above: Boot's watchdog stands down on any successful tracker or scan, not on "completed scanner setup" specifically, and a failed scan now settles its own content visible; and the
--sv-page/--sv-vpublishing trigger is CSS mentioning them (avar()read, a declaration, even a comment), not only CSS reading them. - "JavaScript never animates styles" (AGENTS.md, llms.txt) had a shipped counterexample:
cube-windowswritesel.style.clipPathfrom its own rAF loop. Corrected to note the exception (cube-windows, the canvas effects). - AGENTS.md and
styles/scoped.csspointed at a README hook-examples section, an Accessibility section and a "Scoped clocks" section that moved todocs/guide.mdwhole; corrected to point at the guide. AGENTS.md's ownstyle={{'--sv-order': i}}snippet failed to type-check (an excess property againstReact.CSSProperties); corrected to the cast the library itself uses. Three installed gallery components (HeroCinematic,ThreeScene,PointerTiltGrid) carried a redundant ref cast and a comment claiming a ref hook returnsRefObject<T | null>; every hook has returnedReact.RefObject<T>since ADU-106, and the casts and the false comment are gone.
Added
- fx gallery:
cube-windows, a Section recipe where a box turns in 3D on scroll and pointer tilt, its outline clipping the content behind it. The projection math (scripts/fx-lib/mask3d-core.ts) is a small, dependency-free, unit-tested module meant to be copied whole into a project.
1.18.0 (2026-09-21)
Changed
- Shortened the README to installation, working examples and documentation links. The complete reference, generated measurements and detailed accessibility guidance now live in
docs/guide.md. - Documentation checks compile both the README quick start and guide examples under React 18 and 19. Generated tables and size stamps now update the guide.
- The release gate runs packed-tarball acceptance in Chromium, Firefox and WebKit and publishes the same tested file.
- Packed acceptance now checks real JPEGs, delayed and failed images, live CMS copy and step replacement, narrow layouts, doubled text and both motion controls under React 18 and 19.
- CI and release retain acceptance results and failure diagnostics with source, package, tarball, registry and runtime attribution; the release still publishes the tested tarball.
Added
AttachmentStatusandTrackOptions.onStatusreport each attachment lease transition, including successful output, completion, failure and release.track()keeps returning its cleanup function.useTrack,useScenes,<Track>and<Scenes>expose the same status callback. StickySteps uses it alongside fit and computed CSS checks before crossfading or making shots inaccessible.- A docs-types gate extracts actual README hook and installed Section examples and compiles them through public package exports under React 18 and 19.
Fixed
- StickySteps contains later controller failures, attempts every cleanup, restores flowing media and React accessibility state, reports once and retries only through explicit remount.
- StickySteps keeps an overflowing replacement candidate measurable until the driver latches flow, and synchronizes accessibility after scene and content changes.
<Scenes onScene>contains a throwing consumer, reports it once and keeps scene state and sibling components working.- Slider child replacement restores departed slides' owned outputs and active classes and releases their ownership records.
- StatsCountup renders final numbers before activation under both reduced-motion controls in the installed component, gallery CSS and Tailwind panes.
- CLI minimum-version checks compare release and prerelease precedence, including numeric prerelease identifiers and build metadata.
- StickySteps measures its candidate stacked layout before the first fit decision, so intrinsic images that fit can crossfade while oversized content still latches static flow.
- Slider, toggles, pointer and canvas setup release partial acquisitions and restore owned DOM writes before rethrowing the original error.
- Runtime measurement, output, observer and callback failures stop and report only the affected instance, preserving siblings and the scroll driver.
- Cleanup is idempotent during callbacks, dragging and gliding; stale events, observers and queued frames cannot write after release or replacement.
- Slider and pointer cleanup restore owned styles, classes and focusability while preserving subsequent author changes and authored native scrolling.
- Failed toggle operations restore semantic class, state-variable and ARIA values; shared controllers retain their marker and release pending transition holds correctly.
- Canvas setup, resize and frame failures stop the loop and restore authored sizing without changing fallback content, even when a consumer disposer throws.
destroy()restores the authored sizing too, which resets the bitmap;pause()keeps the last frame. - React auxiliary attachments and autoplay contain failures locally, while failed Modal promotion preserves static open/closed content and close callback failures report once.
- Repeated attachment and release return listeners, observers, timers, frames and toggle ownership to baseline. Existing exports and signatures are preserved.
1.17.5 (2026-09-15)
Fixed
- Slider autoplay suspends during pointer and touch gestures and restarts a full countdown after release or cancel, preserving focus and explicit pause controls.
- The horizontal rail preset and copied formula apply the start override only at the start and align the endpoint to measured stage overflow.
- Installed StickySteps acquires mutation observation and motion subscriptions transactionally, retaining static, reachable shots when setup fails.
- Failed pointer setup releases listeners, its observer and queued work before rethrowing, preserving overlapping scanner owners and explicit retry.
- Demo bundling passes executable arguments separately so checkout paths containing spaces work.
- Scroll initialization commits readiness after required setup succeeds and rolls back partial subscriptions on failure. Throwing optional intersection observers fall back to unculled tracking.
- Failed attachments restore their outputs and authored pin geometry. Scanners retain only acquired leases, unwind partial setup and settle unprocessed content while preserving overlapping owners.
- Measurement, output and callback failures release the affected tracker, report the original error once and allow healthy entries to continue. Stale cleanup cannot release a replacement.
- Boot watchdog expiry is terminal for late tracking and remounts, including when mutation observation fails. Boot releases its scanner if toggle setup fails.
- Installed StickySteps starts static, waits for tracking and fit evaluation before enabling crossfade, and releases layout and accessibility hiding on failure.
- The browser gate now exercises actual Boot and CLI-installed StickySteps with React 18 and 19, injected failures, nonce CSP, motion changes, retry and responsive static reachability. No public API changed.
1.17.4 (2026-09-15)
Fixed
- Overlapping scanners share split text and pointer controllers until their last owner stops.
- Failed ResizeObserver initialization releases prepaint hiding and cannot acknowledge a successful boot.
scan()now initializes the driver on a route with nothing to track, sohtml.sv-onmarks a working driver from the scan on (before, only the firsttrack()set it); an.sv-actswidget that notoggles()controls therefore reads act zero on such a page, as it already did on any page with a tracked element. - Refresh resolves replacement stage and fit nodes and transfers their resize subscriptions.
- Pin progress and scene navigation include the stage's normal-flow offset before sticking starts.
- Compat rails use measured stage overflow and remain stationary when the rail fits.
- Proximity snap leaves wheel and drag settling to the browser.
- Stopping the last toggle controller releases its enhancement marker and pending settle overrides.
- Default and custom carousel dots expose the current slide and disable redundant activation.
- Pointer tracking clears removed targets without waiting for the container to be destroyed.
- Gallery bundling accepts checkout and output paths containing spaces.
- README accessibility checks describe the actual CI triggers: main pushes and pull-request events.
1.17.3 (2026-09-15)
Fixed
- Share scroll tracking across overlapping scanners until their last owner stops.
- Resolve pin stages within their owning tracker for measurement and scene navigation.
- Preserve authored height and position priorities when releasing pin helpers.
- Honor the page motion switch in Coverflow and GSAP gallery previews.
- Keep static StickySteps shots accessible after failed enhancement or watchdog release.
- Restore the current scroll progress when rebuilding an installed GSAP timeline.
- Wrap unsupported rails, hide decorative curtains and release static legacy deck stages.
- Keep nested scoped-clock readers on their own clocks before the first driver write.
1.17.2 (2026-09-14)
Fixed
- Pin progress with a measured
.sv-stageunder a sticky header (--sv-pin-offset) counted the offset twice (1.17.1 regression): the stage's height already sits below the header, so the pin reached 1 one offset after the stretch ended.scrollToScene()used the same span. The span is the wrapper's height minus the stage's border box now; without a stage it is the viewport minus the offset, as before. - The stage's height was read inside the driver's write phase; it is part of the read-phase snapshot now, next to its width.
- Page-output detection skipped
<html>entirely (to ignore its own inline writes), so an inlinevar(--sv-page)reader on the root never turned publishing on. The root's style attribute is checked for readers now. toggles()syncedaria-expanded/aria-pressedonly on the triggers inside the scope that handled the click; a trigger of the same target outside that scope kept its boot value. Every trigger in the document reflects the target now.trackPointer()cleanup missed an element that only ever received apointerout(zeroed coordinates and the leave class with no move first).- Gallery under reduced motion: TimelineScrub's year and line settle at their finished value, ThreeScene holds its scrolled pose and stops following the pin, the rotating-words preview stops cycling.
- Docs: the scoped-clock forwarding recipe keeps a nested tracker on its own clock (
:not(.sv, [data-sv])); the no-JS Lighthouse claim is scoped to the no-JS path; AGENTS says Sections ship as complete React source.
1.17.1 (2026-09-14)
Fixed
<ScrollVarsBoot>on a first route with nothing to track: its pre-paint watchdog waited for a flag the driver set only on the firsttrack(), so at three seconds it droppedhtml.sv-onand kept dropping it for the session, and later routes never animated (content stayed visible). The scanner marks the driver's arrival now, trackers or not.- Pin progress spanned the wrapper's height minus the viewport's, so an authored stage shorter than the viewport reached
--sv-pin: 1before its sticky stretch ended; the span uses the stage's rendered height, in the driver and inscrollToScene(). [data-sv-fit]with a fixed height and overflowing copy passed the fit test and clipped; the test reads the content's height too.styles/scoped.cssforwarded a clock through a nested tracker, which overrode the nested.sv's own reset until its first inline write; the path rules stop at.svand[data-sv].trackPointer()'s cleanup skipped elements a leave had already dropped, leaving their zeroed--mx/--myandsv-pointer-leavebehind.sv-counterkept counting with the pin clock under reduced motion; it settles on--sv-maxlike every other scrub preset, under the media query and underdata-sv-motion="reduce".- Installed StickySteps watched the OS query only, so under
setMotion('reduce')its shots showed in flow while the inactive ones stayedinertandaria-hidden; it follows the effective preference. Installed RotatingWords hides the column from screen readers, says the phrase once and stops rotating under reduced motion (the gallery preview already did). Both need scrollvars 1.17.0. - Installed CoverflowSlider passed the nonce to its Slider but not to its own
<style>. scripts/demo-sync.mjscopiedstyles/scoped.cssat import time; the copy sits under the run-as-script guard now.- Docs: the Sections ship as complete React components (not three formats); the page-outputs scan cannot see an in-place rule edit or a CSSOM insert with no element added; the range floor's contrast claim names the tested pair; the Slider's live region wording matches the code.
1.17.0 (2026-09-14)
Added
- A motion preference the page can set:
html[data-sv-motion="reduce"]asks for less motion whatever the OS says, and everyprefers-reduced-motionblock in the shipped sheets (core, pin, state, tilt, ui, slider, and the compat fallback) has a twin under it, kept in step by a test and checked rendered in CI.setMotion('reduce' | 'auto')sets the attribute,onMotionChange(fn)reports the effective preference live, andprefersReducedMotion()now reads that effective value. The driver, the slider's glide, canvas effects and the React<Slider>autoplay all follow it from one source (core/motion), so a change from the OS or from the page reaches every animation at once. The OS setting alone was the only switch, and it is one many people never find. The twins and the module cost about 0.4 KB gzipped across the sheets and the driver; the stamped sizes moved with them. styles/slider.css: a reduced-motion block (it was the only preset sheet without one) and--sv-dot-target, the hit area of a dot.- A README section, Accessibility, listing what the library guarantees on its own surfaces with the WCAG criteria each guarantee serves, the page's side as a checklist (motion guards and the switch, nothing essential behind a reveal, a way to stop what moves, readable text for animated copy, contrast floors for dimmed text, scroll padding under fixed headers, target sizes, reflow and the fit contract, axe then a screen reader), and the published guidance the library follows.
- A keyboard-reach gate in the e2e run (
demo/bench/harness/keyboard-gate.mjs): it tabs through every gallery page and the home page, forward and back, and requires each focused element to be seen (in the viewport, effective opacity at least 0.5,visibility: visible, not covered at the center of its visible part, still so 250ms later on the way forward). It proves it can fail on a fixture: a link under a fixed header on the way back, and a link inside a box that stays at opacity 0. - A reflow gate in the e2e run (
demo/bench/harness/reflow-gate.mjs): at 320 and 640 CSS pixels of width, 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 fits its stage or has released the pin; the pinned pages also pass the keyboard gate at 320. It proves it can fail on a fixture with a fixed-width band. - An axe gate in the e2e run (
demo/bench/harness/axe-gate.mjs): every gallery page and the home page pass axe-core's WCAG 2.x A and AA rules with zero violations, audited after boot and again after a walk down the page a viewport at a time, at the bottom, the middle and the top (it waits for every finite animation and transition to end first, since text mid-fade reads as low contrast). It proves it can fail on a fixture with an image without alt and a button without a name.
scrollvars/styles/scoped.css, an opt-in sheet that registers--sv-tand--sv-viewnon-inheriting so a write re-resolves one element instead of its whole subtree. The rule it imposes: a clock reaches only the elements that declareinheritfor 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, and a test derives that list from the stylesheets so a new preset cannot ship without its forward. A registered property always has a value, so avar(--sv-t, 1)fallback is never taken again under the sheet: declare that default on the tracked element instead. Measured behind a rendered-output gate: 63 percent less style recalculation and 28 percent less task time on the benchmark's deep profile, 13 percent more recalculation and 4 percent more task time on its flat profile; on the site's own home page 10 percent less task time and 23 percent less recalculation. Browsers without@propertykeep inheriting, so it never breaks a page below the floor. Not part ofstyles.csson purpose.
Fixed
- Demo: the site honors its own motion switch. The gallery pages and the home page carry a "Motion" control in the header that sets
data-sv-motion="reduce"throughsetMotion()and remembers it (localStorage), applied by an inline script in<head>before anything paints; every reduced-motion rule in the gallery effects (panes and installed components) and in the home's own CSS has its twin under the attribute, kept in step by the same test that covers the library's sheets. Tailwind panes keepmotion-reduce:; the consumer guide shows the one-line variant for the attribute. - Demo: the gallery's code tabs overflowed a 320px viewport by 10px, so twelve pages scrolled sideways (WCAG 1.4.10); the row wraps now. The reflow gate found it.
- Demo: what axe found. Every code block is keyboard focusable (
tabindex="0", WCAG 2.1.1: a scrollable region needs keyboard access); the sticky-steps section keeps an inactive step's text at 4.5:1 (its opacity floor is .65 with the labels at .8, it was .3 with .7 and .75, which read at 1.8:1); the editorial manifesto keeps unread copy at 3:1 (floor .55, it was .28); the cinematic hero's marquee strip has its own ground, its text read at 2.6:1 where an orb passed behind it. The gallery panes and the installed components carry the same values. - Demo: the gallery pages' fixed header covered a focused element the browser scrolled to the top edge on the way back (Shift+Tab into the code block's controls), WCAG 2.4.11. The pages set
scroll-padding-topunder the header now; the keyboard gate found it and keeps it.
Changed
sv-range-risefades a slice in from--sv-range-floor(default .55) instead of from 0, the waysv-readingalready floors at--sv-reading-floor: a child waiting for its slice kept its text at 2:1 or less on the sequenced-scrub and timeline pages (axe, walking the page a viewport at a time). The default keeps the sheet's text color at 4.5:1 on a dark ground; a muted or accent color needs a higher floor (the sequenced-scrub demo uses .8 for its accent line). Set--sv-range-floor: 0on the container for the old look, at the cost of that audit. A unit test pins the floor's arithmetic.- Slider dots are 24 by 24 CSS pixel targets (WCAG 2.5.8 Target Size, Minimum): the button is the target and the visual dot is drawn inside it as
::before, so--sv-dot-sizestill sizes what you see and--sv-dot-targetwhat you hit. An override written directly on.sv-dot(rather than through the knobs) now styles the button box; move it to.sv-dot::before. The dots row is 24px tall instead of 8px. <Slider autoplay>starts paused under reduced motion and pauses the moment the preference flips; the visible control still resumes it. It kept rotating before (jumping instead of gliding), and a glide in flight when the preference changed finished animating: it settles now.
--sv-pageand--sv-vare published only when something in the document can read them. Both live on<html>and both inherit, so every write asked the browser to recalculate style for the entire document, on every scroll frame, whether or not a single rule referenced them. On the 900-box benchmark that cost 3249ms of style recalculation over a 12-second scroll against 269ms without, and 4128ms of total task time against 1696ms; the same page with the identical writes registeredinherits: falsecost 267.5ms, so the price was the inheritance rather than the write. The driver now scans the document's own stylesheets and inline styles once, on the first frame that could publish, and stays silent when neither name appears. A stylesheet added later 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 (by rule count, so a rule inserted into an existing sheet as a component mounts is still seen), which is what keeps a page that mounts elements while it scrolls from serializing every rule on every one of those frames. This can change behavior for a page that reads the variables only from JavaScript, which no CSS scan can see: callsetPageOutputs(true)there. Anything unreadable counts as a reader, so a cross-origin stylesheet without CORS headers keeps publishing exactly as before. CallingsetPageOutputs()at all now takes the decision away from detection permanently, in both directions.
Performance
- The published main-900 benchmark, same page, same methodology: total task time 679ms against gsap-batched's 626ms and framer-motion's 1078ms, with 59ms of script against 117ms and 490ms, and 1.0MB of heap against 6.8MB and 10.9MB. Before this change the same page cost 4144.5ms, 2.6x GSAP rather than 1.08x. On the deep profiles the default still loses to GSAP on style recalculation alone, and
styles/scoped.cssis the answer there: ahead of GSAP at five descendants per box, the gap at fifty narrowed from 72 percent to 24.
- Demo: keep the homepage's custom scenes readable before the driver boots. Entrance text stays visible; pinned rails, decks, product-tour panels and map stations return to normal flow; galleries stop clipping static cards. Native carousels remain keyboard-scrollable without JavaScript, including the page-driven rail. Canvas-only effects and inactive controls are omitted from the static preview.
- Demo: the homepage no longer calls
setPageOutputs(false)itself. The default now detects that nothing on the page reads--sv-pageor--sv-vand stays silent, which is what the earlier explicit opt-out had been buying (measured at the time at about 62 percent less main-thread task time over the same 12-second scroll, 5039.5 to 1911.5ms). - Demo benchmark table: the same dated snapshot as the README and the benchmark page, with three ScrollVars rows (the default, the
styles/scoped.cssopt-in, and the document variables published, which is the cost of that feature rather than a competitor comparison) next to the batched GSAP row. - Demo benchmark page: the low-end profile (4x CPU throttle) re-measured with the new default, six rows instead of a table that still described the old one; and a new section for an app-shaped page, generated by the harness (
harness/app-shaped.mjs: a page that mounts and unmounts rows while it scrolls and swaps its grid and feed at six seconds), showing what the library adds to a page that lives, with the same line stamped into the README's benchmark block.harness/mutation-cost.mjsis the diagnostic behind the consumer-watch fix above; both ship with their results.
1.16.1 (2026-09-09)
- Driver: repeated
setPageOutputs(true)calls no longer schedule frames when outputs are already enabled. Calling it fromonTravel/onPinpreviously sustained a frame loop on a stationary page. A Chromium/Firefox/WebKit probe now records zero callbacks and element-geometry reads during the idle window; real re-enabling and subsequent scroll input still update normally. This removes unnecessary idle work in that callback pattern, not the general benchmark's inherited-style cost. - Driver: enabling page outputs inside a tracking callback waits for a frame with measured document geometry. Previously it could briefly publish
--sv-page: 1halfway through the page. Disabling remains immediate; the fix adds no layout reads during the write phase. Covered for live/travel/pin/scene callbacks and checked in Chromium, Firefox and WebKit. This is a correctness fix, not a measured CPU speedup.
1.16.0 (2026-09-09)
- Add
cssVars: falsetoslider()/useSlider()and the<Slider>kit to skip unused--sd,--sv-progressand--sv-slidewrites in plain carousels. Default output behavior, callbacks, numeric state, active classes and snap are preserved; existing inline values are left intact. Unit, React 18/19 and Chromium/Firefox/WebKit checks cover both modes. - 36–54% less main-thread CPU time in the measured plain-slider workloads, when opting out of unused CSS outputs. Continuous
seek()benchmarks fall from 1145 to 615 ms (15 cards, 46.3%) and 2784.5 to 1288 ms (120 cards, 53.7%). AnimatedgoTo()benchmarks fall from 508.5 to 323.5 ms (36.4%) and 1319 to 603.5 ms (54.2%), respectively. Each workload uses four runs per mode and size with rotated execution order; both modes already deliver 60fps. These are CPU savings, not FPS gains or a general speedup over GSAP/Framer. Sliders whose CSS consumes these variables must keep outputs enabled. [Seek measurements](https://scrollvars.dev/bench/results/slider-api.json), [glide measurements](https://scrollvars.dev/bench/results/slider-glide.json), [methodology and limits](https://github.com/aduptive/scrollvars/blob/main/demo/bench/harness/README.md#unused-slider-outputs-plain-carousel). - Refresh the [general benchmark](https://scrollvars.dev/bench/) for 1.16.0 and lead with total CPU and frame delivery, retaining the style-cost breakdown and both page-output modes. This comparison does not establish a general speedup since 1.15.0; document-wide outputs remain the main measured weakness.
1.15.2 (2026-09-09)
- Slider: retarget an active glide when the existing resize/child-list observers report changed geometry; stop cleanly if all slides disappear. This prevents an old destination from driving repeated out-of-range writes after a responsive resize or content removal, without adding per-frame geometry reads.
- Slider: clamp glide destinations to the real scroll range so partially visible edge slides do not cause animation frames that keep writing unreachable positions. Deliver a final stopped-state measurement even if the browser rounds away the last scroll event. Covered at both edges in LTR, RTL and vertical rails; fitting rails schedule no glide.
- Canvas: pause the automatic frame loop when either layout dimension becomes zero, even when IntersectionObserver still reports an intersection. Restoring the previous size resumes without recreating the simulation; manual pause and
autoPause: falseremain respected. Covered by unit and Chromium/Firefox/WebKit regression checks.
1.15.1 (2026-09-08)
- Pointer: stop rewriting an unchanged leave class on every pointer event; write each coordinate only when its inline value changes. Hidden targets settle to zero instead of
NaN, and queued moves skip detached targets. External style replacements are still repaired. - Slider: read the scroll range once per progress snapshot and avoid copying all children just to count them.
- Driver: skip the velocity-reset timer when already at zero and keep the reset's zero serialization consistent, avoiding redundant document-wide style changes at rest.
- Regression checks cover the actual pointer class mutations and hidden targets in Chromium, Firefox and WebKit, plus coordinate writes, detached targets, slider snapshots and idle velocity writes.
1.15.0 (2026-09-08)
- Add
setPageOutputs(false)and<ScrollVarsBoot pageOutputs={false} />to stop unused document-wide progress/velocity writes. Local tracking keeps working; the existing default remains enabled for compatibility. Gallery pages opt out. - Fix nested sliders stealing drag/wheel gestures, settling from a stale slide index on release, and pin offsets becoming stale after observed container resizing.
- Move the tilt glare with a translated fixed gradient instead of changing its painted gradient center.
- Benchmark the same scroll path through a shared runner, separate startup from scroll CPU, preserve long frames, record individual runs and source hashes, and compare page outputs on/off plus six gallery sections. Synthetic CPU throttling is calibrated with fixed work and is not a physical-device result.
- Verify deployment aliases against a unique deployment ID. Add Chromium/Firefox/WebKit regressions for the interaction fixes and correct performance claims and setup guidance.
1.14.2 (2026-09-07)
- Fix TimelineScrub and StickySteps disabling their pin in bordered stages. Their inner layout now fills the stage content box with
min-height: 100%, instead of exceeding it by the border width. Added scroll-behavior checks against the actual gallery pages; genuinely oversized content still returns to flow.
1.14.1 (2026-09-07)
- Fix horizontal rails clipping their last cards inside narrow stages, including the fx gallery. The driver measures
--sv-stage-widthin its read phase and observes stage resizing; the preset uses this width by default while preserving--sv-rail-startoverrides.
1.14.0 (2026-09-07)
Review fixes and gallery sections
- Pinned sections can opt into
.sv-stage > [data-sv-fit]: tall content returns to flow, releases the pin height and reportsonFlow. TimelineScrub and StickySteps use it, including accessible media in flow. - Pin offsets use the stage's computed CSS top; calc(), env(), percentages and viewport units stay aligned with CSS.
- Slider: isolate nested per-view rules, nonce the responsive stylesheet, expose hover/focus pauses to live regions, retain explicit focus pauses and stop callbacks after destruction. Remove flatMap from the React child flattener for legacy consumers.
- Marquee gets a keyboard pause control; toggles includes its scope root and supports pressed-state buttons. Compat ResizeObserver supports disconnect/reobserve. Canvas never resizes disposed setup resources.
- scan owns declarative pointer containers and CSS timing/ease attributes.
- Gallery Sections expose their exact installable source and visual previews; add CaseStudyRail and EditorialManifesto. Documentation corrects observer gating, replay reads, stagger limits and named-import size measurement.
Tooling (ADU-196)
- Three
.replace()calls inscripts/docs-stamp.mjs(README's total size stamp, README's slider module size stamp) andscripts/demo-sync.mjs(demo/index.html's version line) ran with no guard at all: worse than the "missing anchor" case ADU-195 already covered, since a bare.replace()that matches zero times reports success and silently writes the target file back unchanged, the old number still on the page. A sweep of every other.replace()/.match()inscripts/for the same defect found two more indocs-stamp.mjs(AGENTS.md's two styles-import lines) and two inscripts/bench-tables.mjs(the bench page's runner config bundle size, the README/AGENTS "less bundle" ratio sentence), all fixed the same way.docs-stamp.mjsanddemo-sync.mjsgain aspliceOne()(throws on zero or more than one match, ADU-195'sfloorRow()shape generalized to a plain regex);bench-tables.mjsgainsspliceAll(), which throws only on zero matches since a repeat is the intended shape there (the same bundle size legitimately repeats across three runner-config entries, the same ratio across README and AGENTS).bench-tables.mjsalso gained theisMainguard the other two scripts already had, sospliceAll()can be imported by a test without running the whole script's file I/O. - Fix pass on the above: the audit that closed this ticket was itself incomplete on two counts.
spliceAll()'s "at least one" check accepted PARTIAL coverage: on the bench page's three-entry runner config, breaking one of the three matches (a moved anchor, a stray quote) still exited 0, with the other two silently rewritten and the third silently left stale. It now takes the caller's own expected count (3 for the runner config) and throws on anything else. The README/AGENTS "less bundle" ratio sentence never shared that repeat shape, it runs once per file and expects exactly one match each, so it moves tospliceOne()instead: an accidental second mention in either file now throws rather than being double-patched. And "every other regex-driven write inscripts/was audited and is already guarded" was false: four more calls sat beside the ones that were fixed, each guarded less than that sentence claimed. Unlike the five above, which had no guard at all, these did check for a missing anchor, then wrote through a bare non-global.replace(): an ambiguous anchor silently patched the first match and left the rest stale, the narrower case ADU-195 exists to close. They are README's per-style styles-import loop, README's intro sizes sentence, and AGENTS's "Fully animated" browser-floor headline indocs-stamp.mjs, and the bench page'sh1headline and body ratio claim inbench-tables.mjs. All four now go throughspliceOne().scripts/fx-render.mjs'sfxstickyclass splice stays a no-op for Section previews that are not pin-based (hero-cinematic, stats-countup render no.sv-stageby design), but is no longer unconditional: a pin-based Section (requires.stylesincludespin) that loses its.sv-stagenow throws instead of silently shipping without it. No output changes:npm run demo:syncproduces a byte-identical tree.
Tooling (ADU-195)
- The anchor splices in
scripts/docs-stamp.mjsandscripts/demo-sync.mjs(between(),stamp(),floorRow(), and every regex-based row splice in demo-sync's browser floor and CPU tables) now throw when their anchor is AMBIGUOUS, the same way they already throw when it is missing.between()counts the literalbeforeanchor rather than the compound regex: a non-greedy match starting at a repeatedbeforesilently absorbs the other occurrences, so counting the compound match reports one and misses it, which is exactly the failure that let ADU-194 nearly ship a splice that matched the wrong occurrence, caught only because the NEXT splice threw on the anchor the first one had already swallowed. Structural splices (stamp(),floorRow(), demo-sync's marker blocks and table rows) count the whole constructed regex instead, since a bare label can legitimately repeat across two tables with different cell shapes (demo-sync's CPU table and the Lighthouse table both have a "ScrollVars" row) without being ambiguous for that splice. Every anchor in the tree today was audited against its real target file and found unique; no splice needed widening.between()andstamp()are now exported from their scripts (guarded behind anisMaincheck,scripts/fx-build.mjs's existing pattern) so tests can call them without triggering the scripts' file I/O. No output changes:npm run demo:syncproduces a byte-identical tree.
Docs (ADU-194)
- The bench page's own headline contradicted the measured table two screens below it: "15× less JavaScript" against a stamped bundle ratio of ~7× (46.3 / 6.7), and "three engines" as the whole page's frame, above a table that now has four rows. Fixed the ratio, which is unambiguously wrong regardless of framing; left "three engines" as written both times it appears, since each instance describes the interactive live-run section immediately below it, which genuinely only ever exercises three builds (ScrollVars, idiomatic gsap, framer-motion; the batched gsap variant is the harness-only fourth row, never added to the interactive tool, which would be a behavior change this ticket does not ask for).
- The home page repeated the same numbers by hand in five more places, all drifted: the og/twitter meta descriptions ("1.4 KB driver" against the footer's own stamped "2.9 KB"; a bare, uncaveated "Lighthouse 100" claim nothing in the repo sources, handled the same way ADU-193 handled the identical claim in the launch draft: it goes rather than getting carried forward stale), the slider case's headline and body prose ("30× lighter than Swiper" against 42 / 2.3 ≈ 18×), a demo slide reading "3 KB, not 150", "the receipts" section's own weight and CPU tables (a "6.1 KB" ScrollVars-everything row one screen above the footer's correct "6.7 KB" full core; a driver-alone row still reading a size from before the driver grew; a CPU table with numbers from an earlier benchmark run entirely), and a footer honesty note's "< 6 KB" bound, false now that the full core is 6.7 KB. Every number above is now derived, not typed: see the Tooling entry below.
Tooling (ADU-194)
scripts/docs-data.mjs'smeasureSizes()now also exposes minified (pre-gzip) sizes for the driver, the core entry and the slider module (driverMin,everythingMin,sliderMin), the two figures the home page states side by side with a competitor's own minified size; a newbenchMainEngines()reads the committed harness output (demo/bench/results/latest.json) the same wayscripts/bench-tables.mjsalready does, andcpuTotalMs()is the site's own definition of "CPU total" (script + style recalc + layout, the same formula the ADU-193 article fix already established). gsap + ScrollTrigger's bundle size (46.3 KB, from its public CDN build, ours to cite but not to measure) moves to a single exportedGSAP_KB;scripts/bench-tables.mjsreads it instead of carrying its own copy.scripts/bench-tables.mjsnow also stamps the bench page's own headline ratio and bundle-ratio sentence from the sameratioit already computes for README and AGENTS, so a future driver-size change moves all three the same way instead of two of three.scripts/demo-sync.mjsgained abetween()anchor splice (docs-stamp.mjs's idiom, copied rather than imported cross-module) and now stamps every number named in the Docs entry above ondemo/index.html: the meta descriptions, the slider case's headline and body prose, the hero carousel's driver-size card, the slider demo slide, and "the receipts" section's three ScrollVars rows in the weight table plus its whole CPU table. No new path outside what.github/workflows/ci.ymlandrelease.ymlalready diff (demo/index.htmlwas already on both lists). No behavior change.
Docs (ADU-193)
article/launch-kit.mdandarticle/why-i-built-scrollvars.md(the launch drafts, not shipped by npm or served by the site, so no CI gate covers them) carried headline numbers from an earlier point in the engine's growth: the bundle used in the Show HN title, the tweet and the email pitch was 3.9 KB, now 6.7 KB (README's own bench-table figure), and the bundle multiplier was ~12x, now the ~7x the bench tables stamp.why-i-built-scrollvars.md's driver size, carousel size, comparison table and CPU/heap paragraph were reconciled the same way againstscripts/docs-data.mjs's measured sizes and the committed bench tables; its pattern count went from 26 to 28, matching AGENTS.md anddemo/index.html. The Lighthouse mobile scores in that same paragraph need a fresh run against the deployed site (a live measurement, not a build output) before the piece publishes: left as a marked placeholder rather than carried forward stale. No code change.
Docs (ADU-181)
- The "fully animated" browser floor had SIX hand-typed copies and three of them had drifted:
docs/integration.md,demo/index.htmland the launch draft inarticle/, which is not published from this repo, all said Firefox 74+ (Mar 2020), which is not a bound anything in the code sets. README, AGENTS.md andscripts/docs-build.mjs's /docs/ template already agreed on the true number, Firefox 78+ (Jun 2020): Firefox shipped individual transform properties (translate:/rotate:/scale:as their own CSS properties, the other half of the floor) in 72, Jan 2020, but:is()/:where()only in 78; without:is()/:where()a parser drops the whole.sv-on :is(.sv, [data-sv]) ...selector list that core.css, pin.css and state.css write for every enhanced-path rule, so 78 is Firefox's real binding constraint, not 72 or 74. Chrome (104, Aug 2022) and Safari (14.1, Apr 2021) are bound by individual transforms either way, both already past their own:is()/:where()floors. Fixed all three to Firefox 78+. The five copies that ship now render from one source; the draft sits outside the generated pipeline and was corrected by hand.
Tooling (ADU-181)
- The browser floor's five shipped copies now render from one
BROWSER_FLOORinscripts/docs-data.mjs, next toCOMPAT_PRESETS(ADU-176's pattern):scripts/docs-stamp.mjssplices README's and AGENTS' cells (the anchor-based splice, since a floor cell sits inside a Markdown table row and a prose sentence, neither of which can carry a<!-- name:start -->marker) and now also ownsdocs/integration.md, previously untouched by any generator;scripts/demo-sync.mjssplicesdemo/index.html's table row and footer sentence, the two spots that had drifted; andscripts/docs-build.mjsinterpolates the /docs/ template directly from the same data, no regex needed there since that file is rendered fresh every run.docs/integration.mdanddemo/index.htmlwere not on either gate's file list, so both.github/workflows/ci.ymland.github/workflows/release.ymlnow diff them too, alongside the surfaces already covered. No behavior change.
Docs (round 8, ADU-192)
Docs read against the code merged by the four round-8 code tickets.- README's and AGENTS'
sv-rangeparagraphs no longer say avar()fallback makes older engines settle at the end state:var()'s fallback fires only for a guaranteed-invalid property, and--sv-ris set. It is the registered property's own@property --sv-r { initial-value: 1 }that resolves an engine that cannot compute the calc() division to that initial value instead of turning invalid. Thevar(--sv-r, 1)a consumer writes never fires on a range child at all, with@propertyor without it:--sv-ris set either way, so an engine that cannot compute the division turns the declaration consuming it invalid at computed-value time instead of substituting the fallback. The paragraphs say that rather than calling the fallback a backstop, which reads as protection it does not give. Same fix in the gallery'ssv-rangeTailwind pane comment (scripts/fx-data.mjs) and instyles/pin.css's ownsv-rangecomment, which derived its "always consume it with a fallback" advice from the same wrong mechanism: the advice and the outcome stay, the causal claim goes. - README's and AGENTS'
--sv-pin-offsetunit lists now say whatemresolves against: the pinned stage's font-size, not the tracked wrapper's, per ADU-191.remgained its own parenthetical (root font-size) for the same reason: a unit list that only names tokens cannot tell a reader which element a relative unit is measured from. AGENTS' copy of the list also names thesvh,lvhanddvhgroup README carries, which it had been missing. - README's and AGENTS' entrance-replay paragraphs now name the two shapes ADU-191 left unreplayable, since the promise is true enough now that a reader relying on it hits them: entrance CSS of your own that hard-codes its duration instead of reading
--sv-duration/--sv-stagger, and--sv-durationor--sv-staggerdeclared on a descendant instead of inherited, which is what<Item duration>and<Split duration>emit. Only those two knobs: a bare<Split>emits a count and per-word orders and no timing knob at all, and a descendant--sv-orderor--sv-distancereplays perfectly. Both surviving shapes stay flat, with no visible change at all, when the re-track runs in a plain task (a React effect, a click handler), which is the common case; the dip and reverse needs a re-track from inside a rAF callback, since in a plain task the transition's start time is the next frame's and the driver re-asserts the flag in that frame before the clock advances. - AGENTS' zero-wrapper paragraph no longer says the pre-paint script "removes it again if the driver never boots": ADU-188 made the release final, so it also removes
sv-onwhen the driver boots LATE, after the 3 second watchdog, reverting the class through a MutationObserver installed only once the watchdog fires, instead of re-hiding content the visitor is already reading. src/core/driver.ts'sEntry.pinOffsetfield comment said the offset is read once from the tracked element's computed--sv-pin-offset. That held only for the units resolving identically on either element:4emreads 40 on a wrapper where the stage gives 80, and custom properties inherit downward only, so an offset declared on the stage itself is invisible to a read on the wrapper.readPinOffsetreads it from.sv-stagesince ADU-191; the comment now says so, the wrapper only when there is no stage.
Driver (blind review round 8, ADU-191)
--sv-pin-offsetinemnow resolves against.sv-stage, the element styles/pin.css applies it to, instead of the tracked wrapper:top:andheight:consume the variable on the stage, so that is the font size the CSS resolves it against and the element an author can redeclare it on. Measured in Chrome, a wrapper atfont-size: 10pxaround a stage at20pxrenders--sv-pin-offset: 4emastop: 80px, while the pin math used 40 and the pinned stretch started 40px off. Every other unit is unchanged, and the whole read moves to the stage, so a value declared on the stage itself is the one the math sees too. No stage in the markup (onPinalone, custom skeletons): the wrapper, as before.- Re-tracking replays the entrance again, on an element that never left the viewport. README, AGENTS and llms.txt all name re-tracking as the way to replay it, and it did nothing: the presets are CSS transitions off the inherited
--sv-live, and a frame's rAF callbacks run BEFORE that frame's style update, so between the release (which settles the element visible with an inline--sv-live: 1) and the first frame writing that flag back, the computed value went 1 to 1 and no transition was ever generated. Measured in Chrome: opacity flat at 1 for six frames and 400 ms. A forced style update on its own is not the fix either, it starts the fade OUT and the next frame reverses it from wherever it got to, measured 0.938, a dip rather than an entrance.track()now zeroes--sv-durationand--sv-staggeron the element, forces the update, and hands both knobs back with the priority they were authored at,!importantincluded: the reset lands in one step with no transition to reverse, and the entrance runs from a real 0 at the authored duration and stagger. Only for an element the driver had settled (released, or a settledonce), so a first track pays nothing at boot. Two shapes are not covered, and behave as they did before: entrance CSS of your own that hard-codes its duration instead of reading the knobs, and a knob declared on a DESCENDANT of the tracked element rather than inherited from it (<Item duration>,Splitand the staggered-reveal pane all emit one that way), since a descendant's own declaration beats an inherited value at any priority. Both keep the old behaviour exactly: a 0.938 dip and reverse when the untrack and the track sit a frame apart, no visible change at all when they sit in the same tick. One narrow cost the other way: the knobs are inherited, so for the single flush the zeroes reach every other consumer in the subtree, and an unrelated transition created in that same tick (an accordion opened right there and then) is created with duration 0 and snaps. Proved in Chrome by e2e invariants, red on the old engine: the replay itself (lowest opacity 1, no replay at all) and the handback half of the round trip, that the authored priority survives it (computed--sv-durationback at the authored 400ms against a sheet rule's 3000ms!important, not taken over). The zeroing half of that same round trip, that the zero itself needs!importanttoo or the sheet rule outranks it mid-flush and the reset never lands, is proved in Chrome as well (the knob's own rise child dips below 0.1 under that exact conflict, not just the final value round tripping) and, structurally, by a unit test on the knob round trip that reads the priority a stub was handed mid-flush rather than whether it won a real cascade. Left alone on purpose: the forced read cannot throw on a live element in any browser, the re-scan's N style recalcs against the base's one are fine at realistic N, and the stage lookup takes the first.sv-stageat any depth, which no shipped page can reach. - Size, measured against the base, because these are published numbers: the fixes above take
track(min+gzip) from 2.7 to 2.9 KB,track+scanfrom 3.7 to 3.8 KB, the core entry from 6.6 to 6.7 KB,scrollvars/reactfrom 12.1 to 12.2 KB and the headline typical page from ~5.2 to ~5.3 KB. The driver bundle lands at 2946 bytes, 74 short of the 3.0 KB rounding boundary at 3021. No public surface change: no new export, no new class, no new variable.
React (blind review round 8, ADU-188)
No public surface change: no new export, prop, class or--sv-* variable.
<Slider>counted its children withReact.Children.count, which counts what the caller wrote and not what React renders. A conditional slide ({show && <Slide/>}) counts as one child and renders none, so[<Slide/>, false, null, <Slide/>]drew four dots for two slides, two of them callinggoTo(2)andgoTo(3)on a two-slide engine, with labels reading "1 of 4" and "4 of 4". A fragment counts as one child and renders two, so one dot covered two slides andcloneElementputrole,aria-roledescriptionandaria-labelon the Fragment, where React drops them: neither slide carried any of the annotation the component promises. Both shapes are ordinary React. One normalized list now feeds rendering, counting and annotation alike, so the dots, the labels and the engine agree on the same slides.Children.toArraydoes not open fragments on its own (measured under React 19.2.8, a fragment comes back as one element), so the list recurses into them and keys each level under its parent, joined with a:. That separator is the one React itself escapes (:becomes=2,=becomes=0) while.and$pass through a key untouched, so a fragment's children cannot collide with their uncles: joined on nothing,<Fragment key="a"><b/></Fragment>and a sibling keyeda.$bboth flattened to.$a.$b, and a reorder then matched the two by position and swapped their state. A child that is not an element keeps its place in the rail: it carries no annotation and the engine cannot count it, but it renders. That matters most for a portal, whichChildren.maphands back untouched andtoArray().filter(isValidElement)deletes from the document with no warning anywhere.- The responsive
perViewstylesheet scoped its rules with a descendant selector,[data-sv-uid="..."] .sv-slider. The rail of a Slider nested inside a slide is a descendant of the outer shell too, so the outer map declared--sv-per-viewdirectly on the inner rail, which reads its own value only by inheritance from its own shell: the outer number won every nesting. It is a child combinator now,[data-sv-uid="..."] > .sv-slider. - The pre-paint script releases the page after 3s so a bundle that never arrives still fails visible. A driver that booted after that deadline found the page released, added
sv-onback, and every offscreen entrance went toopacity: 0: content appeared and then disappeared. The release is final now, held by a MutationObserver that is installed only once the watchdog has fired, so the normal path pays nothing for it.
Gallery (blind review round 8, ADU-188)
- Three installed components carried the same pair,
React.Children.countfor the geometry andReact.Children.mapfor the render, andChildren.mapcalls its callback forfalseandnulltoo. Given[<Card/>, false, null, <Card/>],CoverflowSliderwrapped a conditional card in a real<Slide>, so the rail carried an empty slide and the Slider drew a dot wired past the end of the engine;DeckSpreademitted four cells, two of them empty, and set--sv-midto 1.5 where two cards need 0.5, fanning the deck around a card that is not there;SequencedScrubemitted four slices and gave the second real card the scrub window 0.75 to 1 instead of 0.5 to 1. All three now count the elements they render. A child that is not an element passes through unwrapped, as in the kit: it owns no slot in the fan, no slice of the pin and no dot, but it still renders.
Slider (blind review round 8, ADU-190)
measure()declared a read phase then a write phase in its own comment, then broke it:progress()(which readsscrollLeftandscrollWidththroughpos()/range()) ran again after the--sdwrites, for the--sv-progresswrite, and again after the class writes, insidestate(), foronScroll. The twin of the ADU-168 driver fix, same shape, in the slider:progress()is now snapshotted once, still inside the read phase, before the write loop starts, and both the--sv-progresswrite and theonScrollstate hand in that snapshot instead of reading fresh.state()itself is unchanged for any caller outside a measure pass; it still readsprogress()fresh when called with no argument. No API change, and not the round-6 value-consistency claim, which stays rejected: this is about the interleaving, not about the value read.- Size, measured:
slider(min+gzip) goes from 2.2 to 2.3 KB.
Tooling (round 7 follow-up, ADU-176)
- The compat fallback preset list had THREE hand-typed copies, not two:
src/compat/index.ts's header comment (which tsc emits verbatim into dist and npm ships), README's Extended floor paragraph, and the "Older targets" paragraph inscripts/docs-build.mjsthat renders /docs/. Nothing held them together, and the list already escaped once that way (ADU-159 shipped a false sentence to npm). All three now render from a singleCOMPAT_PRESETSinscripts/docs-data.mjs:docs-stamp.mjswrites the first two,docs-build.mjsinterpolates the third, and both the CI and the release gate diffsrc/compat/index.tsalongside the other generated files. Editing the prose by hand is now the thing that fails, and the second and third edits stop being needed at all. - Two prose claims that live in both a shipped source comment and README.md now have a test holding them to the code they describe, instead of a CLAUDE.md rule nobody enforced: the driver's pin-helper release condition (
src/core/driver.ts'strack()comment vs README's pinning paragraph, compared as full normalised text), and--sv-pin-offset's unit resolution, where both prose copies are now checked againstreadPinOffset's ownswitchrather than against a list of unit names. A unit list keeps every token when a unit changes MEANING: a comment that resolvedvh,svh,lvhanddvhagainstwindow.innerWidthinstead ofinnerHeightpassed a token comparison clean, and fails now. Same forCOMPAT_PRESETS: its reveal/pin split is checked against the fallback stylesheet, since a preset in the wrong group is a wrong claim about which module a consumer needs. No behavior change. - The stylesheet-vs-list test above only checked the list against the sheet, so a rule added for a NEW preset would leave the source comment, README and /docs/ stale with everything green: added the inverse, every class the fallback stylesheet has a rule for is named by
COMPAT_PRESETSor on a two-name exception list (sv-deckandsv-reading, which get fallback rules on purpose but are not entrance/pin presets). Proved red by adding a rule for a preset the list did not name. - The README paragraph and the
src/compat/index.tsheader comment that carry the stampedCOMPAT_PRESETStext sit inside a Markdown paragraph and a JSDoc block, neither of which can carry a<!-- name:start -->marker on its own line (that splits the surrounding prose, see CLAUDE.md's anchor-splice rule), so the span itself has no visible sign that it is generated: a sentence added by hand between "stylesheet for" and "(written without" in README.md, or between "the same presets README lists:" and " * Written without" in the source comment, is silently overwritten by the nextdemo:sync. The CI gate still catches the resulting diff, but its message will not say why. Noted here for whoever lands on that diff next.
Testing (round 7 follow-up, ADU-177)
No behavior change: a stub modelled a ResizeObserver that never delivers, which left the mount-time schedule untested rather than passing.- Every
ResizeObserverstub in the slider suite (18 of them, one per fixture) was an emptyobserve() {}that recorded nothing and delivered nothing. A real one delivers once, on its own, right afterobserve(), with the element's current size, andsrc/core/slider.tsis written against that: its callback isschedule(), so the frame that measures a rail whose box was not ready at mount comes from that first delivery and from nothing else. One shared stub now delivers it, batched into one callback per frame, dropped bydisconnect(), the way the spec has it. The delivery is a frame, not a microtask: a real one runs in the rendering step, and these fixtures drive their own rAF queue as their only clock, so a microtask would land after the whole test body had run.disconnect()cancels the queued frame instead of only forgetting it, or a fixture's bluntcancelAnimationFrame(every one of them clears the WHOLE queue) would leave the stub believing a delivery is still pending and unable to ever schedule another. - All 18 fixtures now reach that delivery, up from 8: two drove
requestAnimationFrameas() => 1, which throws the callback away and with it any observer delivery; seven never ran a frame at all; and one (rapid next() clicks accumulate through the pending target) ran six frames of its own glide and still delivered nothing, because the secondnext()'sstopGlidecallscancelAnimationFrame, which every fixture defines as clearing the WHOLE queue, wiping the already-queued delivery along with it. A new test covers what the missing delivery hid, a rail mounted with no box (a tab just revealed, a font not yet swapped): the synchronous mount measure reads a zero viewport and parks on slide 0, and only the observer's first delivery re-measures it onto the real active slide. Proved red against the old do-nothing stub. - The canvas harness's stub had the same hole in a different spot. It queues the first delivery on
observe()now andpump()runs it after that frame's rAF callbacks, Chrome's order: that frame order is what keeps the other 51 canvas tests correct, notdeliverResize()also cancelling the pending flag on a fixture's own hand-firedenv.resize(), which silently cancelled the observer's own queued mount delivery instead of letting it land. Removed: the canvas cases this fix targets only turn red under a delivery synchronous withobserve(), never under one deferred a frame like this stub's. A hand delivery can now be followed by a real, same-box auto delivery on the fixture's nextpump()(five of the 52 canvas fixtures reach that state, up from the one below), harmless because nothing moved between them. The new test mounts and pumps with noresize()call at all: the canvas sizes itself from the first delivery and starts its loop, and stays at the HTML default 300x150 without it.
Testing (round 6 follow-up, ADU-161)
No behavior change: three fixtures modelled a browser state no engine is in, which is what let two rounds of tests pass on broken code.- The canvas suite's
compat()fixture claimed an engine with no ResizeObserver and no IntersectionObserver (Safari below 13.1 and 12.1) while answeringCSS.supports()like Chrome 104 and reporting a computedaspectRatiolike Chrome 88. It is one engine now, Safari 12: the fallback stylesheet path runs,<html>takesdata-sv-compat, and the CSSOM has noaspect-ratio. A new test covers what that mix hid,compat()'s ResizeObserver shim driving an UNSIZED canvas below the aspect-ratio floor: width pinned atw0, no ratio written where it cannot take effect, and a laid-out height stable over five viewport resizes instead of the ADU-158 runaway. Proved red against that defect. - The driver suite ran with no global IntersectionObserver, so
cullerwas null and every entry stayed permanentlynear: 175 of its 184 assertions never touched the offscreen culling path (the other 9 sit inside the two tests that already drove their own IntersectionObserver by hand). IntersectionObserver ships before ResizeObserver in every engine (Chrome 51 vs 64, Firefox 55 vs 69, Safari 12.1 vs 13.1), so the file now installs one that delivers an initial record per observed target and one per crossing, after that frame's animation frame callbacks, the order the HTML rendering steps run them in. Every existing assertion still holds. A new test drives a real cull through that observer, so an always-intersecting stub cannot come back unnoticed. - The slider suite installed a ResizeObserver at 18 sites and a MutationObserver at one, though MutationObserver is from 2012 and ResizeObserver from 2018, which left the re-render resync (a childList record re-observes the slides and re-measures) off for the whole file bar one test. It is file-wide now, delivering only when a fixture actually replaces a child, and
destroy()disconnecting it is asserted rather than assumed.
Toggles (round 7 follow-up, ADU-172)
- A trigger nested inside two
toggles()scopes is now toggled exactly once per click, by the nearest scope.toggles(root?)is public two-argument API and the documented setup runs two instances at once:<ScrollVarsBoot />calls it unscoped while a consumer calls it on their own root. A trigger inside both was contained by both, since containment is inclusive rather than nearest-exclusive, so both instances flipped the same class on one click and the toggle netted to nothing: after one click and after two clicks alike the panel was closed,--sv-statewas 0 andaria-expandedwas false. The user saw a button that does nothing. The first instance that acts on a click now claims it and every later one bails, which is the nearest scope by dispatch order: a scope containing the trigger is an ancestor-or-self of it, so it sits on the event's bubble path, and that path runs inner to outer. Two unscoped instances (the same bug without nesting) tie on registration order and still toggle once. The claim is keyed by the event object and taken on action rather than on sight, so a scope that cannot resolve the trigger's target still passes the click on to a wider scope that can, andstop()needs no bookkeeping: the claim lives exactly as long as the event object does, and a re-dispatched Event object is a silent no-op. One consequence, and the price of single ownership: only the owning scope's sync runs, so a duplicate trigger of the same target and class outside that scope keeps its previous aria-expanded until a click the wider scope owns. Pre-existing since scopedtoggles(), not a regression. No public surface change.
Docs (round 7, ADU-170)
Docs read against the code merged by the five round-7 code tickets.- README's and the docs page's below-the-floor paragraphs no longer say the pin net keeps the stage in flow and nothing clipped unconditionally: that guarantee holds only without
compat(). With it installed (ADU-168) the stage stays pinned so the module's fallback can keep animating the curtains and rail, and content taller than the stage clips there, measured on a four-cardsv-deckwith cards three and four unreachable past the clip and about 1800px of scroll doing nothing visible. Skippingcompat()on that page is the escape. data-sv-compat, the markercompat()writes on<html>, is now documented next tohtml.sv-onanddata-sv-offin README, and in AGENTS' browser support paragraph.- AGENTS and llms.txt gained README's
sv-railbelow-the-floor exception (its track stays unwrapped and can run past the viewport edge); they had the general "static but 100% visible" claim without it. --sv-pin-offsetis documented for what resolves today: px, rem, em, vh (svh, lvh and dvh resolve like vh) and vw.calc()reads as 0 andvmin/%are read as if they were px; full length resolution is ADU-100. Round 5 asked for this and ADU-159 missed it.- AGENTS' slider breakpoint map now says a raw min-width key must be a bare number (
900: 4): a string with a unit ('900px') is coerced withNumber()into@media (min-width:NaNpx), an invalid query that never matches. Repeat of round 6's row 8, untouched by ADU-159's docs pass. toggles()is documented for exactly what it ships: a class,--sv-stateandaria-expanded, no focus trap, Escape,aria-modalor tab semantics. AGENTS now points a real modal at the kit's<Modal>instead of implyingtoggles()alone covers it.- AGENTS' SSR section no longer claims
<Scenes>cannot hold RSC-only children: the slot pattern (pass already-rendered server content down as a prop, close over it in the client render function) still works, the same trick that lets any client component host RSCchildren. - AGENTS' repo layout no longer claims most of
test/runs with no DOM viarenderToStaticMarkup: that helper backs only 3 of the 15 test files. It now says what the rest use, hand-rolled element and global stubs, plustest/react.test.mjs's fake DOM forreact-dom/client(ref and effect tests) andtest/canvas.test.mjs's canvas/observer stubs. - README's pin-helper paragraph and its hand-kept twin, the
track()comment insrc/core/driver.ts, said the wrapper returns to flow "under reduced motion, or below the individual-transform floor" with nocompat()qualifier, the same unconditional claim already fixed in the table row and the paragraph below it. Measured withcompat()installed: the wrapper keepsheight: 320vh; position: relativeand the stage stays sticky, clipped. Both now read "below the individual-transform floor withoutcompat()". - AGENTS' browser-support paragraph dropped a leftover
(html.sv-on guard)parenthetical on the below-the-floor-without-compat()sentence: that guard ishtml:not(.sv-on), scoped to the no-JS case, not to below-floor-with-JS, whichpin.css's own@supports not (translate: 0)net covers instead.
Compat (blind review round 7, ADU-168)
compat()marks<html>withdata-sv-compatwhen it installs its fallback stylesheet, and the two below-the-floor releases added in round 6 now stand down on that marker. Each was right on its own for a page that calls nocompat():styles/pin.cssreleased.sv-stageunder@supports not (translate: 0)(ADU-149) and the pin helper stopped writing the tall wrapper height there (ADU-158), so a pinned section renders at its natural height instead of two blank viewports. Neither asked whethercompat()was installed, and the fallback sheet has no stage rule of its own, so with the module present--sv-pinjumped from 0 to 1 over a single pixel and thesv-curtain-l,sv-curtain-randsv-railfallbacks snapped instead of animating, which is the one thing the module exists to do. The marker is the only new surface: an attribute on the root element, written next to thedata-sv-compatattribute the injected<style>already carried.- The cost, stated plainly: this is a trade, not a free win. On a page that calls
compat()the round-6 releases no longer fire, so below the individual-transform floor the stage keepsposition: sticky,100vhandoverflow: hidden, and stage content taller than the stage clips again, which is exactly the defect ADU-149 measured in Chrome and fixed for everyone. The presets that module exists to animate are the ones that need the skeleton, and a page that would rather have the flow layout than the fallback animation gets it by not callingcompat(): without the marker both halves release as they did in round 6. - Size, measured: the marker rule's comment in
styles/pin.cssis trimmed to what a reader of that file needs, 122 bytes gzipped instead of 155, which takes the file's stamp from 3.2 back to 3.1 KB andstyles.cssfrom 9.0 to 8.9 KB. The rules themselves cost 69 bytes gzipped.
Presets and no-JS (blind review round 7, ADU-168)
- Every released guard in
styles/pin.cssgained the twin that fires when the tracked element IS the preset element:.sv-curtain-l[data-sv-off],.sv-curtain-r[data-sv-off],.sv-rail[data-sv-off],.sv-deck[data-sv-off](and its> *),.sv-reading[data-sv-off] > *,.sv-range[data-sv-off] > *and.sv-counter[data-sv-off].data-sv-offlands on the element whose tracker stopped, and a descendant-only guard asks for it on an ancestor, so a nested tracker on a.sv-deckkept its cards piled in one grid cell with the clock already gone, where the same markup without JS gives a plain block..sv-stage[data-sv-off]carried the shape alone since round 5.
Driver (blind review round 7, ADU-168)
- A motion-preference change now reads every pinned entry's computed position first and writes all the skeletons after, instead of reading and writing one entry at a time. The reversed order inside the helper made each read flush a style recalc of the write just made, against the README's "inside the driver, layout thrashing is impossible by construction". Ordering the read before the write is only half of it: the height cannot change the same element's computed position, so that read is free, but the write on one entry does invalidate the style the next entry's read asks for. Instrumented in Chrome with three pinned entries and a real preference flip, the per-entry order was read p1, write p1, read p2, write p2, and so on, so 2 of the 3 reads still landed with another entry's inline write pending: the reorder alone took N flushes to N-1, the hoisted read pass takes them to one. The position is read fresh on every pass and never cached on the entry, since an author media query can hand the element a
stickya stale read would overwrite withrelative. - Size, measured, because these are published numbers: the read pass takes the core entry (
scrollvars, min+gzip) from 6.4 to 6.5 KB,track+scanfrom 3.6 to 3.7 KB and the headline typical page from ~5.1 to ~5.2 KB. The stamps read 6.6 KB once round-3's own driver work is merged in, and the stamped bundle ratio against gsap + ScrollTrigger stays ~7×.
Gallery (blind review round 7)
- Every paste-the-preset CSS tab now carries the reduced-motion override its stylesheet ships, placed after the rule it overrides. Eight tabs (staggered reveal, deck spread, curtain, horizontal rail, sequenced scrub, split reveal, rotating words, marquee) quoted the animating rule and stopped before the sheet's
@media (prefers-reduced-motion: reduce)block, so a reader who pasted them got a reveal that still transitioned and a marquee that never stopped. Importing the stylesheet was always fine; only the pasted copies were short. - The Curtain, Horizontal rail, Sequenced scrub, GSAP and Three.js CSS tabs use the pin helper the other tabs already used:
data-sv-pin="250vh"(orpin: '250vh'in JS) plus.sv-stage, instead of an inventedclass="outer"/class="sticky"skeleton whose height and stickiness lived only in HTML comments next to a valuelessdata-sv-pin. Pasted as shown, those four effects pinned nothing at all. - Two new gates keep both true: every CSS tab that quotes a preset rule must quote that preset's reduced-motion override and keep it below the rule it beats, and every pane of a pin effect must carry a length on the helper and a real sticky stage. The three tabs with no installed component to compare against are now also loaded in Chrome twice, under no preference and under reduce, so the override is proved to win rather than merely to be present.
- The Horizontal rail and Sequenced scrub CSS tabs quote the shape
styles/pin.cssships, not a simplified one. The rail spelled its start offset as a literal100vwwhere the sheet readsvar(--sv-rail-start, 100vw)twice in the same declaration, so a reader who pasted the tab and then set the documented knob got nothing: at a 600px stage the preset travels 600 to -32, the pasted copy 1200 to 0. The sequenced-scrub tab declared--sv-clockon.sv .sv-range > *, where the sheet puts it on:where(.sv .sv-range)at zero specificity precisely so an author rule on the container wins; with such a rule the pasted copy rendered a child at opacity 0 that the preset renders at .5. - The three gates of the round above now hold what they claimed to: the pin helper's ATTRIBUTE is held to a length like the JS option already was (
data-sv-pin="true"passed, and the driver drops an invalid length and leaves the wrapper at its natural height), the sticky-stage check reads the pane's markup with<script>blocks stripped (a.sv-stageinside a gsap selector satisfied it), and the source-order half anchors per class on the last reduce block that resets THAT class (anchoring on the pane's last block left everything above it unchecked, and sticky-steps ships two blocks with the second below the rule it beats by design).
Click driver (ADU-169)
toggles()'s click handler now requires the resolved trigger to be inside its own scope (scope.contains(trigger)).event.target.closest('[data-sv-toggle]')walks the real DOM past the scope's own root: a click on a scope-internal element with nodata-sv-toggleof its own could bubble to an ANCESTOR trigger outside the scope, which this scope's ownsync()(scoped toscope.querySelectorAll) then had no way to reach, so that outer trigger'saria-expanded, and every sibling trigger of the same (target, class) pair, never updated even though its target class and--sv-statedid flip. Successor of ADU-152, which widenedtrackPointer's match the same way without widening this module's own containment check.
Pointer (ADU-169)
trackPointer()clears every element it has written--mx/--myto on handover, not just the single most recently written one. ADU-152 widenedmatchInto accept nested and self matches (a.sv-tiltinside another.sv-tilt); a single rememberedlastelement could not represent that handover, so the element being left behind was written once and never touched again, not even bydestroy(), which also only ever clearedlast. A realpointeroutcannot cover this either: matchIn/onOut's own containment check treats a move onto a contained descendant as still hovering the same widget. The driver now tracks the full set of written elements and relaxes (sv-pointer-leave,--mx/--myback to0) whichever ones a move's new match does not include, anddestroy()clears whatever is left in that set instead of one element.- Audited every other ancestor-walking call in
src/for the same shape (a scoped module resolving past its own root):slider.ts's twoclosest()calls (native-control detection under a press, and focus-restore after a non-drag press) are both deliberately unscoped, mirroring real unscoped browser focus/activation behavior rather than resolving ownership of a JS-scoped instance, so they are not the same bug.driver.ts'sclearReleased()walks every ancestor up to the document root by design (it is a singleton engine with no scoped-root concept to violate).debug/index.tsreads one directparentElement, not a walk.src/react,src/canvas,src/compatandsrc/core/scan.tshave no ancestor-walking call at all.
Presets (round 7, ADU-166)
.sv-marquee-trackunderprefers-reduced-motion: reduceno longer leaveswidth: max-contentinsideoverflow: hidden: everything past the first screen was unreachable for a reduce user while everyone else saw the whole strip. The reduce block now wraps the track (width: auto; flex-wrap: wrap, matching the rail's own reduce-mode wrap instyles/pin.css), a genuine accessibility fix, and hides the duplicate copy. Measured, the duplicate already carriedinertand reached the accessibility tree with zero extra nodes before this fix: hiding it is a visual correction, not an accessibility one, without it the duplicate's children wrap into view and the marquee's height goes from 96 to 240px.
Presets, React and Gallery (round 7 follow-up, ADU-171)
- The marquee's duplicate copy is hidden by a class, not by an inline style:
styles/ui.cssnow declares.sv-marquee-track > .sv-marquee-dup { display: contents }, reset todisplay: noneunderprefers-reduced-motion: reducewith no!importantneeded. The ReactMarquee(src/react/index.tsx) and the gallery's vanilla markup (scripts/fx-data.mjs) drop the inlinestyle="display: contents"on the duplicate and carry the class instead. - The reduce rule targeted
.sv-marquee-track > [aria-hidden], matched by attribute presence: a consumer's own direct child of the track carryingaria-hidden="false"was hidden too. The selector is now.sv-marquee-track > .sv-marquee-dup, which only ever matches the duplicate.
Tooling (blind review round 6)
- Git pushes no longer create Vercel deployments. Production is deployed by
npm run demo:deploy, which runsvercel deploy --prodfromdemo/and readsdemo/vercel.json, so the public site is unaffected. Every push of a work branch used to create a preview deployment that the project's ignored-build-step cancelled straight away, and a cancelled deployment still counts against the account's shared daily quota: 61 of the last 100 deployments were cancelled scrollvars builds, which exhausted the allowance and blocked another project's production deploys.
Driver
track()'s returned untrack is identity-guarded: it deletes and unobserves only if it is still the current entry for that element (entries.get(el) === entry), so tracking the same element twice and calling the first untrack no longer deletes the replacement. An explicit untrack now also removessv-liveand every variable that entry wrote (plus--sv-scenes);.svstays, and theoncefire-and-forget path keepssv-live(its self-delete already left the entry map, so the guard makes the later untrack call a no-op there, which is the intended behavior).init()is transactional: it constructs theResizeObserverbefore installing any listener, so a throwing constructor leaves nothing to undo.track()checksinitializedafter callinginit()and returns a no-op (no entry, no pin helper, no schedule, no.svclass) when it failed, so the page stays static untilcompat()shims aResizeObserverin and a latertrack()call retriesinit()clean.refresh()now forces one geometry pass through every entry on the very next frame, including culled ones (near === false): content changes that do not fire a resize (an accordion opening, an image swapped for a taller one) previously left offscreen trackers stale until they scrolled back into the culling margin.- A tracked element with a
rootnow measures againstroot.clientTopandroot.clientHeightinstead of the root's bounding rect, so a bordered scroll container shares one origin betweentrack()'s pin math andscrollToScene()'s scroll target.track()also observes therootwith theResizeObserver, so a resize of the scroller itself reschedules a measure. --sv-pin-offsetnow resolvesrem(root font-size),em(the element's own font-size),vh/svh/lvh/dvh(window.innerHeight) andvw(window.innerWidth) to pixels; a bare number still reads as px.--sv-pageand--sv-vskip the style write when the serialized value did not change from the previous frame.- Second pass (verifier findings on 00436db):
track()on an element that is already tracked replaced its entry in place, which left the identity guard on the first entry's untrack blocking forever, so a variable only the first entry ever wrote (--sv-tfrom{travel: true}followed by{}) stayed inline. A replacingtrack()now releases the previous entry's outputs first (its written vars,--sv-scenes, its pin helper, both observers), so it is exactly untrack then track. A root element that is also tracked standalone lost itsResizeObserverwatch the moment the standalone entry was untracked, becauseunobserve(el)fired for the shared element with no regard for the entries still using it as theirroot.untrack()now checks whether any other live entry still needs that element watched, either as its own tracked element or as itsroot, before unobserving it. - Third pass (verifier finding on 130395a): the
oncefire-and-forget branch inapply()still unconditionally unobserved its own element on the ResizeObserver when it self-released, bypassing thestillNeeded()guard the second pass added elsewhere. An element that is both aonceentry and another entry'srootlost that other entry's resize watch the moment theonceentry went live and settled. The branch now checksstillNeeded()before unobserving, same asreleaseEntry(), but keeps settling--sv-viewand latchingsv-liveitself: it still does not route throughreleaseEntry(). - Fourth pass (verifier finding on 293b1dc): the third pass guarded the
oncebranch's unobserve of its own element, but never released its ownroot. A{ once: true, root }entry that self-released kept the root'sResizeObserverwatch forever, sincereleaseEntry()is never called on that path. The two guarded unobserves (the tracked element and itsroot) are now oneunobserveIfUnneeded()helper, used by bothreleaseEntry()and theoncebranch, so the two paths cannot drift apart again. - Fifth pass (verifier findings on PR #13 / ADU-130, and a panel pass):
apply()kept writing--sv-viewand--sv-tinline, and fired one extraonTravel, after an entry's ownonLiveuntracked or re-tracked that same element from inside the callback.apply()now re-checksentries.get(entry.el) === entryright afteronLivereturns, before any of the writes and callbacks that follow, and skips them all when the callback changed the entry's identity.
Presets and no-JS
.sv-splitword/char spans compute todisplay: inline-block, sosv-split-risecan actually applytranslateto them (non-replaced inline boxes ignore it).- The reduced-motion override for
.sv-autonow matches the same:not(.sv-skip)compound as the normal entrance rule, so it wins on specificity instead of losing to it. - The no-JS guards for curtain, rail, deck, reading, range and counter now match
[data-sv]as well as.sv: markup that has not been scanned yet (data-sv only, no JS run) no longer leaves curtain panels as absolute overlays over the revealed content. sv-spreadandsv-actsare driven from the inherited--sv-liveflag, like the entrance presets: a nested tracker that is not itself live no longer inherits a live ancestor's spread or acts clock..sv-acts.sv-opennow also outranks the live-driven rule (.sv-acts:not(.sv-open)on both selectors): an opened widget sitting inside, or itself, a tracker that is not live keeps its finished--sv-actinstead of being reset to 0..sv-auto > :nth-child(1)(and.sv-stagger) resets--sv-orderto 0, so the first child never inherits an ancestor's order..sv-slider.sv-colsalso matches.sv-cols .sv-slider > *, so aclassName="sv-cols"on the Slider shell (one level up from.sv-slider, where React'sclassNameprop lands) works too.- Blind review round 5 (Astra on 7992458), findings 7a and 7b. The reduced-motion overrides for
.sv-spread > *and.sv-tilt.sv-pointer-leavelost their specificity fight against the animating rules they are meant to override (.sv-on .sv .sv-spread.sv-spread-in > *at four classes,.sv-tilt.sv-pointer-leaveat two, both outranking the media query's plain one-class selector), so a live preference switch animated the reset instead of snapping to it. Same fix as the.sv-autoone above, missed on these two presets: each override now also carries the animating rule's own selector shape inside the media query, ties on specificity and wins on source order.
Click driver
toggles()now markssv-uion the element it actually controls (the resolveddata-sv-target, or the trigger itself when there is no target), not on<html>. Marking<html>unconditionally meant an unrelated scroll-revealedsv-actswidget on a page without a booted scroll driver was wrongly exempted from the no-JS finished-state guard and stayed hidden at act zero forever. The guard is nowhtml:not(.sv-on) .sv-acts:not(.sv-ui): scoped to the widget itself. A target added to the DOM after boot getssv-uion its first click, so that first click shows the finished state with no visible transition.- Third pass: marking a boot-present target
sv-uinow holds its inlinetransitionatnonefor two animation frames. Without that, a target closed by default (.sv-actswithout.sv-open) settled from the no-JS finished value down to 0 with the acts transition still running, a visible un-animation the instanttoggles()took over. - Fourth pass: that hold read and wrote the inline
transitionshorthand (save, set tonone, restore). Two defects, both reproduced in Chrome: an inline transition longhand (e.g.style="transition-duration: 400ms") reads back as''through the shorthand getter, so the "restore" erased it for good; andtransition: nonestopped every transition on the element for the hold, not just the acts one, snapping an unrelated in-flight transform transition. The hold now sets an internal--sv-acts-settlecustom property to0sinstead, new and additive:.sv-acts's own transition reads its duration from it (styles/state.css), andtoggles()never touchesstyle.transition. - Fifth pass:
--sv-acts-settleonly reaches the duration the stylesheet itself declares on.sv-acts. An element that also carries its own inlinetransition-durationLONGHAND (the fixture's#longhand-target,style="transition-duration: 400ms") outranks that knob by cascade origin no matter what it is set to, so the settle for that element still played out over the longhand's own duration instead of 0s, reproduced in Chrome (3, 2.876, ... 0over 400ms): the exact un-animation this feature exists to remove. The shipped e2e case only asserted the attribute string survived and never sampled--sv-acton that element, so it stayed green.toggles()now also saves that inline longhand's exact value and priority, holds it at0sfor the same two frames (or until a click inside the hold cancels it, restored immediately there too), and restores it exact, through the longhand getter/setter only, never the shorthand. A target without an inline longhand never has one written, so an unrelated in-flight transition on it is untouched. What remains unguarded: an author RULE, not an inline style, that overridestransition-durationor the wholetransitionshorthand on.sv-actsat higher specificity than the preset's own rule still owns the settle timing (styles/state.css). - Sixth pass: the whole settle (
--sv-acts-settleand the inlinetransition-durationhold) now only runs on.sv-actstargets, gated only onsv-uibefore.getPropertyValue('transition-duration')cannot tell an authored longhand from the browser's own expansion of an unrelated inlinetransitionshorthand, so a plain toggle target with one (this module's own<nav id="menu">example, most of the time) had it forced to0sfor two frames regardless, snapping any change to it that landed inside the window instead of animating. A target that is not.sv-actsstill getssv-ui, nothing else.
Compat
compat()'s fallback stylesheet now coverssv-deckon engines missing individual transform properties: the pile unstacks into a static, non-overlapping layout instead of leaving every card in pin.css's shared grid cell.splitPartsno longer usesArray.prototype.flatMap(missing on Chrome 61-68 and Safari 11, the floor compat claims).- Blind review round 4 (Astra on 7489a11), findings 3 and 4. The ResizeObserver stub's records now carry a
contentRectand acontentBoxSize, not a bare{ target }:mountEffect()'smeasureLayout()readsentry.contentRect.widthdirectly and threw under the old stub. Both come from the layout box, like the native observer:clientWidth/clientHeightminus computed padding, nevergetBoundingClientRect(), which is the paint box and scales with an ancestor transform (undertransform: scale(2)a 400x300 canvas reported an 800x600 content box and settled its backing store there forever).contentBoxSizeis logical, so a vertical writing mode swapsinlineSizeandblockSize. - The fallback stylesheet's
sv-deckrule bounded its--sv-slicewithmax()(shipped together withmin(), above the floor README advertises) and had no plain declaration in front of it, so below that floor the wholetransformwas invalid and dropped, leaving every card stacked in pin.css's shared grid cell. The deck fallback no longer usesmax(): it unstacks statically (display: blockon the deck,transform: noneon the cards) instead of animating. Every othermax()in the sheet (onlysv-drift's fade) keeps the plainopacity: 1in front of it that old parsers fall back to, and is unchanged.
Testing
- The no-JS "pin stages never cover their revealed text" e2e sweep now scrolls each candidate into view before measuring: 6 of the 7 pin fx pages were previously off-viewport at scroll position 0 and silently skipped. The sweep asserts and prints a minimum examined count per pin page, so a regression back to zero coverage fails it instead of passing by omission.
- The boot-settle e2e fixture (toggles-boot-settle.html) gained two more targets: one with an inline
transition-durationlonghand, one with an unrelated in-flighttranslatetransition, proving the settle hold leaves both alone. Its post-click assertion now samples--sv-actacross frames instead of only the final value, so a transition silently reduced to zero duration would fail it instead of passing by omission. - Fifth pass:
#longhand-target's--sv-actis now sampled per frame like the main target, so a settle silently governed by the longhand instead of--sv-acts-settlefails the sweep instead of passing on the attribute string alone. A new#longhand-important-target(transition-duration: 400ms !important) proves the same hold and restore keep the original priority, not just the value. Unit tests cover the longhand hold-and-restore lifecycle exact (value and priority), that a target with no inline longhand never has one written, and that a click inside the hold restores it immediately alongside the knob.
Driver (blind review round 4)
- Releasing a tracked element now settles it VISIBLE.
.svand[data-sv]both declare--sv-live: 0, only.sv.sv-livelifts it to 1, andhtml.sv-onis never taken back off, sostopScan(), aScrollVarsBootunmount or a route teardown used to strand every section that had not gone live yet at opacity 0 forever, and an option change flashed content out and back. Release writes an inline--sv-live: 1(rather than dropping.sv, because server markup keeps its authoreddata-sv, which hides on its own);track()removes that inline value first, so tracking hands the flag back to the class. - The
pin: '320vh'helper no longer keeps an authored inlineposition: static: only a non-static authored position is kept, and a static one (inline or computed) getsrelative, so the containing block the helper promises really exists and an absolutely positioned curtain cannot escape the stage. - The
oncefire-and-forget path deletes its entry by identity (entries.get(el) === entry) BEFORE invokingonLive, so a callback that tracks the same element again keeps its replacement instead of having it deleted out of the map. - The write phase skips entries that are no longer in the map: an
onLiveoronSceneearlier in the same frame can untrack another element, and that element no longer gets one more variable write and one more callback after its untrack returned. scrollToScene()scrolls withbehavior: 'instant'underprefers-reduced-motion: reduceeven when the caller asked for smooth, mirroring the slider's glide.useScenes().goToroutes through it, so React scene navigation honors the preference too.
Canvas
mountEffect()'sapplySize()stops the unsized-canvas DPR feedback loop (ADU-107): a canvas with no CSS width/height lays out at its own backing-store size, so writingcanvas.width/heightafter every resize would otherwise feed straight back into the next resize, unbounded. Eight earlier passes (each one a verifier or panel finding, reproduced in real Chrome) all tried to catch that loop by measuring: a border-box-rect-vs- content-box-attribute equality guard that both missed a bordered unsized canvas and mispinned a legitimately CSS-sized one; a padding-inflated read; a fractional-padding rounding residual; a fallback re-measure that disagreed with a bit-exact ResizeObserverEntry for reasons that had nothing to do with feedback (fractional padding, a transform); a flat 1px tolerance that took dozens of passes to notice a small canvas's small move; a ratio check whosedpr > 1guard missed a zoomed-out page entirely and whose transform-inflated fallback read mispinned a padded, transformed canvas; a value- and timing-based "echo window" (matching a later ResizeObserverEntry'scontentRectagainst the exact size just written, within two animation frames) that could not tell a genuine CSS resize landing on that same number apart from its own echo (a 300x150 canvas at dpr 2, doubled to 600x300 by a class applied a frame after mount, lands exactly on the 600x300 the harness itself had just written), because a comparison of two numbers never asks WHY they match, only THAT they do; and (eighth pass) a causal probe that bumpedcanvas.width/heightup by a flat+1EACH and readcanvas.clientWidth/clientHeight, pinning bothstyle.widthandstyle.heighttogether whenever either axis followed. That probe was genuinely causal, not a coincidence check, but three more verifier findings on it (all reproduced in real Chrome) showed it asked the wrong shape of question: a flat, per-axis+1perturbs the RATIO between width and height, not just their size, so an ordinarywidth: 100%; height: autocanvas (no CSS height at all, the auto height derived from the intrinsic ratio) could read as having moved on height and get wrongly pinned, at dpr 0.5, 0.8 and 1.25; pinning both axes together assumes both need it, so amax-width: 100%canvas not yet at its cap correctly got pinned on mount, but a later container shrink that engaged the cap left the pinned height frozen, distorting a 150x150 box instead of scaling it to 150x75; and a bare<canvas width="300" height="150" style="max-width: 400px">at dpr 2 had its backing-store write (600) clamped by the cap (400) before the probe ever ran, so it read no follow at all and settled visually inflated at 400x200, never pinned to its true, uncapped 300x150. The ninth-pass design narrowed the question: PROPORTIONAL, and about ONE axis. Right afterapplySize()writes the backing store (canvas.width = W,canvas.height = H), it perturbed both attributes together by the same factor (so the ratio between them holds steady) and forced one layout read (canvas.clientWidth), then restoredW/Hand read again. If the two readings differ, WIDTH follows the attribute (unsized on width, whatever height does, which is what catches themax-widthcases above); if they are equal, width is CSS-sized, whatever its literal source (a percentage, a fixed px value, or itself derived from a fixed CSS height through the intrinsic ratio), and nothing about height enters that conclusion. Only WIDTH gets pinned, to the CSS content widthapplySize()measured right before the write, forcingbox-sizing: content-box; height is left alone, deliberately, so it keeps deriving from the intrinsic ratio exactly as this harness's own proportional writes keep it stable, and a latermax-widthshrink correctly recomputes it from the new width instead of fighting a frozen number. The ninth pass choseMath.floor(W / 2)/Math.floor(H / 2)(halving) as that factor. Tenth pass, two more verifier findings on the ninth pass, both reproduced in real Chrome. Finding 1:Math.flooron a halved value does not preserve the ratio betweenWandHwhen they have different parity (one odd, one even), so a fixed-CSS-height, auto-width canvas (the mirror case: the auto width derives from the fixed height through exactly that ratio, e.g.style="height: 101px") could have its floored ratio read back a fraction off the true one, flipping the before/after comparison even though the CSS height never moved; swept across heights 99-151 and dprs 0.5-2, this hit 27 of 42 combinations, wrongly pinning an ordinary, fully-responsive canvas and then distorting it on the next CSS height change. Finding 2:<canvas style="max-width: 100px">with the default 300x150 attributes settles unpinned with a crisp backing store (the cap scaled by dpr) at every DPR above 1, which is CORRECT, not inflation: a cap at or below the canvas's natural size binds before the harness ever touches the attributes, so the box is already the cap's own size, and pinning it would be the bug, not the fix (the opposite of a baremax-width: 400pxabove the natural size, which stays the inflation case and must still be pinned). The probe now tries GROWING first (doubleW/Htogether: exact for any pair of integers, whatever their parity, which is what fixes finding 1) and, only if that shows no follow, also tries SHRINKING (halveW/Htogether, exact only when both are even, so it can never reintroduce finding 1's flooring bug) to catch a cap already binding on the just-written, dpr-inflated attribute that growing alone cannot reveal (growing further only stays behind a cap already behind it). At or below dpr 1, a cap at or below the natural size is a narrower guarantee: below 1 the just-written attribute can itself land below the cap, a genuine risk (the same unbounded loop this module exists to stop, just shrinking instead of growing), correctly pinned; at exactly dpr 1 the attribute lands exactly on the cap, genuinely stable, but the shrinking probe cannot tell that apart from just above it and pins here too, a narrow, documented, harmless over-pin (the pin lands on exactly what the cap already renders). Growing is skipped past the browser's own canvas-size limit (8192: doubling could itself trip it), and shrinking needs even parity, so a giant canvas with an odd dimension on either axis has no safe direction to probe in at all and is treated as already sized. A canvas with a genuine CSS size on both axes is never pinned and always follows a later resize, whatever its border, padding (integer or fractional), transform, or the current device pixel ratio (above 1, below 1, or exactly 1). Cost: two forced layouts per probe run, up to four per resize event when growing alone does not already decide it, never per animation frame, measured against real Chrome to settle in exactly oneresize()callback for most size/DPR pairs, the pin running synchronously inside the same callback that delivered the entry, before the browser ever renders the unpinned intermediate box. One remaining trade-off, documented in the module doc: a canvas with no CSS size on either axis still gets its width pinned inline by the harness (give it real CSS dimensions to keep control of its own size). Eleventh pass, two more verifier findings on the tenth pass, both reproduced in real Chrome. Finding 1: the tenth pass's probe ran AFTER the backing-store write and rounded width and height independently every pass, each from whatever the LAST pass had already produced; a mirror-case canvas (fixed CSS height, auto width) has its free width computed by the CSS engine through the intrinsic ratio, which IS this harness's own backing-store attributes, so independent rounding nudged that ratio a fraction every pass and the error compounded instead of settling: swept at dpr 0.5 this took 51 to 77 ResizeObserver passes to reach a WRONG fixed point (a square box instead of the true 2:1 one), and at dpr 0.8 it never reached one at all (a 103px fixed height diverged to a 422 backing height instead of the true 206). Finding 2: the same post-write probe timing perturbed the just-written, DPR-scaled attribute, so at a DPR that does not divide evenly (roughly 1.2 to 1.9) a first pass could already write a backing store past amax-widthcap before the probe ever ran against the canvas's true, natural size, and a second pass's probe then perturbed that already-inflated value and pinned at the inflated number instead of the true one (max-width: 100pxfalse-pinned across that range; a baremax-width: 400px, natural size 300x150, pinned at an inflated 450x225 at dpr 1.5 instead of the true 300x150). The design anchors every pin, and every free-axis derivation, to values captured on the very firstapplySize()call (mount), before this harness ever writes anything: the CSS content size the author's own CSS and attributes already produced (anchor), and the ORIGINAL width/height attribute ratio (ratio0, fromw0/h0). The causal probe now runs BEFORE this pass's own write, onw0/h0specifically, never on this harness's own evolving backing store, and never runs again once a canvas is pinned; when width follows,style.widthis set toanchor.widthandstyle.aspectRatiotow0 / h0, so the CSS engine derives height directly from the ORIGINAL attribute ratio from then on, never through this harness's own rounded backing-store attributes again (this also fixes the tenth pass's own documented sub-pixel residual on the free height axis: CSSaspect-ratiois computed exactly, not through a rounded attribute ratio). For a canvas that stays unpinned, a second kind of probe, two single-axis perturbations (width alone, height alone), decides which axis, if either, is ratio-derived from the other (the proportional, both-together probe cannot answer this on purpose: it never perturbs a ratio-tracking axis, which is what keeps it immune to the eighth pass's false-positive bug). That free axis is always computed from the OTHER, just-rounded axis andratio0, never independently rounded from its own, possibly-drifted measurement, which is what stops Finding 1's error from compounding. A dead band skips the whole pass, probe included, when the measured content size moved by less than 0.5 CSS pixels on both axes since the last write, unless the DPR itself changed; it absorbs the one small, self-induced residual the free axis's own write can still cause on its very next entry. Probingw0/h0instead of a DPR-scaled write fixes Finding 2's parity-mismatch inflation outright: amax-widthcap at or below the natural size doubles or halves the SAME, never DPR-inflated pair every time, so it reads as CSS-sized across that whole range; a cap above the natural size still only starts to bind once doublingw0/h0pushes past it, and pins at the anchor, never an already-inflated write. It does not remove the tenth pass's dpr-BELOW-1 case, and should not: at dpr 1 and above this harness's own write never drops the attribute below the cap, genuinely never pinned; below dpr 1 it can, unclamping the cap for real, a risk the mount-time probe (onw0/h0, never a DPR-scaled value, on purpose) cannot see coming. A separate escape check, right after computing each pass's own candidate write, reuses the same causal growth check against THAT candidate instead, and catches it there, still never over-pinning at exactly dpr 1 the way the tenth pass did (a genuine improvement, not just a port of the old behavior). One narrow limitation remains in the mount-time probe specifically, inherited from its own halving fallback's exactness requirement: a cap strictly between half the natural size and the natural size itself can still read as a false follow if it changes AFTER this canvas was already found sized; documented, not fixed in this pass. Twelfth pass, two more verifier findings on the eleventh pass, both on its pin. Finding 1: the pin usedanchor.width, the CSS content size MEASURED at the moment this canvas was judged unsized, which, for a canvas whose cap was already binding right then, IS the capped value, not the natural one (amax-width: 120pxcap on a natural-300 canvas pinned at 120); fixed at that measured value forever, the box never grew back when the cap later widened, or a percentage cap's container grew, a symptom the eleventh pass's own "gap" limitation was really this bug wearing a different value. Finding 2:style.aspectRatiowas set unconditionally tow0 / h0, silently overriding an author's ownaspect-ratio(a square 300x300 canvas withaspect-ratio: 1snapped to 2:1 on mount). The fix is simpler than the mechanism it replaces: an unsized canvas wants its intrinsic size, which is the attribute size in CSS px,w0byh0, and nothing measured.style.widthis now pinned tow0always, never a measurement, so CSS caps clamp it exactly as they always clamp an intrinsic size, LIVE, at mount and on every later change, no different from an ordinary CSS-sized element under the same cap;style.aspectRatiois set tow0 / h0only whengetComputedStylereports a ratio that still starts with theautokeyword, so an authored ratio is kept. This removes the eleventh pass's "gap" limitation outright: it existed only because the pin froze at a measured value a cap could later escape, and pinned atw0there is no stale value to escape from. It does not remove the escape check (a DPR below 1 landing a pass's own candidate write below a cap the mount-timew0/h0probe cannot see because the cap sits below halfw0, a separate blind spot, unrelated to the gap): a test proved that case still shrinks unboundedly without it, so it stays, retargeted to pin atw0like every other pin here instead of the candidate value. Also guardsratio0(w0 / h0) against awidth="0"orheight="0"attribute, which would otherwise make it 0, Infinity or NaN: such a canvas now skips the probe and the free-axis derivation entirely and is treated as CSS-sized, both axes rounded independently from the measured size. Stated plainly: an unsized canvas renders at its attribute size in CSS pixels, with a device-pixel backing store for a crisp bitmap; CSS caps and percentages still apply on top of that size, exactly as they would on any other element; give a canvas real CSS dimensions to size it any other way. Thirteenth pass, one verifier finding on the twelfth pass's own fix, live in Chrome: theaspectRatio === 'auto'guard never fired, for any canvas, authored or not. Chrome always reports a canvas's computedaspectRatioasauto W / H, the intrinsic width/height attributes appended to the keyword, never the bareautostring, sostyle.aspectRatiowas never written and height kept deriving from this harness's own DPR-scaled attributes instead; wheneverw0 * dprwas not already an integer, the rounding remainder reapplied to the current height on every later pass (<canvas width="30" height="61">at dpr 0.51 reached a 33-million-pixel backing height after 668 passes). Fixed by testingstartsWith('auto')instead of exact equality: an authoredaspect-ratiocomputes to a bare number pair with noautokeyword at all, so the new check still fires only when the author left the ratio alone, and now actually fires.- Blind review round 4 (Astra on 7489a11), findings 3 and 16.
measureLayout()now falls back to its owngetBoundingClientRect()measurement when a ResizeObserverEntry has nocontentRect(a stub without one, not just compat()'s own, now fixed too), instead of throwing onentry.contentRect.width. A resize whilepause()is active writes a fresh, blank backing store (canvas.width = Wclears the bitmap) but left the tick loop stopped, so the canvas stayed blank until whatever resumed it;applySize()now paints one frame synchronously right after that write whenever the loop is not running, without starting it. - Blind review round 5 (Astra on 7992458), finding 13 and doc row 4's canvas half.
getComputedStyle(canvas).aspectRatio.startsWith('auto')threw aTypeErroron any engine whose CSSOM has noaspectRatiosupport at all: the property is absent there, not an empty string, but the DOM lib types it as always a string, so nothing guarded the read.pinAtW0()threw out of bothapplySize()andmountEffect()on any such engine, below the README's own Safari 12.1 canvas gate. Fixed with an explicittypeofcheck ahead ofstartsWith, so a missing property is treated the same as'auto'instead of thrown on.media.addEventListener?.('change', ...)is a silent no-op on aMediaQueryListthat only implements the deprecatedaddListener/removeListenerpair (Safari below 14): both the DPR-resolution watch and the reduced-motion watch now fall back to it, symmetrically on mount and ondestroy().
Installed components (blind review round 3)
StickySteps'sinertspread now casts like the core does (as unknown as Record<string, never>): the previous inline ternary put astring | booleaninto abooleanprop, failingtscunder React 19 types. It also now subscribes to theprefers-reduced-motionmedia query'schangeevent instead of reading it once, so a live switch drops, or restores,inert/aria-hiddenon the stacked shots immediately.GsapScrubandThreeScenedeclare their mutable refs asuseRef<T | null>(null), notuseRef<T>(null): read-only under React 18 types.GsapScrubalso drives the timeline throughprefersReducedMotion()(imported fromscrollvars), so a reduced-motion visitor gets the finished frame instead of a scrubbed one.gsap-scrubandthree-scenedeclaremin: '1.13.0': the string pin helper and.sv-stagethey both use are 1.13.0 features, not the 1.9.0/1.11.0 previously declared.curtain,horizontal-railandpointer-tiltdeclarerequires.tailwind: true: their installed content leans on Tailwind utility classes with no component-owned CSS backing them. The CLI prints "Tailwind utilities: required" for these effects; the registry gains thetailwindflag.CoverflowSlider's coverflow transform moved from an inlinestyleobject into a.cf-slideclass with aprefers-reduced-motion: reduceoverride, matching the preset policy that scroll-linked transforms return to flow under reduced motion. The Tailwind tab ofhero-cinematicgained matchingmotion-reduce:variants for the orb and the inner block.TimelineScrubrenders the year as visually-hidden real text plus an aria-hidden counter span, instead ofaria-labelon a bare<span>(prohibited on generic roles, Axearia-prohibited-attr).StatsCountupemits<dt>before<dd>(order was reversed), and renders the final value as visually-hidden text with the counter itselfaria-hidden.
Installed components (blind review round 3, second pass)
- The CLI component
tscgate was vacuous under a config-level error: a badmoduleResolutionprints astsconfig.json(9,25): error TS6046, which never matches the per-file<name>.tsx(line,col)regex, so every fixture reported "type-checks: pass" while tsc never actually checked any of them. The gate now counts everyerror TS\d+line in the raw output against the lines it can attribute to a fixture file and fails loudly, with the raw output, on any mismatch or on a non-zero exit with no per-file diagnostics. Proved red on a deliberately invalidmoduleResolutionbefore landing, green again after reverting it. hero-cinematic's Tailwind tab: themotion-reduce:override for.innersat on the.innerdiv itself ([opacity:1]/[scale:none], a one-class selector, specificity 0,1,0) while the base rule reaches.innerthrough the section's[&_.inner]:variants (a two-class selector, 0,2,0), so the override never won and reduced-motion visitors still got the scroll-driven fade and scale. The override now lives on the section in the same[&_.inner]:shape, after the base variants, so equal specificity lets source order settle it.- The condensed
react:doc snippets fortimeline-scrubandstats-countupreferenced<span style={SR_ONLY}>without defining it, unlike every other self-contained snippet (SR_ONLYis not exported fromscrollvars). Both now inline the sr-only style object literal at the point of use.
Installed components (blind review round 3, third pass)
- The second pass's
tscgate fix counted everyerror TS\d+line against the lines it could attribute to a fixture file, but the ambient stubsgsap.d.ts/three.d.tscompile in the same scope (needed to type-checkgsap-scrub/three-scene) and are not one of the EFFECTS fixtures the per-file loop asserts on: a syntax error injected intoAMBIENT_GSAPattributed cleanly togsap.d.ts(line,col), so the count matched, the "not vacuous" meta-test passed, and all 17 fixture tests reported "type-checks: pass" while tsc had exited 1 the whole time. The gate now fails the whole test file on any non-zero tsc exit, no matter how the diagnostics are attributed, printing the raw output; per-fixture attribution stays for the nicer message. Proved red by injecting a syntax error into the ambient stub (the gate failed with the rawgsap.d.tsdiagnostics, every fixture test still green), green again after removing it; a per-file error (injected intomarquee) still fails only that fixture's test plus the file-level gate.
Installed components (blind review round 3, fourth pass)
- The CLI component
tscgate spawnedtscwith a generated tsconfig that had nopathsredirect forreact, so the subprocess always resolved the root's React 19@types, even undernpm run test:react18: the--importloader hook only redirects the parent process's own runtime imports, never a subprocess it spawns. Notscrun anywhere checked the installed fixtures against React 18 types, so revertingGsapScrub'suseRef<T | null>(null)fix stayed green everywhere.react18-register.mjsnow setsSV_REACT18_DIR(its value read straight fromreact18-paths.mjs, the same modulereact18-tsc.mjsalready used forsrc/), and the gate adds the samepathsredirect and canary when that variable is set. Proved red by reverting theGsapScrubfix undernpm run test:react18(the gate failed with "Cannot assign to 'current' because it is a read-only property"), green again after restoring it. - With the gate actually checking React 18 types, four more fixtures failed it:
HeroCinematic,PointerTiltGrid,StickyStepsandThreeSceneall pass a hook'sRefObject<T | null>(the same shapeGsapScrubneeded to satisfy both majors) straight into a host element'sref. React 18's types compare that generic argument literally againstRefObject<T>instead of expanding both to{ current: T | null }, soT | nullfails whereTsucceeds even though the two are structurally identical. Each now casts the ref toReact.RefObject<T>at the JSX call site, the same shapeGsapScrubalready needed for its own mutableuseRef.
Installed components (blind review round 4)
stats-countupdeclarescore.cssas well asstate.css. The acts clock iscalc(var(--sv-live) * var(--sv-acts-count))and--sv-liveis declared incore.cssalone, so a consumer who imported exactly what the registry asked for got--sv-act: 0the moment the driver booted, and every number rendered as 0. Without JS the numbers were correct, which is why nothing caught it: the gallery page loads the wholestyles.css.StatsCountup's markup is valid again: the screen-reader value and thearia-hiddencounter both sit inside the<dd>(.count). The readable value used to be a<span>sibling of the<dd>inside<dl><div>, which is outside the definition-list content model, and the only<dd>wasaria-hidden, so every term reached assistive tech with no definition.- Every installed component renders its constant CSS with
dangerouslySetInnerHTMLinstead of a<style>text child. React 18'srenderToStaticMarkupescapes>inside<style>(React 19 does not), and<style>is a raw-text element, so the entity never decodes and every child-combinator rule was dropped: under React 18 SSR,sticky-stepslost the whole--st-drule and its shots stopped crossfading. CSP is unchanged from any other inline<style>: astyle-srcnonce or hash. test/cli-components.test.mjscompares the React 18 render with the React 19 one instead of returning early: that skip is what let the corrupted React 18 markup ship unseen.- New unit gate: an effect must declare the stylesheets the presets it uses read variables from, so a rule reading
var(--x)with no fallback can never again be one undeclared import away from computing to nothing. - Second pass (verifier findings on 91e2b9c): the gallery's CSS tab for
stats-countupstill generated the number on the<dd>itself (.stats .stat::after), while its React tab had moveddata-suffixonto a.countspan inside that<dd>. Copy both tabs, which is what the page invites, and::afterresolvedcounter(n) "": the number rendered on the<dd>without its suffix, on top of the readable value in the span, so the block announced "248+" and then "248". The double announcement this fix removed, reintroduced in the documented snippet. The CSS tab, the Tailwind tab and the React tab now all carry the.countspan, the rule is.stats .stat .count::after, and no.stat::afteris left.timeline-scrubshipped the same split (.tl-year::afteragainst a React tab with a.tl-countspan) and is fixed the same way. - The requires closure gate iterated
requires.stylesitself, so it could only ever check stylesheets an effect had already declared: a component using a preset from a stylesheet named nowhere had no rule to read and stayed green, andsticky-stepsonstyles: ['core'], with.sv-stageand thepin.cssthat owns it dropped, passed. Class and variable ownership is now mapped over all six stylesheets, and an effect must declare whichever one owns each class it renders, whichever one those rules read their variables from, and whichever one declares a variable its own embedded CSS reads without a fallback.
Testing (blind review round 4)
- New harness step, the isolated installation gate (
demo/bench/harness/installed-gate.mjs,render-installed.mjs, wired intonpm run test:e2e): per effect indemo/fx/registry.jsonit installs the component into a temp dir with the real CLI, renders it with itspreviewPropsunder React 18 and React 19, and loads it into headless Chrome with ONLY the stylesheetsrequires.stylesnames, plus the engine. It asserts the page renders complete with no engine at all, that the effect's key behavior happens with the engine (the counters resolve above zero, the pin clock scrubs the year counter, the shots crossfade, the hero is split and live), that reduced motion hides nothing and adds no inert content, that a<dl>'s content model and terms hold, and it counts the focusable elements and fails if any is unreachable: the four Sections ship none with their preview props, so that last one is a guard, not a claim. Proved red on the three defects above before they were fixed. npm run test:e2einstalls the isolated React 18 first (scripts/react18-install.mjs, the same onetest:react18uses): the gate renders under both majors and fails loudly rather than silently halving its coverage.- New unit gate on the gallery tabs of a Section: every class the React tab renders is shown or styled in the CSS tab, and every
content:pseudo-element the CSS tab generates hangs off a class the React tab renders. The two tabs are one block spelled twice, and a reader pastes both. Proved red on both pane splits above. - Second pass (verifier finding on fe4d844): that gallery gate compared two flat SETS of class names, so either half of the pane split it repaired stayed green on its own. The rule back on
.stats .stat::afterwith the.countspan still in the CSS-tab markup announces "248+" then "248" (contentcomputingcounter(n) ""), the original defect verbatim; the mirror, the rule on.countwith the span dropped from the markup, ships a snippet that renders no number at all. Everycontent:rule in the CSS pane whose value usescounter()is now checked against the element it prints on: the class must appear in that pane's own markup, and it must be the class the React pane hides from assistive tech when the React pane hides one. Proved red on both mutations.cssPaneClasses()strips comments asclassesIn()already did, so a class named only in prose no longer counts as documented. installed-gate.mjscloses its HTTP server and every page it opened in afinally: a throw in the render path no longer leaves a listening socket and a pile of tabs behind for the rest of the run.
Tooling
npm run demo:syncis idempotent again: the bench page's inlined engine marker was lazy on the content but only matched a fixed 3-newline gap before</script>, and the replacement kept the fresh IIFE's own trailing newline on top of that gap, so every run against a live driver added one more blank line anddemo/bench/scrollvars.htmlnever settled. The marker now consumes however many blank lines already accumulated instead of a fixed count, so it self-heals instead of drifting..github/workflows/ci.ymland.github/workflows/release.ymlnow includedemo/bench/scrollvars.htmlin the generated-files gate.
Slider
Blind review round 3 (GPT-6 Astra), findings 8, 9 and 10, verified in real Chrome with a puppeteer-core probe (5 slides of 100px, container 300px wide, scrollWidth 500).slideStart()walked the offsetParent chain and always subtracted the container's own border (clientLeft/clientTop), even when the container itself was the slide's offsetParent. offsetLeft is already measured against the offsetParent's padding edge in that case, so the border was subtracted twice: a bordered, positioned container gave -10 for its first slide instead of 0. The walk now stops the moment it reaches the container and only falls back to the absolute-position subtraction (plus the border) when the container is skipped entirely (a statically positioned rail whose real offsetParent sits further up).- RTL mirrored the slide start against
scrollWidthinstead ofclientWidth: measured in Chrome, a position:relative RTL rail gave 200 for its first slide instead of 0. The mirror now uses the container's own client box. measure()tracked only the active index, not the active element. A MutationObserver-driven replacement of that element (same index, new node, e.g. a framework re-render) leftsv-activeon the detached old node and never moved it to the new one.measure()now remembers the active node as well: a changed node at the same index movessv-activeand--sv-slidewithout firingonSlidefor an index that never changed.
React
useTrack,usePointer,useSlideranduseCanvasEffectnow attach through the ref itself instead of a mount-effect: the returned ref'scurrentis an accessor, so React's own attach/detach (any object with acurrentproperty, unchanged between React 18 and 19) runs the track/untrack. A conditionally rendered target that mounts after the first render, or a node replaced by a new one, used to sit untracked until an unrelated option changed forced the effect to rerun; both are now tracked the moment the node attaches, and untracked on detach. Option changes still retrack the current node. The ref type is declaredReact.RefObject<T>, same as before.<Scenes pin>now takes a string that wins overheightand the one-viewport-per-scene default (pin="320vh"), matching<Track pin>.pin={false}still disables the pin helper.<ScrollVarsBoot nonce>: forwarded to the pre-paint script tag, for a strict CSP with no'unsafe-inline'. Additive, no existing prop changes.<ScrollVarsBoot>'s debug overlay (?sv-debug) no longer mounts if the component unmounts before its dynamic import resolves: the effect cleanup now sets adisposedflag the import's callback checks first.useSlider'shandleRefkept pointing at a destroyedSliderHandleafter the tracked node detached (a conditional unmount, a node swap):next(),prev(),goTo()andhandle.currenton a detached slider drove a dead container, including starting a new gliderequestAnimationFrameloop nothing could stop. TheuseAttachedRefcleanup now also setshandleRef.current = nullbefore destroying the handle. Checked every other hook built onuseAttachedReffor the same shape (useCanvasEffect,usePointer): neither keeps a handle ref beside it, so onlyuseSliderneeded the fix.<Modal open>renders theopenattribute: a modal that starts open is now open in the server markup and stays open without JS and before hydration (README, "open ones open"), instead of shipping a closed dialog. On mount the effect removes that attribute and callsshowModal(): a dialog opened by the attribute is NOT modal,showModal()throws on an open NON-modal one, and the old!dialog.openguard skipped it and left it non-modal. It removes the attribute rather than callingclose(), which fires a close event that a controlled parent answers by closing the modal it just rendered open. That promotion runs exactly once per open: a repeatedshowModal()on a dialog that is already modal returns early by spec, but dropping the attribute first walks past that early return, and the second call records a node inside the dialog as the element to restore focus to, so closing dropped focus on the body instead of the control that opened the modal. Visible under StrictMode, which double-invokes effects in development and is the default in Next.js and in the Vite and CRA templates. The rendered attribute is frozen at the first render, since from mount on the effect owns it and React writing it would stripopenoff a modal dialog without taking it out of the top layer. The no-<dialog>fallback path is unchanged, the attribute still tracks state in both directions there. No new prop, no API change.
CI
- CI now proves the React layer on React 18, not only the React 19 the root installs: a new
test-react-18job (npm run test:react18, also runnable locally) installs react@18, react-dom@18 and their@typesintonode_modules/.cache/react18(its own package.json,--no-save, never the rootpackage-lock.json), type-checkssrc/against those@typesinstead of the root's through a generated tsconfigpathsentry (guarded by a canary that fails loudly if the redirect ever silently falls back to React 19), and runstest/react.test.mjsandtest/cli-components.test.mjswith anode --importloader hook that redirects everyreact/react-domimport to that install for the process (NODE_PATHdoes not affect ESM resolution). react: Marquee duplicate is aria-hidden and inertnow asserts the literal wire formatinert="", not just the attribute's presence: the case both React majors must agree on ({ inert: '' }under 18,{ inert: true }under 19).
React types
- Proved, with a new fixture, that the natural consumer idiom (
const ref = usePointer<HTMLDivElement>(); return <div ref={ref} />, no cast) type-checks under both React 18 and 19 forusePointer,useTrack,useScenes,useCanvasEffectanduseSlider: each hook's return type has beenReact.RefObject<T>since ADU-106, which already satisfies a JSX ref under both majors' types.test/cli-components.test.mjsnow compiles aHookRefIdioms.tsxfixture alongside the installed CLI components under the same tsc gate (bothnpm testandnpm run test:react18), and fails loudly if a future signature change regresses back to a nullableRefObject<T | null>. Theas React.RefObject<T>casts ADU-108 added aroundusePointer/useTrackinHeroCinematic,StickySteps,ThreeSceneandPointerTiltGridare redundant now, left in place to avoid touching lines the fx gallery work in flight also edits. No type or runtime change:dist/react/index.jsis byte-identical before and after.
Gallery
- The gallery preview for the four Sections (
hero-cinematic,timeline-scrub,sticky-steps,stats-countup) is no longer a hand-typed HTML string: it is the installed component itself, compiled with esbuild and rendered withreact-dom/server(the same pipelinetest/cli-components.test.mjsalready proves every fixture against), with a smallpreviewPropsobject per Section (scripts/fx-data.mjs) standing in for real content. Preview and component now share one source, so they cannot drift. A component that attaches via a client hook (usePointer,useScenes, a bare<Track pin>) has no scannabledata-svattribute in its server markup, so its gallery page keeps a tinypreviewScript(documented on the effect entry) that calls the vanilla driver directly oncesv.jsloads. test/cli-components.test.mjs's class-token parity check (installed component vs. hand-written preview) is replaced, for these four, by an assertion thatdemo/fx/<slug>.htmlliterally contains the component's own render; the check for every effect that still has a hand-written preview is unchanged. Rendering withpreviewPropsalso fails the test on any React warning to stderr.demo/bench/harness/e2e-invariants.mjs's pin-stage occlusion sweep no longer flags the visually-hidden sr-only text used alongside an aria-hidden visual counter (Split,TimelineScrub's year,StatsCountup's count:clip-path: inset(50%), by design the same text and position as the digit it describes): nothing on screen for it to cover or be covered by. The renderedTimelineScrubpreview is the first page that put this pattern inside a.sv-stage, where the sweep actually looks.- Second pass (verifier finding on 8ecd8f1): that sr-only exclusion tested
clip-path !== 'none'alone, which also excludes a normal-sized element that only wears a decorativeclip-pathmask (a circular reveal effect, for instance), so visible text covered by a panel there would silently drop out of the sweep. Real sr-only text is pinpoint-sized (1px by 1px, matchingSR_ONLY_CSSinsrc/core/split.tsandSR_ONLYinsrc/react/index.tsx) in addition to beingclip-path'd, so the predicate now requires both. A new negative fixture (demo/bench/harness/fixtures/pin-stage-clip-path-occlusion.html) proves a masked, normal-sized element under an opaque panel is still examined and reported. - Third pass (verifier finding on 4f637e0): the pinpoint-size half of that same predicate read
el.getBoundingClientRect(), which measures the painted rect. A real sr-only span nested under atransform: scale(2)ancestor (sv-tiltandsv-deckboth transform their content) paints at 2px by 2px, so it read as "not pinpoint", escaped the exclusion, and became a false occlusion candidate. The predicate now readsoffsetWidth/offsetHeightinstead, the layout box, which an ancestor transform never changes. The same fixture gained a positive case: a genuine sr-only span under a scaled ancestor, covered by nothing, that must be excluded outright (not examined, no false occlusion) while the existing masked, normal-sized element is still reported. - Fourth pass (panel finding): the "paste the preset" CSS snippets for
staggered-revealandsplit-revealkeyed the entrance rule on the.sv-liveclass, whilestyles/core.csskeys it onvar(--sv-live). A consumer who pasted the snippet instead of loadingcore.cssmissed the settle-visible behavior aonceentry gets fromreleaseEntry()(an inline--sv-live: 1with the class already removed), so the content silently dropped back to hidden the moment the tracker settled. Both snippets now copycore.css's own rule shape, the--sv-livevariable declarations included.
Docs
Blind review round 3 (GPT-6 Astra), section 2: wording that had drifted from the code it describes.- README's size intro: the "min+gzip" label now reads "JS min+gzip, CSS gzip as shipped", and the "typical page" number is stamped from
docs-data'stypicalsize instead of hand-typed; the sizes table intro reads "per module entry", not "per import"; the VARS table's velocity column reads "viewport-heights/s", not "vh/s". sv-auto's doc row says children beyond 10 share order 10, not "the rest". The pin helper doc says it setsposition: relativeonly when the wrapper is static, keeping authored positioning otherwise. The sequenced-scrub example usesdata-sv-pin="320vh"and asv-stagechild, not the empty attribute and.outer/.sticky.- The slider's two return shapes are documented separately:
slider()returns the handle itself (next/prev/goTo/seek/active/state/destroy);useSlider()returns{ ref, active, next, prev, goTo, handle }, wherehandleis a ref to that same handle. - The no-JS "complete static page" claim lists its two exceptions by design (class-toggled panels stay closed, native
sv-view-*still runs), and that a click-drivensv-actstarget needstoggles()to start at zero. Nested scrollers documents--sv-stage-heightand that a bordered root is measured from its client box.Split's char mode documents itsIntl.Segmenterrequirement on both server and client render (Firefox 125+), or emoji and combining marks can split differently across hydration. <Scenes>'s render function is documented as server-rendered inside a client component boundary, not "client-side"; and a stringpinwins overheight.<ScrollVarsBoot>'s pre-paint hiding is documented as gated onIntersectionObserverandResizeObserver. The compat floor explicitly coverssv-deckand no longer depends onArray.prototype.flatMap. AGENTS.md's build line now namesscripts/build-styles.mjs.scripts/docs-build.mjs's CHANGELOG-to-HTML renderer (mdLite) fixed two bugs: a###sub-heading rendered as literal text instead of a heading, and a multi-line bullet only wrapped its first line in<li>, dropping every continuation line from the rendered list. Rewritten line by line instead of by regex backtracking:###now renders as<h4>, and a bullet's indented continuation lines join into the same<li>.- Second pass (verifier finding on 31ee8af):
scripts/docs-build.mjs's generated browser-support table hardcoded Firefox at 74+ (Mar 2020), contradicting README's and AGENTS's 78+ (Jun 2020,:is()/:where()), the version core.css actually needs. Fixed to 78+ (Jun 2020). The same sweep found the table's per-part CSS sizes (core, pin, slider, tilt, state, ui) hand-typed and stale against whatdocs-stamp.mjsmeasures and stamps into README (core read 1.9 KB against a measured 2.2, pin 1.8 against 2.5, state 1.5 against 2.1, ui 0.8 against 0.7; slider and tilt happened to still match).scripts/docs-build.mjsnow importsmeasureSizesfromdocs-data.mjs, the same sourcedocs-stamp.mjsreads, so the generated docs page and README render the same measured numbers instead of two hand-typed copies that can drift apart.
React (blind review round 4)
<Modal>without native<dialog>support now closes. The effect branches once onshowModal: where the element is unknown there is noopenPROPERTY, so the old!open && dialog.openguard could only ever open it. The fallback drives the attribute in both directions withsetAttribute/removeAttribute, nevertoggleAttribute: the engines that reach that branch are the ones without<dialog>(Safari below 15.4, Firefox below 98), and Safari 11 and Firefox 60 to 62 are inside the supported floor while predatingtoggleAttribute, where the effect would throw and React would unmount the tree.useScenes()clamps its scene tocount - 1when the count changes. The driver emits nothing at all forscenes <= 1, so a count going from N to 1 used to keep reporting the last index forever, and a consumer marked its only shot inert.
Slider (blind review round 4)
- The wheel assist now reads the container's COMPUTED
scroll-snap-typeat init, not only the inline style: an instance set tononeby a stylesheet or a utility class (Tailwind'ssnap-none) is left alone, same as an inline one, instead of being snap-suspended and scripted to the nearest slide. state().progressand--sv-progressare clamped to 0..1. Elastic overscroll drove them past both ends, and a follower chained throughonScroll+seek(progress)was seeked outside its own range.
Click driver (blind review round 4)
toggles()writes--sv-stateat boot from the target's class, so markup that ships open no longer disagrees with its own class until the first click.- Triggers are grouped by the PAIR (resolved target element, toggled class) instead of by the
data-sv-targetstring, so two triggers naming the same panel through different selectors keep each other'saria-expandedin sync, while two controls toggling different classes on that same panel (a hamburger onopen, a second control onpinned) keep separate states. Grouping by the element alone made one click reportaria-expanded="true"for both, the second one with its class absent.
Presets and no-JS (blind review round 4)
.sv-counter'scounter-resetand::afterare keyed on:is(.sv, [data-sv]), like the no-JS guard that feeds them--sv-int: attribute-only markup used to get the variable but render an empty element without JS. The no-JS e2e sweep asserts the digits, not just the variable.dialog.sv-pop:not([open])'s fade is wrapped in@supports selector(dialog:modal): the type selector also matches the unknown element an engine without<dialog>parses, which hid the documented static fallback panel with no UAdisplay: nonebehind it.
Docs (blind review round 4)
Blind review round 4 (Astra on 7489a11), section 2: eight docs mismatches against the code ADU-129 to ADU-132 shipped.- The docs page's "Preset vocabulary" table now notes that a scroll-driven
sv-actsclock needscore.cssas well asstate.css, matching the import comments ADU-129 already fixed in README and AGENTS. - The no-JS exception list now includes the marquee:
ui.cssanimates it via a plain@keyframesrule that never depends on the driver. - The compat floor sentence separates what the fallback animates (reveal presets
sv-rise/sv-fade/sv-slide-l/sv-slide-r/sv-auto/sv-driftand pin presetssv-curtain-l/sv-curtain-r/sv-rail) from what stays static below the floor, with the real cause per preset:sv-split-risehas no fallback rule and its animating selector is an:is()those parsers drop whole,sv-spreadhas no fallback rule either but its selector parses fine and simply has notranslate/rotateto apply.src/compat's header comment named only curtain, rail and drift as the covered presets: it lists the same nine now, plussv-deck,sv-split-riseandsv-spreadwith a reason each. sv-deck's fallback is documented as ADU-131 shipped it: a static, non-overlapping unstack, nomax()involved.- Modal without
<dialog>support is documented as an open static panel:styles/state.cssscopes the closed-dialog fade to@supports selector(dialog:modal)and keeps the type selector out of thedisplay: nonerule, so nothing there hides the element in either state. Theopenattribute still tracks state in both directions (ADU-132), so the consumer's own CSS can hide it. - AGENTS.md's pinned-skeleton line now states an inline static wrapper gets
position: relative(ADU-130), matching README. - The knobs sentence notes the one exception to zero-specificity defaults:
--sv-order's automatic-stagger value is declared on the child itself, by.sv-auto > :nth-child(n)and.sv-stagger > :nth-child(n), and an inherited:rootvalue never applies where the child declares its own. Those rules are (0,2,0), so an override needs an inline value or a rule at least as specific. - The "SSR, SEO and the Lighthouse load profile stay untouched" claim is scoped to the no-JS path: a JS-enabled Lighthouse run sees the pre-paint script hide entrances before paint and the pin helper write heights on attach.
Driver (blind review round 5)
- The driver owns the live state of every element it tracks. It writes the flag twice now, as the
sv-liveclass and as an inline--sv-live, and re-asserts both on any measured frame where the DOM disagrees. A React<Track>rendersclassName={'sv ' + className}, so a prop change rewrites the whole class attribute and takes the driver-addedsv-livewith it: the section faded back out (.svalone declares--sv-live: 0), and a settledoncesection, with no tracker left to put the class back, stayed at opacity 0 for good. The inline flag survives the rewrite; the class returns on the next frame the driver measures. track()on an element that still carries a settledoncesv-liveclears the class and the inline flag before the first frame, so the new entry (which starts not live) and the DOM agree and the entrance replays when the element enters the band again.- Precedence, on tracked elements only:
--sv-liveis now an INLINE declaration, and inline outranks every non-important author rule. A rule of your own that lifts the flag (#hero.sv { --sv-live: 1 }) loses to the driver from the first frame its live state changes (writeLive()runs on a live-state transition or a class disagreement, so an element that never enters the band never gets the inline flag), and the "removesv-live, add it back next frame and the entrance replays" trick now works only on elements the driver does not track (a hand-flipped.sv, atoggles()-driven widget). On a tracked element the driver owns the flag: re-tracking replays the entrance instead. - The identity guard runs after
onTraveland afteronPintoo, not only afteronLive: a callback that untracks its own element no longer gets--sv-pin/--sv-scenewritten inline (variables the release had already cleaned up, so they stayed forever) plus one extraonScene. - The
prefers-reduced-motionlistener falls back to the deprecatedaddListenerwhenMediaQueryList.addEventListeneris missing (Safari below 14, inside the supported floor), where the optional call made the whole preference a no-op.
Presets and no-JS (blind review round 5)
- Added: the
data-sv-offattribute (public API, driver-managed). Releasing a tracked element now settles it to its no-JS RENDERING, not just to a visible entrance. ADU-130's inline--sv-live: 1covered the entrance presets, but.svstays andhtml.sv-onnever comes off, so afterstopScan(), aScrollVarsBootunmount or an option change the pin presets kept reading a clock that had stopped:.sv-curtain-l/-rsat closed over the content,.sv-deckstayed stacked in one grid cell,.sv-rangechildren stayed at--sv-r: 0(opacity 0), a.sv-spreadscrubbed from--sv-tstayed fanned into an overlapping stack and.sv-stagekeptposition: sticky,100vhandoverflow: hidden.releaseEntry()marks the elementdata-sv-off, and everyhtml:not(.sv-on)guard instyles/pin.cssandstyles/core.cssnow has a[data-sv-off]twin, so both class markup and[data-sv]markup settle static.track()takes the marker back off. The guard reaches the DESCENDANTS these presets style, which an inline variable on the tracked element cannot. - An attribute, not a class: React's
<Track>rendersclassName={'sv ' + className}, so a prop change rewrites the whole class attribute, and a released element has no tracker left to put a dropped class back. A class marker would have snapped the stage back toposition: stickywith the curtains over the content, permanently. - Each
[data-sv-off]twin is its own rule, never a selector appended to the:is(.sv, [data-sv])guards next to it: a parser that predates:is()throws away the whole selector list, and Firefox 72 to 77 is inside the supported floor and not covered by the@supportsblock below. - Second pass (verifier findings in Chrome on ce777d0): the marker is read as
[data-sv-off] X, which matches through ANY depth, so a released ANCESTOR settled every preset under a descendant whose clock was still running.track(outer),track(inner, { pin: true }),untrack(outer)left the inner tracker writing--sv-pininto a stage flipped back toposition: static, curtains atdisplay: noneand a deck unstacked. Nested trackers are a first-class pattern here (the nearest tracker, not any live ancestor, owns spread), and:has()is far above the supported floor, so the driver keeps the marker honest instead:releaseEntry()marks an element only once nothing tracked is left inside it, and each release settles the ancestors that were waiting on it, sincestopScan()releases an outer tracker before its inner one.track()strips the marker off the whole ancestor chain, not only off its own element, so a section re-mounting under a released one does not run its clock against presets already settled static. - The
.sv-spreadtwin fires when the tracked element IS the spread container too. Its no-JS guard (html:not(.sv-on) .sv-spread > *) requires no tracker ancestor, while the twin was a descendant combinator and needed a separate marked ancestor: with the documented scrub idiom on the container itself (<div class="sv sv-spread" data-sv data-sv-travel>) a released spread stayed attranslate: calc(100% + 16px),rotate: 5deg, where no JS givesnone. Both guards with no ancestor requirement now carry the second marker position too (.sv-spread[data-sv-off] > *,.sv-stage[data-sv-off]), as plain comma lists rather than:is(). The audit compares each twin's ANCESTOR SHAPE with its guard's instead of stripping a selector prefix, and reads every file instyles/, not the two that carry guards today. styles/pin.csscarries an@supports not (translate: 0)block: with JavaScript on and nocompat()call, an engine without individual transform properties (Chrome below 104, Firefox below 72, Safari below 14.1) runs the driver, sohtml.sv-onis set and the no-JS guards cannot fire, while the stacking half of these presets is plain layout and survives. The deck unstacks and the curtains open there too. The curtains open withtransformrather than the no-JSdisplay: noneon purpose:scrollvars/compat's fallback sheet re-expresses the same panels with that property and is appended later, so it still outranks this block and animates them.- Third pass (verifier and panel findings in Chrome on f5a81eb): the marker had two more holes, both in the bookkeeping around the waiting set. A
onceentrance descendant leaves the entry map insideapply(), not throughreleaseEntry(), and that second exit never swept the elements waiting on it: an ancestor released while such a descendant was still tracked stayed unmarked for good, its.sv-stagesticky and clipping with the curtains over the content, until an unrelated later release happened to sweep the backlog. The sweep is its own function now and both exits call it, without marking the settled element itself, which stays live. Andtrack()stripping the marker off the ancestor chain FORGOT those ancestors: released, unmarked, and never marked again. An ordinary<Track>prop change under a released shell reaches it, and so doesstopScan()followed by one section re-mounting, which is what the stripping exists for. A cleared ancestor that carried the marker, or was still waiting for it, goes back into the waiting set, so the next release that empties it marks it again. The sweep returns immediately while nothing is waiting, so an ordinary release pays nothing for either fix. markReleased()writes the attribute with the same optional callclearReleased()removes it with (setAttribute?.), so both halves of the pair hold on the same elements.- Size, measured, because these are published numbers: the release bookkeeping takes the core entry (
scrollvars, min+gzip) from 6.0 KB to 6.2 KB, and the stamped bundle comparison with it, from~8× less bundlethan gsap + ScrollTrigger to~7×(the ratio is arithmetic on the stamped KB, 46.3 / 6.2). The released twins takestyles/pin.cssfrom 2.6 to 2.9 KB gzip andstyles/core.cssfrom 2.3 to 2.4 KB,styles.cssfrom 8.2 to 8.6 KB and the headline typical page from ~4.7 to ~5.0 KB, with their comments trimmed to one note per guard family (styles/state.cssis back at its 2.2 KB: the note that had moved it is in this changelog, which costs no bytes on the wire).styles/core.csscrosses its rounding boundary on the RULES alone: 2392 bytes at the base, 2411 with the new selectors and no comment at all, against 2406 for 2.35 KB. - Fourth pass (the ADU-145 docs verification, round 5): the
@supports not (translate: 0)block above unstacked the deck and opened the curtains, but left.sv-stageatposition: sticky,height: 100vh,overflow: hidden, whose only escapes were the no-JS, released and reduced-motion guards, never this block. Measured in Chrome with the block's declarations applied by hand: four 45vh cards in an 800px stage landed at 0..360, 360..720, 720..1080, 1080..1440, so the fourth card sat entirely outside the clip box and the third was half gone. The block now resets the stage the same way its reduced-motion twin already does, and its own comment states exactly what it guarantees: the curtains sit parted and static, nothing overlaps, content stays in flow. Narrowed in round 7 (ADU-168): withcompat()installed the release stands down, so the stage stays pinned below the floor and stage content taller than the stage clips again there, and not callingcompat()is the escape. - Size, measured: the stage reset takes
styles/pin.cssfrom 2.9 to 3.0 KB gzip (2995 to 3030 bytes; the new selector alone costs one byte against the file's existing repetition, the comment the rest) andstyles.cssfrom 8.6 to 8.7 KB (8845 to 8880 bytes).
Gallery (blind review round 5)
Blind review round 5 (Astra on 7992458), findings 7c, 10, 11.StickySteps's reduced-motion block now resets.st-steps > liand.st-dots itoo (opacity 1, no translate, no scale), not only.st-shot: the pinned stage unpins under reduced motion by design, so--sv-scenekeeps advancing, and every step not at the current scene stayed at 30% opacity and slid with the raw scroll forever. The installed gate's reduced-motion pass asserts every step and dot resets, forsticky-steps.RotatingWordsguards an empty word list (no interval scheduled, so(i + 1) % 0never runs and--sv-wordnever goesNaN) and clamps the index on the render that sees a shrunk list, same shape asuseScenes. Previously a late word list (fetched after mount) could tick once against an empty array, poison the index toNaN, and never recover once real words arrived; a shrinking list stranded the index past the end.- The staggered-reveal and split-reveal "paste the preset" snippets carry a fallback on every
var(--sv-*)they read (--sv-ease, split-reveal's--sv-durationand--sv-staggertoo): withoutcore.cssthe bare vars made thetransitionshorthand invalid and the entrance snapped instead of animating. split-reveal's snippet also regained.sv-split > span[aria-hidden] { display: inline-block }(styles/core.css's own rule): without ittranslatedoes nothing on the non-replaced inline spans. The GSAP React snippet'suseRefis typedgsap.core.Timeline | null, matching the installedGsapScrubtwin, instead of a type that never allows thenullthe ref is assigned.
Scanner (blind review round 5)
scan()'sMutationObservercallback no longer untracks a node that is still connected. A DOM "replace all" (parent.replaceChildren(...),replaceWith) queues one mutation record with a retained node in BOTHaddedNodesandremovedNodes, and a list reorder splits the same move across a removal record and an insertion record in one batch: either way the node was never really removed by the time the observer fires. The remove path now bails withif (el.isConnected) return, so a retained or reordered node keeps its live entry instead of being untracked and re-tracked, which used to strip its state and hide it for a frame.
Scanner (blind review round 5, second pass)
- The remove path's connectedness check is
scope.contains(el), notel.isConnected. A scopedscan(root)only observesroot's own subtree: a tracked node moved OUT ofrootinto another still-connected part of the document leaked forever, since it stayedisConnectedand no further mutation record for it ever arrives.scope.contains(el)degrades to the same check asisConnectedwhenscopeis the document (the churn fix above still holds), is correct for a scoped root, and also fixesscan()on a genuinely detached root, whereisConnectedis always false and could never trigger the churn guard at all.
Pointer (blind review round 5)
trackPointer()only writes--mx/--myon a descendant of its own container.event.target.closest(selector)used to walk straight past the container, so a.sv-tiltANCESTOR of the tracked container matched and received the pointer output. The match is now required to be inside the container (container.contains(match) && match !== container).- Teardown now clears
--mx,--myand thesv-pointer-leaveclass from the last hovered element. It used to only remove the listeners and cancel the pending frame, leaving a destroyed instance's card frozen mid-tilt.
React (blind review round 5)
<Slider>composes a consumeronPointerEnter/onPointerLeavewith autoplay's hover pause instead of letting the props spread replace it. Both are public props (the component extendsHTMLAttributes), so a consumeronPointerEnterused to silence the pause entirely, and a lone consumeronPointerLeaveleft the slider hovering forever, autoplay never resuming after the first hover.<Slider>'s responsiveperViewstylesheet is rendered as raw text (dangerouslySetInnerHTML) instead of a<style>child. react-dom 18.3.1 escapes"to"inside a<style>, 19 does not, and<style>is raw text so the entity never decodes: on React 18 the server dropped every[data-sv-uid="..."]rule the responsive map emits, and hydration did not repair it. Same root as the gallery sections, which already render their CSS this way. The raw sink also drops React's</styleescaping, which is what kept an interpolated value inert, soperViewCsscoerces every part it interpolates withNumber(): aperViewoff untyped data (a CMS) renders--sv-per-view:NaN, a declaration the CSS parser drops, and can neither close the element nor emit a tag. No CSP change, the sheet is still one inline<style>.
Testing (blind review round 5)
test/react.test.mjs'sflushFramesrethrows what a frame scheduled by the running test throws, and keeps swallowing only frames left pending by earlier tests (queued callbacks carry the test that scheduled them). A driver or canvas frame that blew up could not fail a React test before. A frame scheduled from inside a running frame inherits that frame's test, not the flushing one, so a leftover canvas loop rescheduling itself does not blow up whichever later test happens to flush it twice. Two harness tests pin both halves.
Docs (round 5, ADU-145)
Docs read against the code merged by the five round-5 code tickets.onTravel/onPinare documented as exempt from near-viewport culling when tracked with a customroot, by design: the callback fires every frame no matter where the root sits on screen.- The "remove
sv-live, add it back next frame" replay trick is now documented as scoped to elements the driver does not track; a tracked or released element pins--sv-liveinline, which outranks a non-!importantauthor rule, so re-tracking is what replays the entrance there instead. - The outputs table now says which option activates each opt-in clock (
--sv-tneedstravel,--sv-pinneedspin,--sv-sceneneedsscenesgreater than 1), and thesv-spreadscrub recipe no longer reads as if--sv-twere always written. - The browser-support section now documents
pin.css's own@supports not (translate: 0)net accurately: it covers four rules (the stage, both curtains, the deck), not every pin preset; the stage resets to flow so nothing is clipped by the stage itself.sv-railis the one exception again: the no-JS wrap guard does not apply with JS running, so an unwrapped track can run past the viewport edge, reachable only by a page-wide horizontal scroll and not at all under anoverflow-x: hiddenancestor;compat()restores its travel, but with the stage released into flow that travel mostly happens off screen. Also noted, as a caveat pending ADU-150: a released stage can leave a parked curtain panel outside it, extending the document with an empty sideways scroll. - The "below the floor nothing breaks" design rule holds with or without
compat(): skip it and the page renders complete and static, call it and the page animates instead. data-sv-off, the driver-managed released twin ofhtml.sv-on, is documented next to it in README and AGENTS: added on release, removed on re-track, settles every preset under it to the no-JS rendering; a released ancestor still holding a tracked descendant keeps waiting.- README's compat paragraph and
src/compat/index.ts's header comment (the two hand-kept copies) stay in agreement; this pass added text next to them without touching that pairing.
Pointer (blind review round 6, ADU-152, live regression)
trackPointer()accepts a container that matches its own selector again. Round 5's ancestor fix (container.contains(match) && match !== container) closed the ancestor case it was written for but also closed the SELF case, which is how the gallery's flagship hero is wired (trackPointer(hero, { selector: '.sv-hero' })on the hero itself): every pointermove was silently dropped,--mx/--mynever wrote, and the orb parallax was dead.container.contains(match)alone still rejects an ancestor (an ancestor is never inside its own descendant) while allowing the container itself (Node.contains()is true for the node itself), so the extramatch !== containerwas never needed.
Testing (round 6, ADU-152 fix pass)
- The gallery regression guard's own selector match was a false negative: its
\b${cls}\bboundaries treat a hyphen as a word edge, so a selector reading.heropassed as long as ANY sibling class started withhero-(hero-orb,hero-inner), even though no element carries the exact classhero. It now splits eachclass/classNameattribute on whitespace and compares tokens exactly, and requires the CSS-side match to not be followed by a further word character or hyphen either. Proved red by mutatinghero-cinematic's React selector to.hero, proved green again on revert. - The same guard now also scans
previewScript, the fieldhero-cinematicactually renders through in the gallery (the exact path ADU-152 broke in production); it previously scanned onlypreview,css,tailwindandreact. trackPointer()teardown while the last hovered element IS the self-matched container (the hero's own wiring) is now a locked-in test: the runtime already cleared--mx,--myandsv-pointer-leavecorrectly there, this closes the coverage gap.
Slider (blind review round 6)
- The slider re-asserts the classes it owns on every measure, the way the driver does for its live flag:
sv-slider,sv-slider-yandsv-draggableon the rail,sv-activeon the slide nearest the centre. A framework that owns the rail'sclassName(React re-rendering it when a prop likeperViewchanges, with no retrack behind it) used to drop the first three, and a consumer restyling a slide droppedsv-activeuntil the active index happened to change. Each is oneclassListread per measure, with a write only when the DOM disagrees. state().positioninterpolates between adjacent slide CENTRES, so the documented continuous position never goes backwards. It normalized the distance by a single slide's own size before, which made it jump back at every midpoint as soon as the slides had a gap: two 100px slides 16px apart read 0.580 and then 0.430 one pixel of scroll later. Measured old against new on the same fixture: gapless sliders with equal-size slides read exactly as before (max difference 0.0000 over 121 samples across the whole range). Gapless sliders with unequal slides (--sv-spanmaking slides different widths,--sv-gap: 0, a real configuration) differ: 0.75 old against 0.6667 new at scrollLeft 0, maximum difference 0.0833. The new value is the one that is monotone and centre to centre; unequal gapless slides now reading centre to centre is the intended contract, not a regression.- The wheel settle (the glide 200 ms after the last wheel event) is dropped by whatever takes the position over inside that window: a pointerdown,
goToand everything routed through it (arrows, keyboard, autoplay), andseek. It only listened to the next wheel event and todestroybefore, so a drag started right after a trackpad pan had a glide fighting it. A press that drops a pending settle also resumes the snap the wheel had suspended, since the settle it replaced is no longer there to do it.
React (blind review round 6)
<Slider>'s engine classes survive a re-render:perViewis not an attach dep, so React rewrites the rail's class attribute with no retrack, and a consumer's own slideclassNamerewrite dropssv-active. Fixed in the core slider (above), so plainslider()consumers whose framework owns the class attribute get it too.
Scanner (blind review round 6)
scan()'sremoveSplitnow bails with the samescope.contains(el)guard asremove(). A retained[data-sv-split]node (a batchreplaceChildren/replaceWiththat keeps it, or a reorder split across a removal record and an insertion record in one callback) was still restored to its original markup and droppedsv-split, and nothing ever re-split it, since the guard added forremove()was not carried five lines down.removeSplitwas the only other early-exit path that guard had skipped.
Gallery (blind review round 6)
Blind review round 6 (Astra on 3e2c18b), finding 8a. Successor of ADU-144.sticky-steps's CSS tab now resets.st-steps > lito opacity 1 under reduced motion too, not only.st-shot: ADU-144 fixed this in the installed component but never in the tab the docs tell a reader to paste, so pasted code left every non-active step at 30% opacity forever under reduce. The reset sits in its own@media (prefers-reduced-motion: reduce)block placed AFTER.st-steps > li { opacity: calc(...) }, mirroring the installed component: both selectors are.st-steps > li, so with equal specificity the later rule in source order wins whichever one the media query matches, and a reset written into the existing media block above the base rule never applies. A gate (test/cli-components.test.mjs) compares, for every Section with both a CSS tab and an installed component, the selectors inside each side's@media (prefers-reduced-motion: reduce)block, past the installed component's own wrapper-class scoping (.sv-hero,.sv-steps, ...) and past chrome the installed component renders that the tab never documents (sticky-steps' dots), and fails on any drift. Audited every other effect for the same split:hero-cinematicalready agreed on both sides;timeline-scrubandstats-countupcarry no reduced-motion block on either side;coverflow-slider's tab and installed component reset the same three properties under its own, differently named class (.slideagainst.cf-slide), a naming choice the Sliders category is free to make, not drift, so the gate is scoped to Sections the same way the CSS/React pane-pairing gate above already is.- Second pass (verifier findings on 29669c0): that selector-text gate could not see either half of the defect it was written for, because it reads selectors and never declarations or cascade position. The e2e harness now judges the rendered result instead.
demo/bench/harness/installed-gate.mjsrenders every Section's CSS tab on its own (the tab's markup, only the tab's CSS,html.sv-onand the driver's own variables set by hand, underprefers-reduced-motion: reduce) and runs the identical probe the installed component passes: the reduced-motion assertion is now one function shared by both spellings of a section rather than two that drift. Proved red in Chrome on both mutations, the wrong value and the wrong cascade position, each of which leaves the selector-text gate green. - Third pass (verifier finding on the same round):
splitPaneindemo/bench/harness/installed-gate.mjssliced a gallery CSS tab into markup and CSS on the first blank line without checking that one was found. If a reformat ofscripts/fx-data.mjsever collapses that blank line,markupbecomes nearly the whole pane, CSS text included as unstyled nodes,cssbecomes one character, and the rendered page carries no applied stylesheet at all, so the reduced-motion probe passed by coincidence rather than by the behavior it claims to check, exactly the silent failure the file's own comment promised could not happen.splitPanenow throws a named error when the separator is missing. Proved red by collapsing the blank line insticky-steps's CSS tab and green again once restored; a unit test (test/installed-gate.test.mjs) covers both the throw and the ordinary split.
Canvas (blind review round 6)
- An engine whose CSSOM has no
aspect-ratioat all (below the README's Safari 12.1 canvas gate) no longer has its canvas markedpinned. The previous pass stopped the throw there but kept the premise: the ratio write was dropped by the engine, yetpinnedstill switched the backing-store write to rounding both axes independently, which is only safe once the CSS engine owns the height. It was instead still derived from the intrinsic attribute ratio the harness itself rewrites every pass, so the runaway ADU-107 fixed came back below the floor: a 30x61 canvas at DPR 0.51 walked 62, 64, 66, 68, 70 CSS px, growing 2 per pass, unbounded. Below the floor the harness now keeps the width pin (that part does land, and it is what stops the width axis from following), writes no ratio at all, and stays unpinned, so the free-axis derivation anchors height to the ORIGINAL attribute ratio and it settles on the second pass.
Driver (blind review round 6)
- The pin helper no longer writes its tall wrapper height below the individual-transform floor (Chrome 104 / Firefox 72 / Safari 14.1), the same way it already skips it under reduced motion. The
@supports not (translate: 0)net instyles/pin.cssreleases.sv-stagethere (position static, height auto, overflow visible), so a pinned section renders at its natural height, butheight: 320vhstayed on the wrapper: with JS on, the content sat at the top of the box with two blank viewports under it. The helper asksCSS.supports('translate', '0px')and takes the in-flow branch when the answer is an explicitfalse; an engine too old to answer at all is also too old for the@supportsrule that releases the stage, so the JS and the CSS always agree on which side of the floor the page is. prefersReducedMotion()andscrollToScene()no longer return a stalefalsebefore anything has ever been tracked. ThereducedMotionflag was only ever set insideinit(), which onlytrack()calls, so asking either function first picked the wrong answer for a reduce user. Both now read the media query lazily on first use, and fall back to the livereducedMotionflag onceinit()has wired its change listener (theaddListenerfallback for pre-Safari-14MediaQueryListis untouched).
Docs (round 6, ADU-159)
Docs read against the code merged by the seven round-6 code tickets.- The TypeScript import block that pulls in the CSS is no longer labeled
app/globals.css, a CSS file that cannot holdimportstatements: it now points atapp/layout.tsx(or any entry file). - "React renders zero times during scroll" is scoped to what actually never re-renders:
useScenesanduseSliderhold the current index in state and re-render on a discrete change, never per frame. sv-split-riseis documented as static only below:is()support, where its:is()-written rule is dropped whole; between:is()support and the individual-transform floor the rule still matches and itsopacitydeclaration still transitions, so the text fades in without rising.- The pin helper's "returns to flow" list gained a third case: below the individual-transform floor, matching ADU-158 (it already covered no-JS and reduced motion).
compat()'ssv-railfallback is documented as it actually behaves: it ignores--sv-rail-start, starts attranslateX(0)instead of entering from offscreen, and is stationary whenever the track's own width equals the viewport, instead of the previous "gives it back its own scroll-linked travel" claim.- Checked against the round-6 code tickets: the Modal SSR sentence ("open ones open") and the pin-helper wrapper description already match ADU-156 and ADU-158, no further change needed there beyond the floor case above.
- Second pass (verifier finding on 213c15b): the compat header comment in
src/compat/index.ts, whichtscemits verbatim into the publisheddist/compat/index.js, still opened with the old flat claim, three presets stay static, one reason each, includingsv-split-risewith no fallback rule at all. It now carries the same two-band split as the README sentence above:sv-deckandsv-spreadstay static, one reason each;sv-split-riseis static only below:is()support and fades in without rising above it. The README's closing "stay static or progressive" list also still namedsv-split-rise, reading like the old flat claim; it is dropped from that list now that the paragraph above it already carries the nuance.
Slider (blind review round 7)
- The active slide is the one nearest the viewport centre in PIXELS. The argmin divided each distance by that slide's OWN width first, so a wide slide always looked nearer than a narrow neighbour: with a 100px slide beside a 300px one (centres 50 and 250, midpoint 150) the active flipped to the wide slide at centre 101.
sv-active,--sv-slide,onSlide, the glide a drag release lands on and the wheel settle all took that index.--sdis unchanged, still normalized by each slide's own size, which is what the CSS reads; an exact tie still keeps the first slide. Sliders whose slides are all the same width read exactly as before. - A destroyed slider stops moving.
destroy()set no flag, sonext,prev,goToandseekstill scrolled the container from a frame of their own, on geometry nothing measures any more. Every command is a no-op afterdestroy()and nothing schedules a frame, so a scroll or an observer record already in flight measures nothing either.
React (blind review round 7)
<Slider>'s imperativedestroy()drops its handle instead of keeping it, so the autoplay interval (which reads the handle on every tick and returns when there is none) stops advancing a slider the consumer has destroyed. With the core fix above, a destroyed slider is inert from either side.
Canvas (round 8, ADU-189)
destroy()was neither idempotent nor exception-safe. A second call ran the consumer's ownsetup()cleanup again (disposing a renderer or a GPU buffer twice can itself throw), and a cleanup that threw skipped both theResizeObserver/IntersectionObserverdisconnects and all three listener removals (visibilitychange, the reduced-motion query, the DPR query), leaking them for the life of the page.destroy()now returns early on a second call, nulls the stored cleanup before running it, and runs the teardown in afinally, so a throwing cleanup can no longer skip it. The untouched twin of ADU-165, which fixed the same shape in the slider; no other module carries a consumer-supplied dispose callback a destroy path can re-run or skip past.- The idempotency guard above had its own gap: a consumer calling
destroy()reentrantly from inside its ownsetup(), beforesetup()has returned a cleanup (bailing out of a WebGL context that failed to create is exactly this shape), randestroy()with nothing yet stored to clean up, and then had the cleanupsetup()went on to return stashed into the same slot regardless, where nothing ever reads it again: a second, explicitdestroy()hit the early return above and the consumer's dispose never ran. Fixed by running that cleanup immediately whendestroyedis already true instead of stashing it. Thefinallyabove is still a flat sequence, so a throwing teardown step would skip the ones after it and mask the original error; documented, not fixed, since the observers'disconnect,removeEventListenerand the media query removal are all specified never to throw.
1.13.0 (2026-09-05)
Second source-level review round (Kimi K3 and Codex gpt-6-astra on a clean clone): every confirmed defect fixed, plus the primitives both reviewers said premium sections would need.Sections in the fx gallery
- New category Sections:
hero-cinematic,timeline-scrub,sticky-steps,stats-countup. Whole blocks built only on the presets, each with CSS / Tailwind / React snippets and a CLI component (npx scrollvars add <slug>). They are also the dogfooding that surfaced the items below.
Driver
--sv-page(0..1 through the document) and--sv-v(signed velocity in viewport-heights per second, decays to 0 after the scroll stops) are written on<html>every frame: reading-progress bars and skew/stretch effects need no JS.--sv-scenes(the scene count) is written next to--sv-scene, so a progress bar iscalc(var(--sv-scene) / (var(--sv-scenes) - 1))instead of a hard-coded divisor.- Pin helper:
pin: '320vh'/data-sv-pin="320vh"/<Track pin="320vh">sets the wrapper's height andposition: relative; with the new.sv-stagepreset (sticky viewport) the pinned skeleton is no longer hand-written. Under reduced motion the wrapper stays in flow. - Culling now gives every entry one geometry pass on a scroll jump longer than a viewport (anchors,
scrollTo, restored positions): an element carried from far below to far above no longer keeps stale variables. html.sv-onlands last, after every observer is built, and never if theResizeObserverconstructor throws.window.__scrollvarsmarks arrival.scrollToSceneuses the rendered height (like the pin math) and takes an optionalrootscroller;useScenes().goTopasses the root it tracks with.
React
<ScrollVarsBoot />renders a tiny pre-paint script:html.sv-onis set before first paint (no visible-then-hidden flash on SSR entrances) and removed again after 3s if the driver never booted. Place it first in<body>.<Scenes>forwardsroot,enter,exit; its stage is.sv-stageand returns to flow under reduced motion.useTrackonly hands the driver anonScenewhen the consumer has one, soonceentries are actually released.- Slider autoplay wraps when the rail reaches its end (multi-slide views never make the last slide active), and pauses on focus anywhere in the shell (arrows, dots, pause button), not only inside the rail.
Slider
- Geometry is rect-based and container-local: active slide and
goTotargets no longer depend on which ancestor is theoffsetParent; RTL mirrors against the container's own right edge. goTo(i, false)afterseek(), and a plain click that interrupted a glide, restore the authoredscroll-snap-type.- Orientation/drag classes follow the options on re-init and are removed on
destroy();state().positionis clamped tocount - 1.
Presets and no-JS
- Knob defaults moved from
.svto:where(:root): an author:rootoverride now wins regardless of import order. .svresets--sv-pin, so a nested tracked element never reads an ancestor's pin clock.- Split entrances get the nested-ownership guard; the reduced-motion override now outranks the nested hide rules.
- SSR markup carries
.svbefore the driver runs: rail, deck, curtain, reading, counter, spread and acts render their finished state without JS (html:not(.sv-on)rules).--sv-ris a registered property (initial 1). .sv-tiltangle is a knob:--sv-tilt(default 14deg).- compat: the transform fallback honours
sv-skip; the ResizeObserver shim delivers an initial observation, so legacy canvases start.
Sticky headers, knobs, interaction
--sv-pin-offset(one CSS declaration, e.g.:root { --sv-pin-offset: 64px }) is read by both.sv-stageand the driver's pin math, so a sticky header no longer breaks pinned sections;scrollToSceneaccounts for it too.--sv-rail-start(rail inside a narrower stage),--sv-reading-floor(default .55 so unread words keep a 4.5:1 contrast; was .13), and the sv-range clock now lives on the container at zero specificity, so.mine { --sv-clock: var(--sv-t) }overrides it.- StickySteps marks inactive shots
inert+aria-hiddenafter mount, so a crossfaded shot cannot keep focusable links. - e2e invariants: an SSR-shaped sweep (
.svalready on the markup, no JS) and a reduced-motion sweep (JS on, whole page scrolled) over every fx page. - Home page:
<main>landmark; the guided-reading demo passes color-contrast.
Canvas, debug, CLI, split
setup()may return a cleanup, called ondestroy();resize()also runs once aftersetup(). The three-scene component disposes its GPU resources.- Debug overlay renders element names as text, never markup.
- CLI: a flag value is not a positional (
add --dir src/ui marqueeworks), registry file names are validated as bare names, the fallback registry is the real second host. split(…, { by: 'char' })splits on grapheme clusters whenIntl.Segmenterexists (emoji and combining marks stay whole).--mx/--myare clamped to -1..1.
Review round 2 (Codex gpt-6-astra, 17 findings, all fixed)
- Entrance presets now animate from an inherited
--sv-liveflag (0/1) owned by the nearest tracker: nesting can no longer leak a parent's state, server markup withdata-svhides beforescan()runs, and the reduced-motion override always wins. The compat fallback uses the same flag. - Pinning:
--sv-pin-offsetis read for every pin consumer (scenes,onPintoo) and re-read onrefresh(); the pin helper remembers the inline styles it replaced, restores them on untrack, and follows live reduced-motion changes.<Scenes>no longer writes inline stage geometry: it uses the pin helper and.sv-stage(needsstyles/pin.css)..sv-stagegets--sv-stage-heightfor inner scrollers; rails wrap under reduced motion. onceentries settle--sv-viewbefore releasing (a drift child measured below the screen stayed invisible);--sv-page/--sv-vkeep following the scroll after the last entry released itself.- Slider: geometry from offset chains again (transform-immune, container-local, RTL against the content width), reads batched before writes, slides observed for size and childList changes, drag only on the primary button and never on native controls (inputs, textareas, selects, contenteditable).
- React:
onSceneadded later now re-tracks;<Split by="char">renders the graphemes it counts;inertis rendered asinert=""so React 18 keeps it; Modal falls back to an open static panel withoutshowModal. - Toggles:
aria-expandedsynced on boot and across every trigger of a target. - Gallery: every pinned preview and installed component uses the pin helper and
.sv-stage(no hand-written sticky skeleton anywhere); the hero stops its pointer drift and exit scaling under reduced motion; StickySteps dropsinertwhen it stacks the shots; Tailwind tabs carry the selector classes their JS/CSS needs; minimum versions bumped for the migrated components. - CLI: the registry fallback is the committed copy on GitHub (an independent host),
--dirwithout a value is rejected, the registry is only fetched forlist/add. - Docs: Firefox floor is 78 (
:is()), the bundle ratio is computed from the stamped sizes, throttle wording is honest (set through CDP, nominal), the scroll-tracking rAF claim is scoped, pointer has two listeners, page variables document their clamp and decay, the canvas example guardsctx.
One source for the shared facts
scripts/docs-data.mjsholds the variables table and measures every size at build;docs-stamp.mjswrites them into README and AGENTS between markers, generatesdemo/llms.txtfrom AGENTS.md, andbench-tables.mjsstamps the benchmark table into README, AGENTS and the bench page from the committed results. CI fails if any of those files differ from what the build produces.- The gallery data (
EFFECTS,COMPONENTS) lives inscripts/fx-data.mjswith no side effects;test/cli-components.test.mjscompiles all 16 CLI components with esbuild, renders the React ones withreact-dom/server, and checks that each uses the preset vocabulary its preview shows. - Registry entries declare
requires(stylesheets, peer deps, minimum scrollvars);npx scrollvars addprints them and checks the installed version. - Slider tests for a rail inside a positioned ancestor and for a vertical rail.
Docs and release
- Sizes are measured at build and stamped into every document (see README for the current numbers; nothing is typed by hand any more). Stale claims fixed across README, AGENTS, llms.txt, docs, fx recipes (rotating-words
vertical-align, split's sr-only span, "SplitText gap",.sv-opentoggle example, Parallax reads--sv-view, and more). - e2e invariants: every fx page must render every text node without JS; the split invariant follows the sr-only contract.
- Release workflow fails when CHANGELOG has no entry for the tagged version.
- Brand name is written ScrollVars in prose; every identifier stays
scrollvars.
1.12.4 (2026-09-02)
<Split>/split()droparia-labelon the container (axearia-prohibited-attron generic roles); the full text stays in a visually-hidden span, the animated spans arearia-hidden.
1.12.3 (2026-09-01)
Fixes a regression from 1.7.0's drag-click fix.- Slider: mouse drag no longer fights native text-selection. 1.7.0 removed
preventDefault()frompointerdownto stop drags from eating clicks on links/buttons inside slides. But that also removed the only thing killing the browser's native text-selection-drag. Without it, a drag that leaves the container starts a live selection, and the browser auto-scrolls toward the pointer to extend it. Fighting the slider's ownscrollLeftwrites every frame (visible as jitter/trembling, plus visible text selection). Fix:preventDefault()is back onpointerdownfor mouse (kills selection-drag at the source, same as pre-1.7.0), and the one real side effect. It also suppresses the browser's native focus-on-mousedown. Is repaired by manually restoring focus inendDragwhen the press turns out to be a plain click, not a drag. The click event itself was never suppressed bypreventDefault(confirmed in 1.7.0's own fix notes), so link/button activation was never at risk. Only focus was. Verified with a real mouse-drag test (Puppeteer, dragging 480px outside the slider): zero text selection, smooth monotonicscrollLeft, no jitter. And a unit test locks in both halves (preventDefault fires, focus restores on a plain click) so this can't quietly regress again.
1.12.0 (2026-08-27)
The last executable items from the review panel's path-to-9, plus launch readiness.split: SplitText-lite. Word/char spans carrying--sv-order(+--sv-counton the container), aria-label kept, spans aria-hidden, fully restorable. Zero-wrapper:data-sv-split/data-sv-split="char". React:<Split>renders the spans on the server. No client-side splitting, no layout shift, no hydration flash. New presetssv-split/sv-split-rise; pairs withsv-readingfor scrubbed text. New fx entry + CLI component:split-reveal.- Progressive-enhancement invariants, proven.
npm run test:e2e(harness): no-JS renders complete, nothing is ever hidden beforehtml.sv-onexists (probed from the first frame), attribute knobs land. All green; wired into CI. - CI (GitHub Actions): build + 21 unit tests + the e2e invariants on every push/PR.
- Low-end bench profile: the harness runs the 4× CPU-throttle suite headful (headless Chrome never produces frames under throttle. Found the hard way) and publishes
results/throttled-4x.json+ a table on /bench/. - Launch assets: og:image social card on every page, README hero GIF (generated by
harness/make-gif.mjsfrom a real scroll), andarticle/launch-kit.md, Show HN, thread, newsletter pitch, ready to fire on publish day. ScrollVarsBootmounts the debug overlay on?sv-debug(code-split; free otherwise). Release tags v1.7.0–v1.11.1 pushed.
1.11.1 (2026-08-27)
sv-wordsaligns to the text baseline at any host line-height. The preset usedvertical-align: bottom, which only lined up when the host line-height was ~1.15. Inside a loose-leading heading the rotating word sat visibly low. Nowvertical-align: baseline: an inline flex container exports its first item's baseline, so the rotator self-aligns by construction (all words share the same metrics, so every word lands on it).- Demo: the pizzeria wheel is height-driven. Px caps removed (58vh/46vw), and on vertical screens (
max-aspect-ratio: 1/1) the height rules (52vh radius, 48vh slices) so the wheel keeps ~60vh of presence instead of shrinking with the width.
1.11.0 (2026-08-27)
- Attribute knobs: the style attribute is now optional everywhere.
data-sv-order,data-sv-distance,data-sv-from,data-sv-tobecome the matching CSS variables, written once on mount byscan()(and for route-change nodes via the existing MutationObserver). Never in the frame loop, zero global CSS, safe for mapped CMS content where Tailwind's JIT can't interpolate classes. When typed CSSattr()settles, this mapping becomes pure CSS. - Docs: "One knob, four ways" before/after table (style attr vs attribute vs React prop vs arbitrary class vs sv-stagger) and a Browser support section on /docs/. The full matrix plus the
scrollvars/compatlegacy answer (previously README-only). - fx: staggered-reveal and sequenced-scrub teach the attribute path in their Tailwind tabs; the sequenced-scrub preview dogfoods it.
1.10.0 (2026-08-27)
"Hitting the ceiling never means rewriting". Interop as official recipes, not adapter modules (no new dependencies, the scope boundary stays).- Canvas harness works for WebGL/Three:
mountEffect(canvas, { context: null })grabs no 2D context. Your renderer owns the canvas, the harness keeps the lifecycle (DPR cap, resize, pause offscreen/hidden, delta-time, reduced-motion, cleanup).EffectFramegainscanvas;ctxis null in that mode.useCanvasEffectforwards the option. - Two Interop fx recipes (new gallery category):
gsap-scrub: author a GSAP timeline, scrub it viaonPin(one listener, one writer; with the honest per-page bundle note) (andthree-scene) a Three.js torus-knot scrubbed by the pin on the harness. Both ship CLI components (npx scrollvars add gsap-scrub|three-scene). - Docs interop section links both recipes; AGENTS/llms carry the pattern.
1.9.0 (2026-08-26)
Driven by the third blind-review round (Kimi K3 moved 7 → 8; the remaining criticisms became this release).- Offscreen culling. Entries far outside a one-viewport margin skip the per-frame
getBoundingClientRect(IntersectionObserver-gated; entries with a customrootandonce-completed entries are handled; tested). Long pages stop paying for sections nowhere near the screen. And the main bench scenario's total CPU flipped in scrollvars' favor with it. scrollvars/debug: the devtools story: a dev overlay listing every tracked element with live variable values, live badge, tracked-element outlines and click-to-scroll.import('scrollvars/debug').then(m => m.debug()).- Honest numbers, mechanically enforced. The demo footer version and wire sizes are stamped at build time from the actual dist (esbuild+gzip); fx pages compute the engine size from the real bundle; every stale 1.2/3.1 KB claim corrected to measured values (driver 1.4 KB gz, full core 3.9, slider 1.7, core.css 1.8). The demo page's inline driver is labeled as that page's teaching copy. The package in dist/ is the source of truth.
- Benchmark: medians of 5 runs (was 3) and a precisely-stated headline claim: not faster frames: the same frames for ~12× less bundle and a fraction of the heap, with total CPU trading blows (scrollvars wins shallow scenarios, batched GSAP wins deep subtrees; both published).
1.8.0 (2026-08-25)
The "path to 8" release. Everything the external review panel said would move the score, minus the parts only the real world can provide.sv-range: sequenced scrub choreography without a timeline. Each child of.sv-rangederives--sv-r(0..1) from its--sv-from/--sv-toslice of the parent clock (--sv-pin, else--sv-t);sv-range-riseis the ready-made flavor;mapRange(t, from, to, ease?)is the JS twin foronPin/onTravelconsumers. Reduced motion settles ranges at the end state. New fx entry + CLI component:sequenced-scrub.- Custom root scroller + configurable live band.
track(el, { root })measures against an inner scroll container (root rects read once per root per frame, still strictly read-then-write);enter/exitoptions: alsodata-sv-enter/data-sv-exitand<Track>props. Replace the hard-coded 75%/25% band. - Reproducible benchmark.
demo/bench/harness(puppeteer-core + CDP) reproduces every published number: symmetric pairings both directions (idiomatic AND batched one-trigger-per-section GSAP), medians of N runs, rotated engine order, calibrated CPU throttle, raw JSON committed. The bench page tables regenerate fromresults/latest.json(scripts/bench-tables.mjs); the inline engine resyncs from dist on every build; the 900-trigger Lighthouse row is labeled a stress test. - The style-recalc curve, published. Including where it loses.
?deep=Ngives every box a realistic subtree; at 50 nodes/box the batched GSAP build wins total CPU. The measured curve and the authoring rule (keep tracked elements thin; static content next to, not inside, animated elements) are on /bench/ and in the docs. - APG carousel contract for
<Slider>.role=region+aria-roledescription+labelprop; per-slide "i of n" annotation in place; visible pause/resume control whenever autoplay is set (.sv-pause, arrow knob family);aria-liveoff-while-rotating / polite otherwise;renderDotkeeps focus indication. Marquee pauses on focus-within. - First React-layer tests:
renderToStaticMarkupin plain node (also guards SSR): carousel contract,<Scenes>prop hygiene, inert marquee duplicate. react/react-dom join as devDependencies. - Human docs at /docs/: quickstart, the six variables, every export, preset vocabulary, coming-from-GSAP mapping (with the honest "keep GSAP" row), when-NOT-to-use, interop recipe, per-surface accessibility contract, troubleshooting, rendered changelog. Version visible on every gallery page.
1.7.0 (2026-08-25)
Fix release driven by a four-model external review panel (blind site evaluations + source-level code reviews). Everything below was independently found by at least one reviewer and verified before fixing.Core driver:
- Nested scrollers now work: the scroll listener runs in the capture phase, so scrolls inside modals and inner panels reach the driver.
- Travel/pin math uses the rendered box (
rect.height) instead ofscrollHeight. Progress reaches 1 on fixed-height elements with overflowing content. onceis fire-and-forget: after going live, entries with no continuous outputs (travel/pin/scenes/callbacks) stop paying the per-framegetBoundingClientRect.--sv-viewfreezes at its last value.scan()/toggles()no longer throw during SSR (default params were evaluated before the environment guard).
Slider:
- RTL support: positions normalize to logical coordinates (0 → range from the content start); arrows mirror; progress/seek/goTo correct under
dir="rtl". - Drag no longer eats clicks: a 5px movement threshold separates clicks from drags (links and inputs inside slides work again, focus included), and the accidental click after a real drag is swallowed.
- Arrow keys typed into inputs inside slides no longer move the carousel.
- A glide interrupted by touch (or with
drag: false) no longer leaves native snap suspended forever. - The scripted glide respects
prefers-reduced-motion(jumps instead).
React:
<Slider ref>works: the forwarded handle is a stable proxy that delegates at call time (it was permanentlynull).<Scenes>honors its declared props: tracking options reach the driver,onScene/as/VarProps work, and nothing leaks to the DOM.useSliderforwardsonScroll(it was typed but dropped).- Responsive
perViewbreakpoints emit in ascending order ({xl: 4, md: 2}no longer letsmdwin at desktop widths). - Autoplay pauses while keyboard focus is inside the slider (WCAG 2.2.2) and reads the last IntersectionObserver record, not the first.
- The Marquee duplicate is
inert, so its links aren't tabbable.
Canvas & pointer:
- Canvas re-applies its backing size when the monitor's DPR changes.
- Pointer tilt can't get stuck by a leave racing the queued rAF.
- Legacy-guard consistency: the canvas module uses the same optional
matchMedialistener calls as the driver.
Styles & packaging:
- Nested tracked sections: a live ancestor no longer reveals entrance children of an inner
.svthat hasn't gone live (plain CSS3 selectors). - Reduced motion on
sv-decklays cards out in flow instead of leaving them stacked in one grid cell. - The compat drift preset carries an
opacity: 1fallback wheremax()doesn't parse (the advertised Chrome 61 floor). sideEffectsglob is**/*.css,styles/*.cssimports survive tree-shaking.engines.node >= 18;pretestbuilds before testing;preparebuilds on git installs (npm i github:aduptive/scrollvarsworks).- README: documented stagger default corrected to 90ms (matches the CSS).
1.6.0 (2026-08-24)
- The fx gallery (/fx/ on the demo site): a growing library of copy-paste effects. Live preview, Tailwind + CSS + React tabs with copy button, knobs documented, per-gallery llms.txt for AI ingestion. Built from one data file (
scripts/fx-build.mjs); 8 seed effects. - shadcn-style CLI:
npx scrollvars list/npx scrollvars add <slug> [--dir] [--force]. Fetches a remote registry (registry.json on the fx site) and writes a complete component file into the project; the library grows without package releases.
1.5.0 (2026-08-24)
- React component kit:
<Slider>/<Slide>(the Swiper replacement:perViewnumber or responsive map. Breakpoints ARE media queries;gap,spanper slide, arrows/dots chrome on stable classes + var knobs,prevIcon/nextIcon/renderDot, external control via ref exposing the full SliderHandle,autoplaythat pauses on hover/offscreen/hidden),<Marquee>(infinite strip, the honest answer to Swiper loop),<Accordion>(native details + interpolate-size animation, exclusive groups via name),<Modal>(native dialog + sv-pop). - CSS:
sv-colscolumn sizing (--sv-per-view, fractional = peek;--sv-spanper slide), slider chrome,styles/ui.css(marquee, accordion).useSlidernow returns the handle and accepts duration/axis.
1.4.0 (2026-08-24)
sv-spread: the deck-to-grid pattern: children live in their final flex row and a per-card translate collapses them onto the center (slight fan) while--sv-spreadis 0. Two clocks:.sv-spread-inplays on arrival (sv-live + transition + stagger, re-deals on re-entry), or map the variable from--sv-t/--sv-pinto scrub it. Demo case 18, zero bespoke JS.
1.3.0 (2026-08-24)
sv-acts: multi-act timelines in pure CSS: a registered custom property (--sv-act) transitions 0 → N whensv-open(click) orsv-live(scroll) arrives; acts are the sameclamp()slices as the scroll scenes. One idiom for every timeline. Relative retiming (--sv-acts-durationrescales all acts), reversible and interruptible by construction. Older browsers snap to the finished state.
1.2.0 (2026-08-24)
- Click states: the third input:
toggles()(wired by ScrollVarsBoot):
data-sv-toggle/data-sv-target flip a class, write --sv-state and keep
aria-expanded in sync. Deliberately one click = one state change; no
timeline engine.
- Presets (
styles/state.css):sv-pop: popover/dialog/panel entry-exit via@starting-style+allow-discrete;sv-words: rotating words (clipped column,--sv-word: n), promoted from the pizza demo.
1.1.0 (2026-08-23)
- Modular stylesheets:
scrollvars/styles/core.css(1.2 KB gz.
pin.css (1.3 KB),
slider.css (0.4 KB), tilt.css (0.5 KB). styles.css remains as the
generated aggregate (scripts/build-styles.mjs). Fully backwards
compatible, selector set verified identical.
- README "Pay for what you use": measured per-import JS costs: a typical reveal page ships ~2.2 KB gzipped total (track + core.css).
1.0.0 (2026-08-21)
- API freeze. The 0.x surface ships as-is: driver (
track,scan,slider,trackPointer,scrollToScene,refresh), React layer (Track/Reveal/Parallax/Scenes/Item,ScrollVarsBoot, hooks, VarProps attribute API),scrollvars/canvas,scrollvars/compat, styles.css presets. SliderStatetype now exported from the root.- Packaging:
sideEffectsscoped to CSS (bundlers no longer risk tree-shaking the stylesheet import), repository/homepage/bugs metadata,prepublishOnlyruns build + tests. README gains a Defaults table. - Demo tooling:
npm run demo:sync/demo:deploy(dist-inline sync with parse checks; deploy re-points the alias and verifies). Driver core test suite added, 9 test files green.
0.12.0 (2026-08-20)
scrollvars/compat: opt-in legacy module: ResizeObserver stub (viewport-resize backed), always-visible IntersectionObserver stub, and atransform:-based preset fallback stylesheet (no:is()/clamp()/min()). Free on modern browsers (feature-checks and exits). With the consumer bundler downleveling ES2020, the animated floor extends to ~Chrome 61 / Firefox 60 / Safari 11.
0.11.4 (2026-08-20)
- Keyboard navigation glides: arrow keys (axis-aware), Home and End go through the same soft glide instead of native 40px key-scroll steps + hard snap settle. Containers are made focusable (
tabindex=0) so this works in Safari too.
0.11.3 (2026-08-20)
- Mouse drag:
preventDefault()on pointerdown: native text selection was starting under the drag, and selection auto-scroll inside the scrollable container fought the gesture (the opposite-direction tug). Note: mousedown no longer focuses elements inside slides; irrelevant for carousels.
0.11.2 (2026-08-20)
- Wheel-assist only reacts when the gesture's dominant axis matches the slider's axis: vertical page scrolling over the carousel (trackpad gestures are always slightly diagonal) was triggering mid-gesture
goTo(nearest)and made multi-slide travel feel impossible. Quiet window 160 → 200ms.
0.11.1 (2026-08-20)
- Mouse drag survives leaving the container: move/up listeners live on
windowwhile dragging (pointer capture on scrollable containers is unreliable). Release happens on the real pointerup, anywhere on the page.
0.11.0 (2026-08-20)
- Slider state & chaining:
state()andonScroll(state)expose active/count/position (continuous)/progress/dragging/gliding; container gets--sv-progress; newseek(progress)for followers: two sliders chain in one line (Swiper controller/thumbs, unidirectional). axis: 'y': vertical sliders (.sv-slider-y), same API.- Size reference measured: this module 3.1 KB min / 1.4 KB gzip vs Swiper 11 bundle 151 KB min / 42 KB gzip (+18 KB CSS).
0.10.3 (2026-08-20)
- Glide is now an exponential lerp (velocity ∝ remaining distance) instead of a fixed-duration tween: short drag-release settles feel as soft as long button glides. The tween front-loaded short distances and read as a dry snap. Retargets stay continuous.
durationcalibrates the settle time.
0.10.2 (2026-08-20)
- Rapid
next()/prev()clicks accumulate: relative steps count from the in-flight glide destination (pending target), not from the lagging active index. Five fast clicks land five slides ahead.
0.10.1 (2026-08-20)
- Drag release and trackpad pan now glide too: snap is suspended via inline style for the whole interaction (authored inline value preserved), and a wheel-quiet debounce replaces the native fast settle with the slow glide. Skipped on instances authored with
scroll-snap-type: none. - Grab cursor only on draggable instances (
sv-draggable),drag: falsesliders no longer advertise a hand they can't honor.
0.10.0 (2026-08-20)
- Slider glide: own eased scrollLeft animation (ease-out) with a configurable
durationfor next/prev/goTo AND the drag release. Native smooth scrolling is fast and not configurable. Snap suspends while gliding (sv-gliding); pointerdown cancels the glide (the user takes over).
0.9.0 (2026-08-20)
slider()/useSlider: featherweight Swiper: native scroll + scroll-snap, mouse drag, active-slide observer as--sdper slide (signed distance from center) +.sv-active,next/prev/goTo..sv-sliderCSS in styles.css.- Demo case 10/11: regional scroll-snap (onLive toggles the magnet) and the four snap flavors. Case 12: the treasure map: scroll drives a camera along an SVG path with heading + counter-rotating stations.
0.8.0 (2026-08-20)
onPincallback on TrackOptions and the React layer. Raw 0..1 across the pinned stretch, for frame scrubbing and camera tours. The demo already taught it; now the package has it.- Demo code samples audited against the current API:
--sv-yleftover fixed, deck/reading/counter samples now show the official presets,useScenesmisuse replaced byuseTrack({ scenes }).
0.7.0 (2026-08-19)
- Zero-wrapper mode:
scan()tracks every[data-sv]element (options viadata-sv-once/pin/travel/scenes) and follows DOM mutations;<ScrollVarsBoot />wraps it for Next.js layouts. Pages stay 100% RSC. - Presets promoted from the demo:
sv-deck(pinned card pile),sv-reading(guided reading),sv-counter(@property+counter()). - ESM-correct relative imports (
.jsextensions). Dist now runs in plain Node too, not only through bundlers.
0.6.0 (2026-08-19)
--sv-viewreformulated: now the signed position relative to the live band. The same 75%/25% lines thesv-liveclass uses, so the variable and the class always agree. −1 with the top at the viewport's bottom edge, 0 across the whole band, +1 once the bottom clears the exit line. Entrance/exit ramps are shorter than before (0.25 vh each);sv-driftreacts a touch snappier near the edges.LICENSEfile and this changelog.
0.5.0 (2026-08-19)
- Attribute API for React (Felipe's feedback):
order,distance,stagger,duration,easeas props onTrack/Reveal/Parallax.
<Item effect="rise|fade|slide-l|
slide-r|drift|tilt"> for children. The variables remain the real API.
sv-railfixed on wide windows: starts one viewport offscreen right, ends right-edge aligned (min()keeps it moving when the track fits).
0.4.0 (2026-08-18)
scrollvars/canvas:mountEffect/useCanvasEffect: lifecycle harness for ambient (time-driven) canvas effects: resize, DPR cap (2), delta-time loop, auto-pause offscreen and on hidden tab, live reduced-motion flag, full cleanup. Simulations stay in userland.AGENTS.md(agent-facing docs, shipped in the npm package) andtest/canvas.test.mjs.
0.3.0 (2026-08-18)
- Pointer module:
trackPointer/usePointerwrite--mx/--my(−1..1 from the element's center) with one delegated listener;sv-tiltpreset (3D tilt + glare).
0.2.0 (2026-08-17)
- Vocabulary finalized:
--sv-view,--sv-t,--sv-pin,--sv-scene,.sv-live,html.sv-onguard. React layer:Track,Reveal,Parallax,Scenes,useTrack,useScenes. Presets incl.sv-curtain-l/r,sv-rail,sv-autostagger,sv-view-*pure-CSS tier.
0.1.0 (2026-08-17)
- First cut: single global driver (one passive scroll listener + one rAF, batched read → write), CSS variables as the entire output surface, zero React renders during scroll.