internal · noindex

Verify.

Three phases of correctness work, most of which has no UI surface — engine internals, CI gates, packaging, README numbers. This page renders the parts that can be seen, so they can be checked by eye instead of taken on trust. Every section states what was broken, then demonstrates it live in this browser.

Phase 001 – 05

Honesty & hygiene

  • 5 false README claims
  • CI red on main
  • library never typechecked
  • 3 engine bugs
  • 2 CLI bugs
Phase 106 – 09

Real browsers

  • native CSS ≠ JS engine
  • CDN build crashed
  • idle CPU burn
  • scrollSnap ignored a11y
  • morphTo silently wrong
Phase 210 – 12

The rest of the surface

  • scrollHorizontal never moved
  • destroy() abandoned styles
  • onSnap fired twice
  • viewport override in the harness
  • no Range support = dead video

Phase 0 · sections 01 – 05

Honesty and hygiene.

Nothing here needed a browser to find. The package was advertising numbers that were measurably wrong, its own CI had been failing on every push, and the library was never typechecked — so a type error shipped in the engine and sat there across releases.

Coverage

74.6% → 85.9%, gate now passes

Tests

425 → 475

Size claim

~4.4 KB advertised, 9.0 KB real

Guards added

size budgets + doc-claim checker in CI

01

Generated Vue & Svelte files had an inert stroke.

The starter file from npx svg-scroll-draw init used JSX-style attribute names in HTML-parsed templates. Both graphics below request an identical 2.5px round stroke. Only one gets it.

strokeWidth="2.5"before

what Vue / Svelte users generated

parsed attributes:

stroke-width="2.5"after

what they get now

parsed attributes:

BUGThe left path asked for a 2.5px round stroke and rendered a 1px butt-capped hairline. The HTML parser lowercased strokeWidth to strokewidth, which is not an SVG attribute, so it was discarded. Every Vue and Svelte starter file from npx svg-scroll-draw init shipped this.

02

getProgress() was frozen at zero.

For every autoplay stroke animation, currentAlpha was only assigned on the clip-path branch — so the public getProgress() API reported 0.000 forever. The readout below is polled every frame from the real instance.

getProgress()0.000
peak seen0.000
WAITNot measured yet — this autoplay only starts once its container enters the viewport. Scroll it fully into view and the readout above will begin ticking.

03

Off-screen pause/resume burned the animation out.

Leaving the viewport set startTime = null. Because null coerces to 0, a later pause() recorded the whole timestamp since page load as elapsed time — so resume() started a run already past its own duration and completed instantly, invisibly.

idle — press the button

04

Every size figure is now measured.

The README claimed ~4.4 KB against a real 8.9 KB, directly beneath a bundlephobia badge showing the true number. These come from npm run size, which reads the built output and fails CI if any entry drifts past its budget.

Entry pointRawGzipped
svg-scroll-draw27.1 KB9.0 KB
svg-scroll-draw/group23.6 KB7.6 KB
svg-scroll-draw/reveal9.2 KB3.9 KB
svg-scroll-draw/timeline7.0 KB3.0 KB
svg-scroll-draw/text5.4 KB2.3 KB
svg-scroll-draw/video3.9 KB1.9 KB
svg-scroll-draw/pin3.3 KB1.5 KB
svg-scroll-draw/snap2.5 KB1.3 KB
svg-scroll-draw/lenis0.4 KB0.2 KB

9 of 21 entries shown — the full table lives in README.md.

05

Claims that were wrong, and now can't be.

scripts/check-claims.mjs derives the real test and example counts from source and fails the build when a doc disagrees. It caught a stale number mid-session, which is the only reason this table is right.

ClaimWasIs
Bundle size (main entry)measured from dist/~4.4 KB9.0 KB
Raw ESM sizemeasured from dist/11.9 KB27.1 KB
Test countread from vitest272531
Example countread from ExamplesPage1323
Smaller than GSAP bymain entry8–10×~4×
Coverage gateagainst 91.3% real90 / 90 / 85 / 8090 / 90 / 82 / 82

Phase 1 · sections 06 – 09

What a real browser found.

Phase 0 could not prove the library worked, only that its paperwork was honest — every unit test, 478 of them at the time, ran in jsdom with getTotalLength stubbed and IntersectionObserver faked. A Playwright suite across Chromium, Firefox and WebKit now runs in CI, and the first thing it did was disprove the library's headline claim.

Browser tests

30, across Chromium · Firefox · WebKit

Bugs found

5, none of them findable in jsdom

Headline claim

was false — native CSS ≠ JS engine

Worst divergence

0.114 in WebKit → now 0.0000

