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.
Honesty & hygiene
Real browsers
The rest of the surface
Phase 0 · sections 01 – 05
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
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.
what Vue / Svelte users generated
parsed attributes: …
what they get now
parsed attributes: …
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
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.
03
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
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 point | Raw | Gzipped | |
|---|---|---|---|
| svg-scroll-draw | 27.1 KB | 9.0 KB | |
| svg-scroll-draw/group | 23.6 KB | 7.6 KB | |
| svg-scroll-draw/reveal | 9.2 KB | 3.9 KB | |
| svg-scroll-draw/timeline | 7.0 KB | 3.0 KB | |
| svg-scroll-draw/text | 5.4 KB | 2.3 KB | |
| svg-scroll-draw/video | 3.9 KB | 1.9 KB | |
| svg-scroll-draw/pin | 3.3 KB | 1.5 KB | |
| svg-scroll-draw/snap | 2.5 KB | 1.3 KB | |
| svg-scroll-draw/lenis | 0.4 KB | 0.2 KB |
9 of 21 entries shown — the full table lives in README.md.
05
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.
| Claim | Was | Is |
|---|---|---|
| Bundle size (main entry)measured from dist/ | ~4.4 KB | 9.0 KB |
| Raw ESM sizemeasured from dist/ | 11.9 KB | 27.1 KB |
| Test countread from vitest | 272 | 531 |
| Example countread from ExamplesPage | 13 | 23 |
| Smaller than GSAP bymain entry | 8–10× | ~4× |
| Coverage gateagainst 91.3% real | 90 / 90 / 85 / 80 | 90 / 90 / 82 / 82 |
Phase 1 · sections 06 – 09
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
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 Δ.
live Δ (while you scroll)
0.0000
worst Δ in sweep
—
native supported
no
fast path engaged
no
07
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.
process.env.NODE_ENV !== 'production'…
typeof process !== 'undefined' && typeof process.env !== 'undefined' && ……
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 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×.
09
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.
scrollSnap 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
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
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 progress
—
new progress
—
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
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
style="" (nothing yet)
after the fix
style="" (nothing yet)
destroy() leaves behind.12
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.
snaps requested
0
onSnap calls
0
your OS preference
…
snapTo() button. The two counters must stay equal.13
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
—
getProgress() reports after the last one finishes.14
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
15
The limits, kept up to date rather than quietly dropped as they shrink.