Load a page in headless Chrome, drive a realistic scroll with real compositor input, and report exactly how well its scroll animations perform — then fail the build when they regress.
It answers the questions a Lighthouse score cannot: did any frames get dropped while scrolling, which animation is dragging the main thread into every frame, and did this pull request make scrolling worse than it was yesterday.
Frames
────────────────────
Total frames 187
Dropped / partial 19 / 28
Dropped frame % 25.13%
p50 / p95 / p99 6.86 / 20.18 / 106.86 ms
Worst frame 111.95 ms
Longest stall 100 ms
- Real input, not
scrollTo. Scrolling is driven with CDP wheel events, so the compositor handles them exactly as it would a physical mouse wheel.window.scrollToruns on the main thread and hides the jank you are trying to find. - Pure analysers. Every measurement is a pure function over trace events, so the analysis layer is unit-tested against committed fixtures with no browser involved.
- One dependency. Only
puppeteer-core. Nochalk, nocommander, no bundled Chrome download. - Built for CI. A budget file plus a non-zero exit code is the whole integration story.
- Requirements
- Installation
- Quick start
- What it measures, and why
- How it works
- CLI reference
- The budget file
- Every check, and how to fix it
- Sample report
- Reporters
- Library API
- Continuous integration
- Accuracy caveats
- Troubleshooting
- Demo pages
- Development
- Further reading
- Contributing
- License
- Node.js 20 or newer.
- Your own Chrome or Chromium installation. This project depends on
puppeteer-core, which drives a browser but never downloads one. That keepsnpm installfast and lets you profile against the exact browser build you care about.
The binary is located in this order:
- the
--chrome-pathflag, - the
CHROME_PATHenvironment variable, - the usual install locations for your platform —
/usr/bin/google-chrome,/opt/google/chrome/chrome,/snap/bin/chromiumand friends on Linux;/Applications/Google Chrome.app/…on macOS;C:\Program Files\Google\Chrome\…on Windows.
If none of those match, the profiler exits with code 2 and tells you how to point it at a browser.
This package is not published to npm. Clone it and install its single dependency:
git clone https://github.com/css-scroll-driven/scroll-jank-profiler.git
cd scroll-jank-profiler
npm installRun it from the repository:
node bin/scroll-jank-profiler.mjs --url https://example.comIf you would rather have it on your PATH, link it locally — still no registry involved:
npm link
scroll-jank-profiler --url https://example.comProfile a page with the built-in default budget:
node bin/scroll-jank-profiler.mjs --url https://example.comProfile the bundled demo pages and watch the difference. In one terminal:
npm run demo:serve # serves ./demo on http://localhost:8000In another:
node bin/scroll-jank-profiler.mjs --url http://localhost:8000/janky.html
node bin/scroll-jank-profiler.mjs --url http://localhost:8000/optimised.htmlWrite a PR-ready Markdown report against your own budget, taking the median of three runs on a throttled CPU:
node bin/scroll-jank-profiler.mjs \
--url https://example.com \
--budget scroll-budget.json \
--runs 3 \
--cpu-throttle 4 \
--reporter markdown \
--out report.mdFrame health. Total frames, dropped frames, partially presented frames, the dropped-frame percentage, p50/p95/p99 frame durations, the worst single frame and the longest stall. Averages hide jank — a page can average 8ms per frame and still hitch badly, which is why the percentiles and the worst-case numbers carry most of the weight here.
Frames come from Chrome's PipelineReporter trace events, which record what actually happened to
each frame in the compositor pipeline. Frames marked STATE_NO_UPDATE_DESIRED are excluded from
every statistic: the compositor had nothing new to draw, which is not a dropped frame. Counting
them would make an idle page look catastrophic.
Long tasks. Any main-thread task over 50ms, attributed to the longest script event inside it, so you get a URL and a function name rather than an anonymous block of time. A long task cannot be interrupted, so it delays the next frame and blocks input for its whole duration.
Main thread versus compositor. How much work landed on each thread, broken down by rendering stage (script, style, layout, paint, composite). A well-built scroll animation runs almost entirely on the compositor and leaves the main thread near-idle.
Forced synchronous layouts. Layout events nested inside script events — the signature of code that writes to the DOM and then immediately reads a geometry property, forcing the browser to lay out mid-task instead of at its normal point in the frame.
Style recalculation. How many recalculations ran during the scroll, what they cost, and how many elements they touched.
Non-compositor-friendly animations. The profiler reads the page's stylesheets and
document.getAnimations(), extracts the property list of every animation, and flags any that
touches a layout- or paint-triggering property. Each finding names the offending property, the
keyframes name, the element, and a compositor-safe alternative.
Scroll-driven specifics. Which animations are running on a scroll() or view() timeline,
whether their properties allow compositor acceleration, and how many elements carry will-change.
Layers and memory. Composited layer count before and after the scroll, will-change
promotions, JS heap delta and DOM node delta. A warning fires on layer explosion, because past a
certain point layer management costs more than the compositing it saves.
flowchart TD
A["launch.js<br/>find & start your Chrome<br/>(puppeteer-core, no download)"] --> B["profile.js<br/>viewport, CPU & network emulation<br/>navigate, wait for --wait-for"]
B --> C["trace.js<br/>CDP Tracing.start<br/>+ Animation & LayerTree domains"]
C --> D["scroll.js<br/>scripted wheel events via<br/>Input.dispatchMouseEvent"]
D --> E["trace.js<br/>Tracing.end, collect events"]
E --> F["collect.js<br/>read stylesheets + getAnimations()<br/>inside the page"]
F --> G["analyze/*.js<br/>PURE functions over trace events<br/>frames · long tasks · threads<br/>reflows · animations · layers"]
G --> H["budget.js<br/>compare against thresholds"]
H --> I["reporters/*.js<br/>pretty · json · markdown · html"]
I --> J{"budget met?"}
J -->|yes| K["exit 0"]
J -->|no| L["exit 1"]
As plain text: launch your Chrome → navigate and settle → start a CDP trace → dispatch wheel events on a timed easing curve → stop the trace → collect the page's animations and layer hints → run the pure analysers over the trace-event array → compare against the budget → render a report → exit 0 or 1.
The important boundary is between recording and analysis. Everything to the left of analyze/
needs a browser; everything from analyze/ rightwards is pure data transformation. That is what
makes the measurements testable, and what lets you re-analyse a saved trace without re-running it.
window.scrollTo() and scrollBy() are main-thread APIs. A page whose scroll is smooth under
scrollTo may stutter badly under a real wheel, because the two take different paths through the
browser. Input.dispatchMouseEvent with type: mouseWheel injects input at the same layer as a
physical device, so the compositor's scroll handling, input latency and animation ticking all
behave as they would for a user.
The recorder enables devtools.timeline, disabled-by-default-devtools.timeline,
disabled-by-default-devtools.timeline.frame, disabled-by-default-devtools.timeline.stack,
blink.user_timing, latencyInfo, rail and v8.execute — the minimum needed to reconstruct
frame timing, long tasks, layout and style cost, and script attribution.
node bin/scroll-jank-profiler.mjs --url <url> [options]| Flag | Default | Description |
|---|---|---|
-u, --url <url> |
(required) | Page to profile. Repeat the flag to profile several pages in one invocation. |
-b, --budget <file> |
built-in defaults | Path to a budget JSON file. See the budget file. |
-r, --reporter <name> |
pretty |
One of pretty, json, markdown, html. |
-o, --out <path> |
stdout | Write the report to a file. With several URLs, or a path with no extension, <path> is treated as a directory and one file is written per URL. |
--chrome-path <path> |
auto-detected | Chrome/Chromium binary. Falls back to CHROME_PATH, then to the usual install locations. |
--headful |
false |
Run with a visible window. Slower, but closer to what users experience. |
--viewport <WxH> |
1280x800 |
Viewport size. |
--device-scale <n> |
1 |
Device pixel ratio. Raise it to 2 or 3 to expose raster cost on high-DPI screens. |
--cpu-throttle <n> |
1 |
CPU slowdown multiplier. 4 approximates a mid-range laptop; 6 a mid-range phone. |
--network <preset> |
none |
none, 4g, fast-3g or slow-3g. Affects loading, not the scroll itself. |
--runs <n> |
1 |
Profile the page n times and report the median run. See accuracy caveats. |
--scroll-distance <px> |
0 |
Pixels to scroll. 0 means the entire scrollable height. |
--scroll-duration <ms> |
4000 |
How long the scroll should take. Shorter scrolls are more demanding. |
--scroll-easing <name> |
easeInOut |
linear, easeIn, easeOut or easeInOut. |
--scroll-direction <d> |
down |
down, or down-up to scroll down and back up again. |
--scroll-step <px> |
100 |
Maximum pixels per wheel event. Smaller steps mean more, finer events. |
--wait-for <selector> |
— | Wait for this CSS selector before scrolling. Useful for client-rendered pages. |
--timeout <ms> |
30000 |
Navigation and selector timeout. |
-v, --verbose |
false |
Log progress to stderr. Report output stays on stdout. |
--version |
— | Print the version and exit. |
-h, --help |
— | Print the help text and exit. |
| Code | Meaning |
|---|---|
0 |
Every profiled URL met its budget. |
1 |
At least one budget check was exceeded. |
2 |
The profiler could not run: bad flags, no Chrome found, navigation failure, unreadable budget file. |
Note the distinction — 1 means your page is slow, 2 means the tool did not measure
anything. CI should treat them differently.
A budget is a JSON object with any subset of the keys below. Every threshold is a ceiling: the
check passes when the measured value is less than or equal to it. A value of null disables that
check entirely. Anything you omit falls back to the default. Unknown keys are an error, so a typo
fails loudly instead of silently disabling a check.
Copy scroll-budget.example.json to get started.
| Key | Type | Default | Measures |
|---|---|---|---|
droppedFramePercent |
number | 5 |
Dropped plus partially presented frames, as a percentage of all frames. |
p95FrameMs |
number | 16.7 |
95th-percentile frame duration, in milliseconds. |
p99FrameMs |
number | 33 |
99th-percentile frame duration, in milliseconds. |
worstFrameMs |
number | 50 |
The single slowest frame, in milliseconds. |
longestStallMs |
number | 100 |
Longest period with no screen update while an update was wanted. |
longTasks |
number | 0 |
Count of main-thread tasks longer than 50ms. |
totalBlockingMs |
number | 50 |
Sum of time beyond 50ms across all long tasks. |
maxLayers |
number | 60 |
Composited layer count after the scroll. |
forcedReflows |
number | 0 |
Count of forced synchronous layouts. |
nonCompositorAnimations |
number | 0 |
Animations touching layout- or paint-triggering properties. |
styleRecalcMs |
number | 100 |
Total style recalculation time, in milliseconds. |
mainThreadMs |
number | 1500 |
Total main-thread busy time during the scroll, in milliseconds. |
Example — strict on animation correctness, lenient on raw frame timing because the build runs on a noisy shared CI runner:
{
"nonCompositorAnimations": 0,
"forcedReflows": 0,
"longTasks": 0,
"droppedFramePercent": 15,
"p95FrameMs": null,
"p99FrameMs": null
}This is usually the right shape for a first budget. The structural checks — non-compositor animations, forced reflows, long tasks — are nearly deterministic and catch real regressions. The frame-timing checks are the noisy ones; tighten them once you know your runner's baseline.
| Check | What it means | How to fix it | Learn more |
|---|---|---|---|
| Dropped frames | Frames that never reached the screen, or arrived with only part of their update. Above a few percent, users see stutter. | Find what the main thread was doing during the drops — usually a long task or a layout-animating property. | Profiling scroll animations in DevTools |
| p95 / p99 frame duration | The slow tail of frame times. One frame in twenty (or a hundred) took at least this long. | Reduce per-frame main-thread work: fewer style recalculations, no layout in the animation path. | Diagnosing main-thread jank |
| Worst frame | The single slowest frame in the scroll. Almost always a long task landing mid-scroll. | Break the task up, move it to a worker, or defer it until the scroll ends. | Diagnosing main-thread jank |
| Longest stall | The longest run where the screen did not update although an update was wanted. | Same causes as the worst frame, but sustained — look for a run of expensive frames rather than one spike. | The rendering pipeline for scroll animations |
| Long tasks (>50ms) | Main-thread tasks long enough to block input and delay the next frame. The report names the script and function. | Split the work, use scheduler.yield() or requestIdleCallback, or move it off the main thread. |
Diagnosing main-thread jank |
| Total blocking time | Time beyond 50ms summed across all long tasks — the same idea as TBT, but measured during a scroll. | Reduce the number and length of long tasks. | Core Web Vitals for scroll and view transitions |
| Composited layers | Composited layer count. Each layer costs GPU memory and compositing work. | Remove blanket will-change declarations; promote only the few elements that actually animate. |
Compositor-safe properties and will-change |
| Forced synchronous layouts | Script wrote to the DOM and then read a geometry property, forcing layout mid-task. | Batch all reads before all writes, or avoid reading geometry in a scroll handler entirely. | Diagnosing main-thread jank |
| Non-compositor animations | An animation touches a property the compositor cannot handle, so every frame goes back through the main thread. | Rewrite in terms of transform and opacity. The report suggests a specific replacement per property. |
transform vs top/left in scroll animations |
| Style recalculation | Time spent recomputing styles during the scroll. High values usually mean broad selectors or class toggling on scroll. | Narrow the selectors, reduce the number of affected elements, prefer CSS-driven animation over class flipping. | The rendering pipeline for scroll animations |
| Main-thread busy time | Total main-thread work during the scroll. A compositor-only scroll keeps this near zero. | Move animation work to the compositor; remove scroll listeners that do layout or paint work. | Animation performance, profiling and optimization |
Animations are flagged by the properties their keyframes touch:
| Class | Examples | Cost per frame |
|---|---|---|
| Safe | transform, translate, rotate, scale, opacity |
Compositor only. The main thread is not involved. |
| Layout | width, height, top, left, margin, padding, font-size, flex-basis |
Layout, then paint, then composite — the full pipeline, every frame. |
| Paint | box-shadow, background-position, background-color, border-radius, color, clip-path |
Paint, then composite. |
| Caveat | filter, backdrop-filter |
Compositable in principle, but only when the element already has its own layer, and expensive to rasterise. |
The pretty reporter against the bundled demo/janky.html, abridged:
scroll-jank-profiler
http://localhost:8000/janky.html
viewport 1280x800
Frames
────────────────────
Total frames 187
Dropped / partial 19 / 28
Dropped frame % 25.13%
p50 / p95 / p99 6.86 / 20.18 / 106.86 ms
Worst frame 111.95 ms
Longest stall 100 ms
Over budget 78 frames > 16.67 ms
Main thread
────────────────────
Main-thread busy 1176.46 ms
Compositor busy 250.27 ms
By stage script 1033.8ms style 58.64ms layout 460.19ms paint 33.82ms composite 30.16ms
Style recalcs 5849 (58.64 ms)
Forced reflows 5814 (464.41 ms)
Long tasks 5 (200.44 ms blocking)
Longest tasks
────────────────────
90.19 ms TimerFire
90.14 ms TimerFire
90.08 ms TimerFire
Animations
────────────────────
Total 3
Scroll-driven 3
Compositor-safe 0
Layers 4 (0 during scroll)
will-change 120
JS heap 1.32 MB (+0.07 MB)
Non-compositor animations
─────────────────────────────
layout width in grow-box → div.grower
Triggers layout, paint and composite on every frame.
Fix: Animate `transform: scale()` instead, and correct child scaling if needed.
layout top in slide-top → div.slider
Triggers layout, paint and composite on every frame.
Fix: Animate `transform: translate()` instead.
paint box-shadow in grow-box → div.grower
Triggers paint on every frame.
Fix: Cross-fade the `opacity` of a pseudo-element that carries the shadow.
Budget
────────────────────
FAIL Dropped frames 25.13% budget 5%
FAIL p95 frame duration 20.18ms budget 16.7ms
FAIL p99 frame duration 106.86ms budget 33ms
FAIL Worst frame 111.95ms budget 50ms
PASS Longest stall 100ms budget 100ms
FAIL Long tasks (>50ms) 5 budget 0
FAIL Total blocking time 200.44ms budget 50ms
PASS Composited layers 4 budget 60
FAIL Forced synchronous layouts 5814 budget 0
FAIL Non-compositor animations 2 budget 0
PASS Style recalculation 58.64ms budget 100ms
PASS Main-thread busy time 1176.46ms budget 1500ms
What to fix
────────────────────
• Dropped frames: Frames that never reached the screen. Above a few percent, scrolling visibly stutters.
Learn more: …
✖ 8 budget checks failed.
The same page rewritten as demo/optimised.html reports zero forced reflows, zero long tasks,
every animation compositor-safe, and roughly thirty times less main-thread work.
| Reporter | Output | Use it for |
|---|---|---|
pretty |
Colourised terminal text | Local runs. Colour is disabled automatically when stdout is not a TTY, when NO_COLOR is set, or with FORCE_COLOR=0. |
json |
Machine-readable JSON | Storing results, diffing runs, feeding dashboards. Per-frame detail is omitted by default to keep the payload small. |
markdown |
A PR-ready report | Posting as a pull-request comment. Every check links to an explanation. |
html |
A single self-contained file | CI artifacts. Inline CSS, no external requests, light and dark themes, and a frame-duration bar chart drawn with plain div elements. |
import {
profile,
analyze,
loadBudget,
evaluateBudget,
reporters,
} from './src/index.js';
const analysis = await profile({
url: 'https://example.com',
runs: 3,
cpuThrottle: 4,
scrollDuration: 3000,
});
const budget = evaluateBudget(analysis, await loadBudget('./scroll-budget.json'));
console.log(reporters.markdown(analysis, budget));
process.exitCode = budget.passed ? 0 : 1;| Export | Signature | Description |
|---|---|---|
profile |
(options) => Promise<Analysis> |
Launch, navigate, scroll, trace and analyse. The only export that needs a browser. With runs > 1, returns the median run. |
analyze |
(input, options?) => Analysis |
Pure. Runs every analyser over { events, animations, metricsBefore, metricsAfter, meta }. |
medianRun |
(analyses) => Analysis |
Pure. Picks the median run by dropped-frame percentage. |
loadBudget |
(path?) => Promise<Budget> |
Read and validate a budget file, merged over the defaults. |
evaluateBudget |
(analysis, budget?) => BudgetReport |
Pure. Produces { passed, failures, results }. |
describeChecks |
() => Check[] |
The static catalogue of checks, for generating documentation. |
reporters |
{ pretty, json, markdown, html } |
Each is (analysis, budgetReport, options?) => string. |
analyzeFrames, analyzeLongTasks, analyzeThreads, analyzeForcedReflows, analyzeStyleRecalcs, analyzeAnimations, analyzeLayers |
(events, options?) => Stats |
Individual pure analysers, if you only want one of them. |
classifyProperty |
(property) => PropertyVerdict |
Classify one CSS property as safe, layout, paint, caveat or unknown. |
findChrome |
(path?) => string |
Resolve a Chrome binary, or throw with guidance. |
performScroll |
(page, options?) => Promise<ScrollResult> |
Drive the scripted wheel scroll on a Puppeteer page you already have. |
recordTrace |
(cdpSession, run) => Promise<TraceRecording> |
Record a CDP trace around an arbitrary async operation. |
Because analyze is pure, you can profile once and re-analyse forever:
import { readFile } from 'node:fs/promises';
import { analyze, evaluateBudget, reporters } from './src/index.js';
const events = JSON.parse(await readFile('./saved-trace.json', 'utf8'));
const analysis = analyze({ events });
console.log(reporters.pretty(analysis, evaluateBudget(analysis)));The CLI exits 1 on a budget violation, so no wrapper script is needed.
name: Scroll performance
on: [pull_request]
jobs:
scroll-jank:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install browser
run: |
sudo apt-get update
sudo apt-get install -y google-chrome-stable
- name: Install and build the site
run: |
npm ci
npm run build
- name: Serve the built site
run: npx --yes serve -l 8000 ./dist &
- name: Profile scroll performance
env:
CHROME_PATH: /usr/bin/google-chrome-stable
run: |
node bin/scroll-jank-profiler.mjs \
--url http://localhost:8000/ \
--url http://localhost:8000/features \
--budget scroll-budget.json \
--runs 3 \
--cpu-throttle 4 \
--reporter html \
--out ./scroll-reports
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: scroll-reports
path: ./scroll-reportsTwo tips that make this pleasant rather than painful:
- Start with a lenient budget. Run it on
maina few times, note the spread, and set the frame-timing thresholds above the worst observed value. Tighten later. - Use
--runs 3on shared runners. GitHub-hosted runners are noisy neighbours; the median of three runs is dramatically more stable than a single one.
Read this section before you trust a number.
Headless is not headful. Headless Chrome has no real display and no real vsync source. Frame
pacing is close to, but not identical to, a headful browser on a physical screen. Structural
findings — forced reflows, non-compositor animations, long tasks, layer counts — transfer exactly.
Absolute frame timings do not. Use --headful when frame timing is the thing you care about, and
be aware that CI runners typically cannot.
CPU throttling is a blunt instrument. --cpu-throttle slows down script execution but does not
model a phone's memory bandwidth, GPU or thermal behaviour. It is excellent for making main-thread
regressions visible, and it is not a phone.
Variance is real. Run-to-run swings of several percentage points on dropped frames are normal,
especially on shared CI hardware. This is why --runs exists: it profiles n times and reports
the median run, whole and self-consistent, rather than averaging unrelated measurements
together. Three runs is a good default; five if your runner is noisy.
Not every animation can be seen. Animations in cross-origin stylesheets cannot be read, because
their cssRules are inaccessible. Animations created after the scroll finishes are not counted.
Animations inside cross-origin iframes are not inspected.
Compositor acceleration is inferred, not reported. Chrome does not expose a per-animation
"is composited" flag over CDP, so the profiler infers it from the property list. An animation using
only transform and opacity is reported as compositor-safe. In rare cases Chrome declines to
composite such an animation anyway — because of a will-change conflict, an unsupported stacking
context or a very large layer. Cross-check in DevTools when the result surprises you.
Layer counts depend on the driver. The composited layer count comes from the CDP LayerTree
domain, which reports what the compositor built for the current view. Headless and headful can
differ, and a page that lazily promotes layers will report differently depending on how far the
scroll travelled.
The first run of a page is not like the rest. Caches are cold and JIT is unwarmed. With
--runs, later runs share a warm browser, so the median leans toward the warm case — usually what
you want for measuring animation quality, not first-load performance.
Could not find Chrome or Chromium — Install Chrome, or pass --chrome-path /path/to/chrome,
or set CHROME_PATH. This project deliberately does not download a browser.
Chrome path is not an executable file — The path exists but is not executable by the current
user. On macOS point at the binary inside the bundle
(/Applications/Google Chrome.app/Contents/MacOS/Google Chrome), not the .app directory.
No frames were captured / totalFrames: 0 — Usually the page did not scroll. Check that it is
actually taller than the viewport, that --wait-for waits for the content that creates the height,
and that scrolling is not intercepted by a custom scroll library on a nested container.
Every run reports a huge number of forced reflows — That is normally correct and normally a
scroll listener. The report names the script; look for a geometry read such as offsetHeight,
getBoundingClientRect() or scrollTop immediately after a style write.
Chrome fails to start in a container — Containers usually need --no-sandbox, which this tool
does not add for you. Run the container as a non-root user with the right seccomp profile, or pass
a wrapper script via --chrome-path.
The budget fails in CI but passes locally — Expected. CI runners are slower and noisier. Set a
separate, more lenient budget file for CI, keep the structural checks strict, and use --runs 3.
The report shows no animations at all — The stylesheet may be cross-origin. Serve the CSS from
the same origin as the page, or add crossorigin and appropriate CORS headers.
demo/ holds two versions of the same page:
demo/janky.html— animateswidth,height,margin,top,left,box-shadowandbackground-positionfrom scroll-driven keyframes; adds a non-passive scroll listener that forces synchronous layout on 120 elements; promotes every tile withwill-change; and fires a 90ms task on a timer.demo/optimised.html— the same four visual effects, built entirely fromtransformandopacity, with no scroll listener and no blanket layer promotion.
Profiling both is the fastest way to see what each check is actually detecting.
npm install # installs puppeteer-core, the only dependency
npm test # runs the unit tests with node --test
npm run demo:serveThe test suite covers the analysers, the budget evaluator and the reporters. It runs entirely
without a browser, against small hand-authored trace fixtures in tests/fixtures/.
bin/scroll-jank-profiler.mjs CLI: flag parsing, orchestration, exit codes
src/launch.js Chrome discovery and launch
src/scroll.js scripted wheel-event scrolling
src/trace.js CDP trace recording
src/collect.js in-page animation and layer collection
src/analyze/frames.js frame stats from PipelineReporter events
src/analyze/longTasks.js long tasks and script attribution
src/analyze/mainThread.js thread breakdown, forced reflows, style recalcs
src/analyze/animations.js non-compositor animation findings
src/analyze/properties.js CSS property cost classification
src/analyze/layers.js layer and memory deltas
src/budget.js budget loading, validation and evaluation
src/reporters/ pretty, json, markdown, html
src/index.js public library API
Adding a check means touching three places: an analyser in src/analyze/, an entry in the CHECKS
array in src/budget.js, and a row in the checks table above. The reporters pick
it up automatically.
The measurements here are only useful if you know what to do with them. These articles cover the underlying model in depth:
- Animation performance, profiling and optimization — the overview: what costs what, and where to look first.
- Profiling scroll animations in DevTools — doing by hand what this tool automates, which is how you confirm a finding.
- Diagnosing main-thread jank in the Performance panel — reading a flame chart to find the cause of a dropped frame.
- Compositor-safe properties and
will-change— which properties are free to animate, and when promotion helps rather than hurts. transformvstop/leftin scroll animations — the single most common fix for a non-compositor animation finding.- Core Web Vitals for scroll and view transitions — how scroll performance connects to the metrics you are already reporting on.
- The rendering pipeline for scroll animations — style, layout, paint, composite, and why the order matters.
- CSS scroll-driven animations vs GSAP ScrollTrigger — where each approach lands on the measurements this tool reports.
Bug reports, fixtures from real traces and new checks are all welcome. See CONTRIBUTING.md for the workflow and the house rules.
MIT © 2026 css-scroll-driven.
Built and maintained alongside css-scroll-driven.com, a reference for CSS scroll-driven animations.