06

Native CSS and the JS engine disagreed.

The library's headline claim is that the native CSS fast path and the JS engine are interchangeable. Nothing verified it, and it was false. Below, the same SVG is drawn twice at the same scroll offset — left on whatever the browser supports, right with native: false forced. Scroll slowly and watch Δ.

native CSS view-timeline0.0000
JS engine (native: false)0.0000

live Δ (while you scroll)

0.0000

worst Δ in sweep

native supported

no

fast path engaged

no

WAITThis browser reports no scroll-driven animation support, so both boxes run the JS engine and Δ is trivially 0. That fallback is itself worth checking — Firefox 153 takes exactly this path.

07

The CDN build crashed in a plain browser.

Every dev warning in 13 modules was guarded by a bare process.env.NODE_ENV check. process does not exist in a browser without a bundler, so reaching a warning threw instead of logging one. Both guards are evaluated below in a scope that has no process.

beforeprocess.env.NODE_ENV !== 'production'

aftertypeof process !== 'undefined' && typeof process.env !== 'undefined' && …

WAITBoth expressions were just evaluated inside a fresh iframe — a scope with no bundler-injected process, exactly what a CDN user has. The old guard sat in front of everydev warning in 13 modules, so reaching one threw instead of logging. The concrete repro: style a path's stroke with CSS instead of a strokeattribute, and the engine's “no stroke” warning took down the whole call.

08

The JS engine worked while the page sat still.

The rAF loop ran every frame for as long as the container was in view, whether or not the scroll position had moved — recomputing values that had not changed. This is the path Firefox and every pre-115 browser always take, so it is not a niche case.

parked (1s)

scrolling (1s)

ratio

Both figures are baseline-subtracted: the page is timed once with no instances, then again with 8 alive. Without that subtraction this readout is dominated by React and the other live proofs on this page, which poll every frame — it read 1.6× where the isolated e2e fixture reads 7×.

WAITPress the button. Before the fix the rAF loop did identical work whether or not the user was scrolling: 8 instances parked in a viewport cost 6.4 ms per second in Chromium, recomputing values that had not changed.

09

scrollSnap overrode prefers-reduced-motion.

The clearest accessibility defect in the library, and the readout below is live against your own OS setting.

your OS preference

scrollSnap would

This readout is live — toggle Reduce motion in System Settings → Accessibility → Display and it updates without a reload. That is the same listener the library now uses.

PASSscrollSnap animates window.scrollToover a duration with an easing curve, driven off a debounced scroll handler — it takes over the user's scrolling. It had no reduced-motion check of any kind, which was the clearest accessibility defect in the library. It now jumps straight to the target section; snapping still happens, only the animated scroll is dropped. respectReducedMotion: false is the documented opt-out.

Phase 2 · sections 10 – 12

Everything else.

Phase 1 examined one API in a real browser and found five bugs. Everything else was unexamined, so this phase went through the rest of the surface: the other nine APIs, then Group / Sequence / Timeline / Cinematic, then scrollAnimate's own native fast path, then all eight framework wrappers and the CDN builds, and finally the same code again with no DOM at all. It found the worst defect of the three phases — an API that did nothing in the arrangement its own documentation prescribed — and ten more after that, including a server-side import that crashed the render and a CDN bundle shipping without the component it advertises. Sections 10–12 show three of them; the rest are in the changelog for 2.10.0.

Browser tests

30 → 175, on all three engines

Defects found

14 in the library, 2 in the test harness

Worst of them

scrollHorizontal never moved the track

New guard

every test must fail against a broken build

10

scrollHorizontal never moved the track.

The worst defect in three phases of this work. The default trigger window was measured against the track itself — and a sticky stage pins that track at exactly one stage tall, so both ends of the window resolved to the same scroll position. Progress was then clamped at zero forever. The API did nothing, in precisely the CSS setup its own docs prescribe.

old — trigger measured on the pinned track

01
02
03
04
05

new — trigger measured on the container that holds the scroll

01
02
03
04
05

scroll inside this box ↕

old progress

new progress

WAITScroll inside the box. The lower strip travels through all five panels; the upper one never moves at all, and its progress readout stays at 0.000 no matter how far you scroll. That upper strip is not a mock-up — it is the current library with triggerElement pointed at the track, which is precisely what the engine measured before this fix. The API had 100% line coverage in jsdom the whole time it was broken: every rect there is 0, so the window is equally degenerate and looks identical to a working one.

11

destroy() left elements stranded mid-animation.

The engine wrote opacity and transform inline every frame and removed neither on teardown. Destroy a component while its reveal was half-finished and it stayed half-finished — permanently faded and offset, with nothing left running to correct it. Worse for the visitor than never animating at all.

