An accessibility auditor for scroll-driven animations and view transitions — the two CSS features that made large-surface motion cheap enough to put on every page, and that no general-purpose accessibility checker currently understands.
It ships as two front ends over one rule engine:
- a dependency-free browser script you paste into the console or run as a bookmarklet, which mounts a draggable, keyboard-accessible panel over the page you are looking at;
- a Node CLI that runs the identical rules in headless Chrome, with exit codes suitable for CI.
Not published to npm or any store. There is no
npm install scroll-a11y-auditand there never will be. Clone the repository and run it from source — see Installation.
- Why this exists
- Installation
- Quick start
- What it checks, and why it matters
- Rules reference
- How it works internally
- Scoring
- CLI reference
- Configuration
- Browser and bookmarklet usage
- Sample reports
- Continuous integration
- Limitations and false positives
- Development
- Contributing
- Further reading
- Licence
animation-timeline: scroll() and document.startViewTransition() made a
category of motion trivially easy to ship: parallax layers, reveal-on-scroll
cards, full-page cross-fades. That motion is also the specific category most
likely to hurt people. Large-surface movement, unexpected zoom and rotation
provoke nausea, dizziness and migraine in people with vestibular disorders, and
the most common implementation pattern — start at opacity: 0, let the
animation bring it in — makes content unreadable the moment the animation does
not run.
Existing tools do not catch this. They evaluate the DOM as it stands at one
instant, which is precisely when a scroll-driven animation looks fine. They have
no model of what a keyframe will do to an element, no notion of whether a
prefers-reduced-motion block actually wins the cascade, and no threshold for
how far is too far to move something.
This tool reads the CSS as a model — keyframes, timelines, media conditions, cascade order — and reasons about what the motion will do.
Clone it and install the single dependency:
git clone https://github.com/css-scroll-driven/scroll-a11y-audit.git
cd scroll-a11y-audit
npm installRequirements:
- Node 20 or newer.
- Chrome or Chromium, for the CLI only.
puppeteer-coredoes not download a browser; the tool drives the one you already have. See Chrome detection.
The unit tests need neither Chrome nor a network connection.
Audit a page from the command line:
node bin/scroll-a11y-audit.mjs --url https://example.comTry it against the bundled demo pages, one built badly on purpose and one built correctly:
python3 -m http.server 8000 &
node bin/scroll-a11y-audit.mjs \
--url http://localhost:8000/demo/bad.html \
--url http://localhost:8000/demo/good.htmlBuild the browser script and paste it into a DevTools console:
node scripts/build-browser.mjs # → browser/scroll-a11y-audit.jsThe ten rules group into four concerns.
Can the user turn the motion off? prefers-reduced-motion is an operating
system setting that a substantial number of people rely on, and honouring it is
not optional. Three rules cover it: whether a guard exists at all
(no-reduced-motion-guard), whether the guard actually works rather than merely
shortening the animation (reduced-motion-still-animates), and whether view
transitions — which no CSS guard touches by default — have their own opt-out
(missing-view-transition-reduced-motion). The third is the one teams miss most
often, because startViewTransition() never consults the preference on its own.
Is the content readable without the motion? The reveal-on-scroll pattern
puts opacity: 0 in the base rule and restores it from a keyframe. When the
animation does not run — reduced motion, an older browser, a failed script, a
print stylesheet — the content is simply gone.
content-only-revealed-by-animation catches the pattern statically and confirms
it against computed style. focus-order-breakage catches its sibling problem:
the invisible content is still in the tab order, so keyboard users tab into a
void.
Is the motion itself safe? motion-exceeds-safe-thresholds measures what
the keyframes actually do — translation as a share of the viewport, scale
factor, rotation, and oscillation frequency as a flashing proxy — against
configurable limits drawn from vestibular-safe design guidance.
no-pause-mechanism implements WCAG 2.2.2 directly: motion that auto-plays,
runs past five seconds and loops needs a control.
Does the motion break the page? animation-blocks-interaction finds
view-transition layers and animated overlays that keep hit-testing while they
play, swallowing clicks aimed at the content beneath. scroll-hijacking finds
the patterns that take scrolling away from the user entirely.
animation-without-motion-preference-scaling is informational: it notes when a
page has heavy motion but offers no in-page way to dial it down, only the
all-or-nothing OS switch.
| # | Rule id | Severity | WCAG SC | Triggers when | How to fix | Learn more |
|---|---|---|---|---|---|---|
| 1 | no-reduced-motion-guard |
critical | 2.3.3 Animation from Interactions (AAA) | A scroll-driven animation or @view-transition at-rule exists and no @media (prefers-reduced-motion: reduce) block neutralises it, either by name or through a blanket reset |
Add a reduced-motion block setting animation: none and animation-timeline: none for the selector, or a universal * reset |
Respecting prefers-reduced-motion in CSS |
| 2 | content-only-revealed-by-animation |
critical | 1.4.13 / 2.3.3 | The base rule resolves to opacity: 0 or visibility: hidden and only a keyframe restores visibility; or a scroll-driven element computes to zero opacity at audit time |
Make the resting state the readable state — animate transform only, or restore opacity: 1 inside the reduced-motion block |
Implementing prefers-reduced-motion |
| 3 | reduced-motion-still-animates |
critical | 2.3.3 Animation from Interactions (AAA) | A reduced-motion block exists but only shortens the duration, or is beaten in the cascade by a later rule of equal or greater specificity that re-declares the animation | Set animation: none, not a shorter duration; place the block after the rules it overrides and match their specificity |
WCAG 2.3.3 animation-from-interactions checklist |
| 4 | focus-order-breakage |
serious | 2.4.3 Focus Order (A), 2.4.7 Focus Visible (AA) | A focusable element inside an animated container computes to zero opacity, visibility: hidden, a zero-sized box, or sits far outside the viewport |
Use visibility: hidden or inert — which remove elements from the tab order — instead of opacity: 0 alone, and clear them once revealed |
WCAG 2.2 animation compliance |
| 5 | motion-exceeds-safe-thresholds |
serious | 2.3.3 (AAA), 2.3.1 Three Flashes (A) | Keyframe travel exceeds parallaxViewportPercent of the viewport, scale exceeds maxScaleFactor, rotation exceeds maxRotationDegrees, or opacity oscillates faster than maxOscillationHz |
Keep travel under a fifth of the viewport, scale near 1.1×, avoid rotating large surfaces, never oscillate above ~3 Hz | Designing parallax safe for vestibular disorders |
| 6 | no-pause-mechanism |
serious | 2.2.2 Pause, Stop, Hide (A) | A time-driven animation auto-plays, loops, exceeds loopDurationSeconds in total, and no pause/stop control is found anywhere on the page |
Add a keyboard-operable control that sets animation-play-state: paused; one site-wide toggle covers every animation it governs |
Pause, Stop, Hide for scroll animations |
| 7 | animation-blocks-interaction |
serious | 2.1.1 Keyboard (A), 2.5.3 context | An animated ::view-transition-* layer or full-viewport overlay lacks pointer-events: none, or a transform shifts an interactive container by more than 100 px |
Set pointer-events: none on transition layers and decorative overlays; animate wrappers that hold no interactive content |
Accessibility and inclusive motion standards |
| 8 | missing-view-transition-reduced-motion |
serious | 2.3.3 Animation from Interactions (AAA) | @view-transition, ::view-transition-* styles or startViewTransition() are used with no reduced-motion variant in CSS and no matchMedia guard in script |
Gate the script call on matchMedia('(prefers-reduced-motion: reduce)') and add a reduced-motion block setting animation: none on the pseudo-elements |
Detecting prefers-reduced-motion with matchMedia |
| 9 | scroll-hijacking |
moderate | 2.1.1 Keyboard (A), 2.4.3 Focus Order (A) | scroll-snap-type: … mandatory over content taller than snapContentHeightPx, a wheel/touchmove listener calling preventDefault(), or scroll-behavior: smooth / overscroll-behavior: none forced on the root |
Prefer proximity snapping, never cancel wheel events for layout, and scope smooth scrolling to prefers-reduced-motion: no-preference |
Vestibular-safe motion design |
| 10 | animation-without-motion-preference-scaling |
info | Best practice, supports 2.3.3 and 2.2.2 | Heavy motion is present and the page exposes no motion-intensity control — no --motion-* custom property, no labelled slider or toggle |
Expose an off/subtle/full preference writing a --motion-scale custom property, seeded from the media query |
Motion-intensity toggle with CSS custom properties |
The design constraint driving everything: one rule engine, two runtimes. A rule written once must produce identical findings whether it runs inside a page or inside a headless Chrome driven by Node. Rules therefore never touch a global — every capability arrives through an injected context object.
flowchart TD
subgraph shared["Shared rule engine — src/"]
CTX["Injected context<br/>{ document, window, getComputedStyle,<br/>stylesheets?, scripts?, config? }"]
CSS["css-model.js<br/>normalise CSSOM → plain objects<br/>flatten rules, keyframes, conditions"]
ANIM["animation-model.js<br/>parse timelines, durations, keyframes<br/>measure translate / scale / rotate"]
RULES["rules/*.js<br/>ten pure run(ctx, model) functions"]
ENG["engine.js<br/>filter → run → collect → score"]
CTX --> CSS --> ANIM --> RULES --> ENG
end
subgraph browser["Browser runtime"]
BUILD["scripts/build-browser.mjs<br/>concat + strip ESM → IIFE"]
PANEL["browser/overlay.js<br/>shadow-root panel, filters,<br/>highlighting, copy-as-Markdown"]
end
subgraph node["Node runtime"]
CLI["bin/scroll-a11y-audit.mjs<br/>parseArgs, config, Chrome detection"]
PPT["puppeteer-core → your Chrome<br/>page.evaluate(engine source)"]
REP["reporters/*.js<br/>pretty · json · markdown · html"]
end
shared --> BUILD --> PANEL
shared --> CLI --> PPT --> REP
BUILD -.->|"same bundle, overlay entry stripped"| PPT
Every rule receives exactly this shape. Anything absent is derived or degrades to an empty result rather than throwing.
| Key | Type | Purpose |
|---|---|---|
document |
Document or stub |
Element queries. Needs only querySelectorAll, plus documentElement and styleSheets when stylesheets is not supplied |
window |
object | innerWidth and innerHeight resolve viewport-relative units; location.href labels the result |
getComputedStyle |
(element) => style |
Returns anything with getPropertyValue. Lets the tests assert on computed values without a layout engine |
stylesheets |
array, optional | Pre-normalised sheets. When omitted, they are collected from document.styleSheets |
scripts |
string[], optional |
Inline script sources. When omitted, gathered from <script> elements |
config |
object, optional | Merged over the defaults by mergeConfig |
Because stylesheets and scripts can be handed in directly, the entire test
suite runs against a ~200-line hand-written DOM stub. There is no jsdom, and no
test needs a browser.
collectStylesheets walks the live CSSOM once and flattens it into plain
objects — style, media, supports, keyframes and view-transition rules —
recording each rule's enclosing at-rule conditions and its cascade order. From
that, buildAnimationModel produces the single structure every rule reads:
which rules animate, which are scroll-driven, which sit inside a reduced-motion
query, what each animation's keyframes actually do to translation, scale,
rotation and opacity, and how those measurements compare to the viewport.
That work happens once per audit, not once per rule.
scripts/build-browser.mjs is about 120 lines and has no dependencies. The
engine's module graph is a straight line, so the build concatenates the sources
in dependency order, strips the import statements and export keywords, and
wraps the result in an IIFE.
This only works because every top-level identifier across src/ is unique. The
build enforces that invariant itself: it collects declared names as it goes and
throws on a collision, naming both files. A refactor that reintroduces a
duplicate run function fails the build rather than producing a subtly broken
bundle.
The CLI reuses the same artefact. It reads the built bundle, strips the IIFE wrapper and the overlay's auto-mount call, and evaluates the engine inside the page — so the CLI and the bookmarklet are running byte-identical rule code.
Every audit produces an integer score from 0 to 100 and a letter grade.
1. Each finding carries a penalty from its severity:
critical 20 · serious 12 · moderate 6 · minor 2 · info 0
2. Repeat findings from the same rule are damped — the nth finding of a
given rule counts for weight / n:
penalty(rule) = Σ weight / n for n = 1, 2, 3, …
3. Each rule's total is capped:
penalty(rule) = min(penalty(rule), 34)
4. score = round(max(0, 100 − Σ penalty(rule)))
Steps 2 and 3 exist because one misconfigured selector can match forty elements. Without damping, a single rule firing repeatedly would drown out forty genuinely distinct problems and every imperfect page would score zero, which tells you nothing. With it, breadth of failure moves the score more than depth: one critical issue lands at 80 (grade B), four distinct criticals land in the F band.
| Score | Grade | Reading |
|---|---|---|
| 95–100 | A+ | No findings, or informational only |
| 90–94 | A | Minor polish outstanding |
| 85–89 | B+ | One moderate issue |
| 80–84 | B | One serious or critical issue |
| 70–79 | C | Several issues; likely a real barrier for some users |
| 60–69 | D | Multiple serious failures |
| 0–59 | F | Broad failure across rules |
A score is a triage aid, not a compliance certificate. Read the findings.
node bin/scroll-a11y-audit.mjs --url <url> [options]| Flag | Short | Default | Description |
|---|---|---|---|
--url <url> |
-u |
— | Page to audit. Repeat for several pages. Required |
--reporter <name> |
-r |
pretty |
pretty, json, markdown or html |
--out <file> |
-o |
stdout | Write the report to a file. Disables ANSI colour |
--fail-on <severity> |
— | — | Exit 1 when a finding of this severity or worse exists |
--rules <ids> |
— | all | Comma-separated allow-list of rule ids |
--exclude-rules <ids> |
— | none | Comma-separated deny-list of rule ids |
--config <file> |
-c |
— | JSON configuration file |
--viewport <WxH> |
— | 1280x800 |
Viewport size; also resolves vh/vw units in keyframes |
--wait-for <selector> |
— | — | Wait for a CSS selector before auditing |
--timeout <ms> |
— | 30000 |
Navigation and wait timeout |
--chrome-path <path> |
— | auto | Chrome executable. Also read from CHROME_PATH |
--headful |
— | off | Run Chrome with a visible window |
--verbose |
-v |
off | Add remediation text and links to the pretty reporter |
--list-rules |
— | — | Print the rule catalogue and exit |
--help |
-h |
— | Show usage |
--version |
— | — | Print the version |
| Code | Meaning |
|---|---|
0 |
The audit ran and --fail-on was not tripped |
1 |
A finding met or exceeded --fail-on |
2 |
Invalid usage — unknown flag, rule, reporter, severity or viewport |
3 |
Runtime error — Chrome missing, navigation failed, bundle not built |
puppeteer-core never downloads a browser. The CLI resolves one in this order:
--chrome-path- the
CHROME_PATHenvironment variable - the usual install locations for Linux, macOS and Windows — including
/usr/bin/google-chrome,/usr/bin/chromium,/snap/bin/chromium,/opt/google/chrome/chrome, the two macOS.appbundles and both Windows Program Files paths
If none is executable, the CLI exits 3 and lists every path it probed.
# JSON to a file
node bin/scroll-a11y-audit.mjs --url https://example.com -r json -o report.json
# Fail CI on anything serious or worse
node bin/scroll-a11y-audit.mjs --url https://example.com --fail-on serious
# Only the reduced-motion rules, on a mobile viewport
node bin/scroll-a11y-audit.mjs --url https://example.com \
--rules no-reduced-motion-guard,reduced-motion-still-animates \
--viewport 390x844
# Wait for a client-rendered app to settle, watch it happen
node bin/scroll-a11y-audit.mjs --url https://example.com \
--wait-for "[data-hydrated]" --headful --verbosePass a JSON file with --config, or set
window.__SCROLL_A11Y_AUDIT_CONFIG__ before the browser script runs. Both use
the same schema, and both merge over the defaults — you only specify what you
are changing. See scroll-a11y.config.example.json.
{
"rules": { "animation-without-motion-preference-scaling": false },
"severityOverrides": { "scroll-hijacking": "serious" },
"thresholds": { "parallaxViewportPercent": 20, "maxScaleFactor": 1.2 }
}| Key | Type | Default | Meaning |
|---|---|---|---|
rules |
Record<string, boolean> |
{} |
Set a rule id to false to skip it. Unlisted rules run |
severityOverrides |
Record<string, severity> |
{} |
Remap a rule's severity. Must be one of critical, serious, moderate, minor, info — anything else throws |
thresholds |
object | see below | Numeric tuning. A non-numeric value throws |
| Threshold | Default | Unit | Used by | Meaning |
|---|---|---|---|---|
parallaxViewportPercent |
15 |
% of viewport | rule 5, 10 | Maximum translation as a share of the viewport's relevant axis |
maxScaleFactor |
1.5 |
multiplier | rule 5, 10 | Maximum scale reached by any keyframe |
maxRotationDegrees |
15 |
degrees | rule 5 | Maximum absolute rotation in an animation |
loopDurationSeconds |
5 |
seconds | rule 6 | Total run time past which a looping animation needs a pause control. The WCAG 2.2.2 value |
maxOscillationHz |
3 |
hertz | rule 5 | Direction reversals per second before oscillation counts as flashing |
snapContentHeightPx |
3000 |
pixels | rule 9 | Scrollable height above which mandatory snapping is flagged |
invisibleOpacity |
0.05 |
0–1 | rules 2, 4, 5 | Opacity at or below which content counts as invisible |
Validation is strict and fails at startup: an unknown severity or a non-numeric threshold exits 2 with a message naming the offending key, rather than silently auditing with the wrong settings.
Build the script, then paste it into a DevTools console or wire it to a bookmark. Full instructions, including CSP and HTTPS caveats, are in browser/bookmarklet.md.
node scripts/build-browser.mjsThe panel docks to the top-right: a header reading scroll-a11y-audit with a
colour-coded score chip (green at 90+, amber from 70, red below) and a close
button. Under it sits a row of severity pills — critical (3), serious (5),
moderate (2) — that toggle their groups on and off. The list below holds one
card per finding, striped down the left edge in its severity colour, each
showing a severity badge, the rule title, the element's selector path in
monospace, the WCAG criterion, a plain-language explanation, a bolded Fix:
line, the offending CSS in a scrollable code block, and a "Learn more" link. A
footer pins Copy as Markdown and Log JSON in place.
Hovering or tabbing to a card paints a translucent, severity-coloured rectangle over the offending element in the page behind the panel.
The panel is itself accessible — it would be embarrassing otherwise. It is a
role="dialog" in a shadow root, draggable by mouse and by focusing the header
and pressing the arrow keys, every control is keyboard-operable, filter state is
announced through aria-pressed and a live region, and Escape closes it. The
audit runs before the panel is inserted, so the panel never audits itself.
scroll-a11y-audit — http://localhost:8000/demo/good.html
────────────────────────────────────────────────────────────────────────
Score 100/100 Grade A+
Issues none
No accessibility problems detected in the page's scroll-driven or view-transition motion.
That page uses the same visual ideas as the bad one — reveal-on-scroll cards, a
parallax layer, view transitions — built correctly: opaque resting states,
travel measured in tens of pixels, pointer-events: none on transition layers,
proximity snapping, a matchMedia guard around startViewTransition(), a
--motion-scale custom property wired to a three-state control, and a blanket
reduced-motion reset placed last in the sheet.
scroll-a11y-audit — http://localhost:8000/demo/bad.html
────────────────────────────────────────────────────────────────────────
Score 0/100 Grade F
Issues 8 critical · 18 serious · 3 moderate · 1 info
CRITICAL Content is only reachable through an animation
rule content-only-revealed-by-animation
element .reveal-card
css .reveal-card { opacity: 0; animation-fill-mode: both; animation-timeline: view(); … }
wcag WCAG 1.4.13 / 2.3.3 — content must not depend on motion to be perceivable
why The base style resolves to an invisible state (opacity 0) and only
"fade-up" restores it. If the animation never runs — reduced motion,
an unsupported browser, a JavaScript error, or print — the content
stays hidden.
CRITICAL Reduced-motion override does not actually stop the motion
rule reduced-motion-still-animates
element .marquee-banner
css .marquee-banner { animation-duration: 3s; }
wcag WCAG 2.3.3 Animation from Interactions (AAA)
why The reduced-motion override only shortens the animation to 3s.
Shortened motion is still motion — the animation continues to play
for users who asked for none.
SERIOUS Motion exceeds vestibular-safe thresholds
rule motion-exceeds-safe-thresholds
element .parallax-layer
css .parallax-layer { animation: drift linear; animation-timeline: scroll(root block); }
wcag WCAG 2.3.3 Animation from Interactions (AAA), 2.3.1 Three Flashes (A)
why This animation moves content 90% of the viewport (limit 15%), about
720px at the audited viewport size; scales by 2.6× (limit 1.5×).
… 27 more findings
Both reports above are real output from the CLI against the bundled demo pages.
The CLI is designed to gate a build. --fail-on sets the severity that turns a
report into a failure; everything below it is reported but does not block.
# .github/workflows/motion-a11y.yml
name: Motion accessibility
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install
run: npm install
- name: Build the rule bundle
run: node scripts/build-browser.mjs
- name: Serve the site
run: |
npx --yes http-server ./dist -p 8080 &
npx --yes wait-on http://localhost:8080
- name: Audit
env:
# ubuntu-latest ships Chrome at this path
CHROME_PATH: /usr/bin/google-chrome
run: |
node bin/scroll-a11y-audit.mjs \
--url http://localhost:8080/ \
--url http://localhost:8080/pricing \
--reporter markdown \
--out motion-a11y.md \
--fail-on serious
- name: Publish to the job summary
if: always()
run: cat motion-a11y.md >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
if: always()
with:
name: motion-a11y-report
path: motion-a11y.mdTwo practical notes. Start with --fail-on critical on an existing codebase and
tighten to serious once the backlog is clear — a gate that fails on day one
gets disabled on day two. And keep the --reporter markdown output flowing into
the job summary even when the gate passes, so the trend stays visible.
This is static analysis of CSS plus one computed-style snapshot. It is genuinely useful and it is genuinely incomplete. Be honest with yourself about the gap.
What it cannot see:
- Cross-origin stylesheets. The CSSOM throws on
cssRulesfor a sheet served from another origin without CORS headers. Those sheets are skipped and a warning names them in the report. A page whose motion lives entirely in a third-party stylesheet may audit clean while being badly broken. - External scripts. Only inline
<script>bodies are read. AstartViewTransition()call or apreventDefault()wheel handler in a bundled file will be missed, so rules 8 and 9 under-report on most real applications. - Animation driven from JavaScript. The Web Animations API, GSAP, Framer Motion and friends never touch CSS, so nothing here sees them.
- One moment in time. The audit runs at the initial scroll position. Content
revealed by later scrolling, route changes or interaction is not visited. Use
--wait-for, and audit several URLs. - Whether the motion is actually a problem. No tool can tell you that a parallax effect is essential to the design, or that a hidden card is decorative.
Where it over-reports, and what to do:
content-only-revealed-by-animationflags anyopacity: 0base rule with an animated reveal. If a reduced-motion block restores opacity by a route the selector matcher does not connect, this is a false positive. Verify by emulating reduced motion in DevTools and reloading.focus-order-breakagemeasures the live DOM at one scroll position, so content legitimately below the fold can look "far offscreen". Findings whose rect is merely below the viewport deserve a manual look before you act.motion-exceeds-safe-thresholdsresolvesvh/vw/%units against the audited viewport. A different--viewportgives different numbers. TuneparallaxViewportPercentto your design language rather than suppressing the rule.no-pause-mechanismfinds pause controls by matching label text against a word list (pause,stop,motion toggle, and similar) and bydata-motion-toggle. A control in another language, or one labelled only with an icon, will not be recognised. Adddata-motion-toggleto it.animation-blocks-interactionflags transforms over 100 px containing focusable elements. On a carousel that is expected behaviour, not a bug.no-reduced-motion-guardmatches guard selectors to animated selectors with a deliberately cheap heuristic. Complex selector architectures will produce misses in both directions; a blanket*reset is both better practice and reliably detected.
Above all: a passing audit is not a passing page. Nothing here replaces testing with reduced motion enabled at the OS level, tabbing through the page with the animations running, and asking someone with a vestibular disorder what they experience. Use the score to find work, not to declare it finished.
npm install # one dependency: puppeteer-core
npm test # node --test, no framework, no Chrome needed
node scripts/build-browser.mjs # rebuild the IIFE
node --check browser/scroll-a11y-audit.js # verify the bundle parsesLayout:
src/
constants.js severities, weights, grade bands
config.js defaults, merging, validation, rule filtering
css-model.js CSSOM → plain objects; flattening; specificity
animation-model.js timelines, durations, keyframe measurement
engine.js orchestration and scoring
rules/ ten rules plus the registry
reporters/ pretty, json, markdown, html, ANSI helpers
bin/ the CLI
browser/ overlay source, built bundle, bookmarklet guide
scripts/ the hand-rolled browser build
demo/ bad.html and good.html
tests/ unit and integration tests, plus the DOM stub
The test suite covers each of the ten rules in both directions, the scoring formula and its damping, config merging and validation, the CSS and animation parsers, the rule registry's metadata, and all four reporters including HTML escaping. It runs in well under a second.
Bug reports, and especially false-positive reports with a reproducing test
case, are the most valuable contributions. See
CONTRIBUTING.md for the ground rules — chiefly that
puppeteer-core stays the only dependency, that rules never touch globals, and
that every rule ships with tests in both directions.
Background on the standards and techniques these rules encode:
- Accessibility and inclusive motion standards — the overview these rules are derived from
- WCAG 2.2 animation compliance — which success criteria motion actually engages
- Pause, Stop, Hide (WCAG 2.2.2) for scroll animations — the five-second rule, applied to scroll-driven motion
- The WCAG 2.3.3 animation-from-interactions checklist — a review checklist worth running alongside this tool
- Implementing prefers-reduced-motion — the preference end to end
- How to respect prefers-reduced-motion in CSS — why shortening a duration is not honouring the preference
- Detecting prefers-reduced-motion with matchMedia — the script-side guard rule 8 looks for
- Vestibular-safe motion design — what large-surface motion does to people
- Designing parallax safe for vestibular disorders — where rule 5's default thresholds come from
- A motion-intensity toggle with CSS custom properties — the pattern rule 10 asks for
- Testing prefers-reduced-motion across OS and browsers — the manual testing this tool cannot replace
MIT © 2026 css-scroll-driven.
Maintained alongside css-scroll-driven.com, a reference site for CSS scroll-driven animations.