Keep the effects. Lose the stutter.
At 60 Hz a frame lasts 16.7 ms, and every effect on your page spends part of it. framebudget measures what a device can really do and turns on only the effects that fit. Slow phones stay smooth. Fast phones keep everything.
npm i framebudget
- Frame rate
- 60 fps
- Late
- 0 of 0
Each bar is one frame: the page's own work, then every effect framebudget allowed. The outline is the same frame with every effect on.
Run this page as
import { budget } from "framebudget";
if (budget.allows("parallax")) startParallax();
budget.on("change", (snapshot) => {
setEffects(snapshot.effects);
});
Every effect spends the same 16.7 ms
Parallax, blur, canvas, transitions and sound each take a slice of every frame. A laptop has time to spare. A phone from 2019 runs out, so frames arrive late and the page stutters. Most sites ship one set of effects to everyone. framebudget picks per device.
Score 100, this device
Every effect on
0.0 ms
framebudget's choice
0.0 ms
This device runs every effect inside the frame, so framebudget keeps them all. Pick a slower device in the hero or the simulator to see the difference.
What happens, and when
framebudget acts at six moments in the life of a page. Every panel below is reading this page right now.
-
Before the first paint
Decide before anything moves
An inline boot script in
<head>runs a 1.6 ms benchmark and writes the decision on<html>before your CSS loads. An animation never starts only to freeze, and CSS can gate effects without any JavaScript.On this page
<html data-framebudget="..."> -
At the load event
Measure again, warm
Once the CPU has ramped up, a longer benchmark runs in five short tasks, never one long one. Four kernels give a score where 100 is the reference phone, using the median of each and the geometric mean of all four, so no single kernel dominates. The score is cached for the next page.
This device, warm
Kernel Rate Ratio Float math ... ... Typed arrays ... ... Allocation ... ... Canvas paths ... ... Score ...Waiting for the load event
-
For every effect
A threshold per effect, with hysteresis
Each effect has its own threshold instead of one global tier. An effect that is on stays on until the score falls 10% under its threshold, and an effect that is off needs 10% over it. A device that sits near a line does not flicker between visits.
Thresholds against the score of this device
- Hover states10
- Counting numbers12
- Interface sounds20
- Staggered reveals24
- Frame chart, 1x32
- Deadline glow38
- Text reveals46
- Morphing controls49
- Page transitions62
- Spring presses66
- Magnetic buttons85
- Parallax90
- Cursor spotlight112
- Backdrop blur135
- Frame chart, full res180
-
While the page runs
A governor watches real frames
It judges windows of 30 frames. Three windows in a row under 45 fps step one effect down, the most expensive first, then it waits two seconds. A phone that heats up loses its blur before it loses its frames.
-
On the next visit
It remembers what stuttered
An effect the governor stepped down stays off on this device next time. After five clean visits it gets another try, and if it stutters again the wait doubles. All of it lives in one localStorage entry that never leaves the device.
Learned on this device
Nothing yet: no effect has stuttered on this device.
-
Only if the site opts in
Telemetry is off by default
There is no default endpoint. A site can share anonymous, sampled reports to help calibrate the numbers, with one beacon when the page hides. Nothing is sent under Global Privacy Control or Save-Data, while a tier is forced, or while a device is simulated. This site opts in for its own visitors: see what it keeps.
A report contains
- Calibration version and rounded scores
- Kernel rates and clock resolution
- Cores, memory and CPU pressure
- Tier, allowed and stepped-down effects
- Median fps per source
It never contains
- Identifiers or cookies
- The URL or the user agent
- Timestamps
Try a slower device
Pick a device and this page changes for real: framebudget gets a different score, and every effect on the page turns on or off with it. Run the load test to burn that device's frame time on this thread, then heat it up and watch the governor step effects down.
Device
100 is the reference phone. A device twice as fast scores 200.
You are on your real device. Pick another one above.
- Score
- 100
- Tier
- Full
- Effects on, of 15
- 0
- Frame rate, fps
- 60
One frame on my device
0.0 ms
Effect decisions
| Effect | Threshold | Cost | Decision |
|---|---|---|---|
| Hover states | 10 | 1 | Checking |
| Counting numbers | 12 | 1 | Checking |
| Interface sounds | 20 | 1 | Checking |
| Staggered reveals | 24 | 2 | Checking |
| Frame chart, 1x | 32 | 3 | Checking |
| Deadline glow | 38 | 2 | Checking |
| Text reveals | 46 | 2 | Checking |
| Morphing controls | 49 | 2 | Checking |
| Page transitions | 62 | 3 | Checking |
| Spring presses | 66 | 3 | Checking |
| Magnetic buttons | 85 | 2 | Checking |
| Parallax | 90 | 5 | Checking |
| Cursor spotlight | 112 | 4 | Checking |
| Backdrop blur | 135 | 6 | Checking |
| Frame chart, full res | 180 | 8 | Checking |
Governor and changes
Four ways in
Ask in JavaScript, ask in React, decide before paint with the boot script, or let CSS read the decision straight from <html>.
effects.ts
import { budget } from "framebudget";
// Your own effect: a threshold, a relative cost, and flags.
budget.register("confetti", { threshold: 60, cost: 4, motion: true });
if (budget.allows("confetti")) launchConfetti();
budget.on("change", (snapshot, reason) => {
// reason: "warm", "governor", "pressure", "motion", ...
setEffects(snapshot.effects);
});
Hero.tsx
import { useBudget, useTier } from "framebudget/react";
export function Hero() {
// false during server rendering and hydration, then the real answer
const parallax = useBudget("parallax");
const tier = useTier();
return <Layers parallax={parallax} data-tier={tier} />;
}
vite.config.ts
import { createBootScript } from "framebudget/boot";
// The same calibration you pass to configure(), so both agree.
const boot = createBootScript({ calibration: { effects } });
// First script in <head>, before your CSS.
const html = page.replace("<head>", `<head><script>${boot}</script>`);
site.css
/* Written on <html> before the first paint. */
html[data-framebudget-effects~="blur"] .header {
backdrop-filter: blur(16px);
}
html[data-framebudget="Lite"] .hero-video {
display: none;
}
The effects on this page
Fifteen effects, each registered with a threshold and a cost. A tier is every effect at or below its floor: Lite 20, Medium 49, High 90, Full 180. The column of this device's tier is lit.
| Effect | What it does | Threshold | Cost | Lite 20 | Medium 49 | High 90 | Full 180 |
|---|---|---|---|---|---|---|---|
Hover states hover | Links, rows and controls answer the pointer. | 10 | 1 | ||||
Counting numbers counters | Scores, frame times and counts roll to their new value on a spring. Off under reduced motion. | 12 | 1 | ||||
Interface sounds sound | Synthesized taps, toggles and outcomes, from cuelume. No audio files. Off under Save-Data. | 20 | 1 | ||||
Staggered reveals entrances | Sections arrive in a short stagger as they scroll into view. Off under reduced motion. | 24 | 2 | ||||
Frame chart, 1x canvasLowRes | The hero's live frame chart, at one pixel per CSS pixel, stepping. Off under reduced motion. | 32 | 3 | ||||
Deadline glow shimmer | The 16.7 ms line breathes and the install command catches a sheen. Off under reduced motion. | 38 | 2 | ||||
Text reveals textReveal | Headlines arrive by blur, by word, by line or by wipe. Off under reduced motion. | 46 | 2 | ||||
Morphing controls morph | Selectors slide, buttons confirm in place, readouts swap their text. Off under reduced motion. | 49 | 2 | ||||
Page transitions pageTransition | A cross-document view transition between this page and the API reference. Off under reduced motion. | 62 | 3 | ||||
Spring presses springs | Buttons give under the press and spring back. Off under reduced motion. | 66 | 3 | ||||
Magnetic buttons magnetic | The main buttons lean toward the pointer. Off under reduced motion. | 85 | 2 | ||||
Parallax parallax | The hero's headline and grid drift at different speeds as you scroll. Off under reduced motion. | 90 | 5 | ||||
Cursor spotlight spotlight | A soft light follows the pointer across the panels. Off under reduced motion. | 112 | 4 | ||||
Backdrop blur blur | Frosted glass behind the header and the device dock. | 135 | 6 | ||||
Frame chart, full res canvasHiRes | The frame chart at the screen's pixel ratio, scrolling smoothly. Off under reduced motion and Save-Data. | 180 | 8 |
Questions
Does measuring the device slow the page down?
The boot script is 3.7 KB gzipped and its benchmark runs for 1.6 ms. The warm benchmark runs after the load event in five separate tasks, so it never becomes a long task. The governor samples frames for the first seconds and while the visitor interacts, not forever.
Why not just read the core count or the memory?
They are hints, not speed. Two phones with eight cores can differ by five times. framebudget uses them only as caps: one gigabyte of memory or two cores limit the score to 60. The score itself comes from running code on the device.
What about reduced motion and Save-Data?
Effects flagged motion turn off under prefers-reduced-motion: reduce, and effects flagged data turn off under Save-Data or a 2g connection. Both are watched live. These preferences do not lower the reported tier, because the device did not get slower.
Does it send anything anywhere?
Not unless the site developer configures an endpoint. Local learning stays in one localStorage entry. Optional telemetry is anonymous, sampled, sent as one beacon, and never sent under Global Privacy Control or Save-Data. This site does share its own visitors' measurements, to calibrate the numbers: what it sends and how to say no.
What happens during server rendering?
Without a window, allows() answers false and the tier is Lite. In React, useBudget answers false during server rendering and hydration, so effects start on the client only.
Are the reference numbers final?
No. The reference rates and thresholds come from one desktop measurement scaled down to stand for a mid-range phone. They will change once they are calibrated on real devices, and every value can be overridden with configure().