old teardown

Still here?

style="" (nothing yet)

after the fix

Still here?

style="" (nothing yet)

WAITPress the button. Both boxes are driven by the real engine and destroyed at the same progress — the difference is only what destroy() leaves behind.

12

onSnap fired twice for a single snap.

scrollSnap reacted to the scroll event its own animated scroll produced, treating it as a fresh user gesture and announcing a snap it had already announced. Guaranteed under reduced motion, intermittent otherwise — a public callback that fires once or twice depending on the easing curve.

section one
section two
section three

snaps requested

0

onSnap calls

0

your OS preference

WAITPress a snapTo() button. The two counters must stay equal.

13

A finished sequence reported 0%.

scrollDrawSequence delegates getProgress() to whichever step is active. When the last step completed, the active index advanced past the end of the array, so the getter read from an undefined instance and fell back to zero — a sequence that had visibly finished reported that nothing had happened.

Code

Build

Ship

getProgress()

0.000

peak seen

0.000

onComplete

WAITScroll until all three have drawn. The number to watch is what getProgress() reports after the last one finishes.

14

split: 'lines' deleted the spaces between words.

The only one of the three you can see without instrumentation. splitIntoLines groups words by offsetTop, and the whitespace nodes between them were dropped rather than reinserted — so a split headline rendered as one unbroken word. The sentence below is the test: read it.

Every word here should still be separated by a space after splitting.

spaces before

spaces after

split

lines

WAITMeasuring…

15

What still is not proven.

The limits, kept up to date rather than quietly dropped as they shrink.

  • CI going greenThe browser suite has only ever been proven on one laptop. Pushing the v2.10.0 tag triggered the first remote run; its result lives on GitHub Actions, not here. That run also fails deliberately at "is this version already published", because 2.10.0 went out by hand — see the next item.
  • 2.10.0 has no provenance attestationThe release workflow publishes with --provenance, which cryptographically ties a package to the commit and workflow run that built it. It needs an NPM_TOKEN repository secret, which was not set, so this version was published from a laptop instead. The workflow is wired and waiting; the claim is simply not yet true of anything on npm.
  • 531 unit tests are still jsdomgetTotalLength is stubbed to a constant and IntersectionObserver is faked. They verify engine arithmetic. The 175 Playwright tests per engine are what cover browser behaviour, and a separate suite with no DOM at all covers what happens on a server.
  • The wrappers are tested through their contracts, not their build pipelinesAll eight now mount for real and are held to the same contract — the engine runs, unmounting leaks no observer and no frame loop, re-mounting works. But Svelte is exercised through its action functions rather than a compiled component, and Angular through its ref classes rather than a running Angular app. That is the whole public surface in both cases, and it is still not the same as a real build.
  • Changing an option after mount does nothing in most wrappersOnly the Svelte actions re-create the engine when their parameters change, because Svelte calls update() for you. React, Vue, Solid and Angular read their options once on mount, so a changed prop never reaches the running engine. Defensible — re-creating on every render would thrash — but undocumented and surprising. The tests pin it so it cannot change by accident while the decision is open.
  • This page found a bug the e2e suite missedSection 06 kept reporting a constant 0.0201 offset. Trigger points were cached at init and only recomputed on a window resize, so a 21px layout shift during hydration left the JS engine permanently offset while the native CSS path stayed correct. Now fixed with a ResizeObserver — but only a busy, real page surfaced it, which says something about how much the isolated fixtures can prove.
  • Dev warnings still need opting intoThey were unreachable for CDN users entirely: IS_DEV is derived from process.env.NODE_ENV, and process does not exist in a browser without a bundler. 2.10.0 ships svg-scroll-draw.dev.global.js, which reports them — but you have to know to load it, and the production bundle has them removed at build time.
  • Two of the three 2.10.0 fixes are now demonstrated — sections 13 and 14A finished sequence reporting its real progress, and split: "lines" keeping its spaces, both now measure the current build live rather than replaying a recorded number.
  • The named-easing parity demo is withheld, and that is a finding in itselfA harness written for it measured the native CSS path and the JS engine 0.83 apart with easing: "ease-out", against 0.00 for the same harness on the default curve — one variable changed. That is far larger than the 0.069 divergence 2.10.0 fixed, and too large to be an easing curve difference, which points at the animation range rather than the timing function. It is one new harness on one browser and it may well be measuring wrong, so it is not being published as a verdict about the library. It is written up in NEXT-SESSION-PLAN.md with the repro, and this page will carry the section once the number is understood.