Skip to content

css-scroll-driven/scroll-jank-profiler

Repository files navigation

scroll-jank-profiler

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.scrollTo runs 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. No chalk, no commander, no bundled Chrome download.
  • Built for CI. A budget file plus a non-zero exit code is the whole integration story.

Contents


Requirements

  • 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 keeps npm install fast and lets you profile against the exact browser build you care about.

The binary is located in this order:

  1. the --chrome-path flag,
  2. the CHROME_PATH environment variable,
  3. the usual install locations for your platform — /usr/bin/google-chrome, /opt/google/chrome/chrome, /snap/bin/chromium and 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.

Installation

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 install

Run it from the repository:

node bin/scroll-jank-profiler.mjs --url https://example.com

If you would rather have it on your PATH, link it locally — still no registry involved:

npm link
scroll-jank-profiler --url https://example.com

Quick start

Profile a page with the built-in default budget:

node bin/scroll-jank-profiler.mjs --url https://example.com

Profile the bundled demo pages and watch the difference. In one terminal:

npm run demo:serve          # serves ./demo on http://localhost:8000

In another:

node bin/scroll-jank-profiler.mjs --url http://localhost:8000/janky.html
node bin/scroll-jank-profiler.mjs --url http://localhost:8000/optimised.html

Write 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.md

What it measures, and why

Frame 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.

How it works

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"]
Loading

As plain text: launch your Chrome → navigate and settle → start a CDP tracedispatch wheel events on a timed easing curve → stop the tracecollect the page's animations and layer hints → run the pure analysers over the trace-event array → compare against the budgetrender a reportexit 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.

Why wheel events

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.

Trace categories

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.

CLI reference

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.

Exit codes

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.

The budget file

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.

Every check, and how to fix it

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

Property classification

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.

Sample report

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.

Reporters

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.

Library API

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)));

Continuous integration

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-reports

Two tips that make this pleasant rather than painful:

  • Start with a lenient budget. Run it on main a few times, note the spread, and set the frame-timing thresholds above the worst observed value. Tighten later.
  • Use --runs 3 on shared runners. GitHub-hosted runners are noisy neighbours; the median of three runs is dramatically more stable than a single one.

Accuracy caveats

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.

Troubleshooting

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 pages

demo/ holds two versions of the same page:

  • demo/janky.html — animates width, height, margin, top, left, box-shadow and background-position from scroll-driven keyframes; adds a non-passive scroll listener that forces synchronous layout on 120 elements; promotes every tile with will-change; and fires a 90ms task on a timer.
  • demo/optimised.html — the same four visual effects, built entirely from transform and opacity, with no scroll listener and no blanket layer promotion.

Profiling both is the fastest way to see what each check is actually detecting.

Development

npm install     # installs puppeteer-core, the only dependency
npm test        # runs the unit tests with node --test
npm run demo:serve

The 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.

Further reading

The measurements here are only useful if you know what to do with them. These articles cover the underlying model in depth:

Contributing

Bug reports, fixtures from real traces and new checks are all welcome. See CONTRIBUTING.md for the workflow and the house rules.

License

MIT © 2026 css-scroll-driven.

Built and maintained alongside css-scroll-driven.com, a reference for CSS scroll-driven animations.

About

CLI and library that scripts a real scroll in headless Chrome and reports dropped frames, long tasks, forced reflows and non-compositor animations against a performance budget.

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors