Performance budget
At most three composited surfaces on screen at once, why a blurred backdrop costs what it costs, and how to measure it on the phone your readers actually own.
Overview
Translucent materials are not free, and their cost is paid in exactly the place a health product can least afford it: the frame rate of a scroll on a mid-priced Android phone that is three years old and has 40% battery.
The budget is one number:
At most three composited surfaces visible at once.
Three is not a round number picked for tidiness. It is what the worst legitimate
screen in the system needs: an overlay pinned at the top, a scrim over the
content, and a sheet above the scrim. Anything beyond that is a screen that has
acquired translucency it did not design for.
The thing this is most often confused with is bundle size. They are unrelated costs with unrelated fixes; Performance and bundle size covers the other one.
How it works
What a backdrop filter actually costs
An element with backdrop-filter forces three things the compositor would
otherwise not do:
- Its own compositing layer. The element is promoted, which costs GPU memory proportional to its area at device pixel ratio. A full-width bar on a 3× phone is a surprisingly large texture.
- A readback of what is behind it. The compositor has to snapshot the
region of the backdrop under the element before it can filter it. That
snapshot is the expensive part, and it is why
backdrop-filtercosts substantially more thanfilteron the same element. - A blur pass whose cost scales with area and radius. Gaussian blur is separable, so the cost is roughly linear in radius rather than quadratic, but it is multiplied by the area every time.
And then the important part: all three repeat on any frame in which the backdrop changes. For a sticky bar with content scrolling under it, that is every frame of every scroll. Scrolling is the single most common interaction in a list-based health app, and the one where a dropped frame is most visible.
Why the number is three
| Surfaces | Typical screen | Verdict |
|---|---|---|
| 0 | a list of readings on canvas and card | the common case; costs nothing |
| 1 | the same list with a sticky overlay | fine |
| 2 | a sheet over a scrim, no pinned chrome | fine |
| 3 | an overlay, a scrim and a sheet | the designed worst case |
| 4+ | a list of translucent cards, or a nested blur | over budget |
The step from 3 to 4 is almost always caused by one of two mistakes: giving a repeating element a translucent rung, or nesting one translucent rung inside another. Both are already banned by Choosing a layer rule N1. The budget is what happens when the rule is broken, not a second rule.
Note that a full-screen sheet counts as one surface but is the size of the
viewport, so three surfaces of which one is full-screen is materially more
expensive than three small ones. The budget counts surfaces because that is a
number a designer can hold; area is the thing that actually costs, and if you are
near the limit it is the number to check.
Five rules that keep you inside it
1. A repeating element never gets a translucent rung. Ten translucent cards
in a list is ten readbacks and ten blur passes, every frame, while the list is
moving. Cards are card or raised.
2. Never nest translucent rungs. Two blurs stacked is two readbacks where the second reads the output of the first, which also serialises them.
3. Do not set will-change: backdrop-filter. It permanently promotes the
layer and holds its memory for the whole lifetime of the element, including the
95% of the time the element is idle. will-change is for an animation that is
about to start, and a bar is never about to start.
4. Never animate the blur radius. Every frame is a full re-filter at a new
radius, and there is no cheap path. Animate opacity on the surface instead. It
is compositor-only and the visual result is close enough that nobody has ever
noticed the difference in a usability session.
5. Keep radii modest. Beyond roughly the low twenties of CSS pixels the visual difference between one blur and a larger one is very hard to see, while the cost keeps climbing. The ladder's radii are chosen at the point where the curve flattens.
Using it
How to measure it, reproducibly
Performance claims without a device, a browser build and a date are not measurements. This is the procedure the budget is defined against.
Use a real mid-range phone
Not a flagship, not an emulator, not a desktop with CPU throttling. The path under test is the GPU compositor, and CPU throttling does not simulate a weaker GPU or slower memory bandwidth. A mid-priced Android handset of the last three years is the target; if your product's analytics name a specific popular device, use that one.
Build for production
A development build's extra work will dominate the trace and hide the thing you are looking for.
Record a fling scroll with the surfaces present
Chrome DevTools → remote debugging → Performance panel. Record five seconds of a hard fling scroll on the screen with the most composited surfaces. That is usually a long list with the sticky bar pinned.
Read frames, not totals
The metric is the frame timeline, not total scripting time. What matters is whether any frame exceeded the device's frame budget during the fling, and how many did. A screen that averages well and drops four frames at the start of every scroll feels broken; a screen with a slightly higher average and no drops does not.
Count the layers you actually created
Rendering panel → Layer borders and Paint flashing. Compare what you see with what you intended. This is where a fourth composited surface nobody designed usually shows up.
Confirm on the compositor thread if it is ambiguous
If the Performance panel does not make the cause obvious, a Perfetto or
chrome://tracing capture will show whether time is going into the readback,
the filter or elsewhere.
Record the device, the build and the date with the number
A figure without those three cannot be compared to the next one, which makes it useless for exactly the purpose you took it for.
What to do when you are over budget
In order of how much they help and how little they cost:
- Move a repeating element off a translucent rung. This is the fix in the large majority of real cases.
- Reduce the area of the translucent surface before reducing its radius. A bar that is 56px tall costs a fraction of one that is 160px tall.
- Make the surface opaque while it is moving and translucent when it settles, if the design can absorb it. This is a real technique and it is honest. Nobody is reading the backdrop through a bar during a fling.
- Drop the rung. An
overlaythat becomes acardcosts nothing and loses one visual affordance.
Do not reach for lowering the alpha. That is the one dimension of a material that is not available for tuning. See The contrast floor.
Tokens
| Token | What it controls | Used by |
|---|---|---|
| --opsin-material-canvas-tintvar(--opsin-neutral-0) | Rung 0 (canvas): the tint over what is behind it. The application background. Nothing is behind it, so nothing shows through it. | surface |
| --opsin-material-canvas-tint-alpha1 | Rung 0 (canvas): how opaque that tint is. | surface |
| --opsin-material-canvas-blur0px | Rung 0 (canvas): backdrop blur radius. | surface |
| --opsin-material-canvas-saturation1 | Rung 0 (canvas): backdrop saturation multiplier. | surface |
| --opsin-material-canvas-bordernone | Rung 0 (canvas): the boundary. | surface |
| --opsin-material-canvas-shadownone | Rung 0 (canvas): the shadow that separates it from what is behind. | surface |
| --opsin-material-canvas-scrim0 | Rung 0 (canvas): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-canvas-opaquevar(--opsin-neutral-0) | Rung 0 (canvas): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
| --opsin-material-inset-tintvar(--opsin-neutral-100) | Rung 0.5 (inset): the tint over what is behind it. The recessed well inside a card: the explanatory note a Callout draws inside the reading it annotates, and the track a RangeBar lays its range along. Its fill sits below the card fill in both themes, so the well reads as carved into the card rather than lifted off it. In light it is neutral-100, one step under the white card, which is where a Callout already sat. In dark it is a value between the page and the card, below the card so the well is a recess and above the page so a Callout used on its own is still visible. | callout, range-bar, surface |
| --opsin-material-inset-tint-alpha1 | Rung 0.5 (inset): how opaque that tint is. | surface |
| --opsin-material-inset-blur0px | Rung 0.5 (inset): backdrop blur radius. | surface |
| --opsin-material-inset-saturation1 | Rung 0.5 (inset): backdrop saturation multiplier. | surface |
| --opsin-material-inset-bordernone | Rung 0.5 (inset): the boundary. | surface |
| --opsin-material-inset-shadownone | Rung 0.5 (inset): the shadow that separates it from what is behind. | surface |
| --opsin-material-inset-scrim0 | Rung 0.5 (inset): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-inset-opaquevar(--opsin-neutral-100) | Rung 0.5 (inset): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
| --opsin-material-card-tintvar(--opsin-neutral-0) | Rung 1 (card): the tint over what is behind it. The default home for a health value. Opaque, bounded by a line rather than a shadow, and the rung every ResultCard, MetricTile and RangeBar sits on. | surface |
| --opsin-material-card-tint-alpha1 | Rung 1 (card): how opaque that tint is. | surface |
| --opsin-material-card-blur0px | Rung 1 (card): backdrop blur radius. | surface |
| --opsin-material-card-saturation1 | Rung 1 (card): backdrop saturation multiplier. | surface |
| --opsin-material-card-bordervar(--opsin-neutral-200) | Rung 1 (card): the boundary. | surface |
| --opsin-material-card-shadownone | Rung 1 (card): the shadow that separates it from what is behind. | surface |
| --opsin-material-card-scrim0 | Rung 1 (card): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-card-opaquevar(--opsin-neutral-0) | Rung 1 (card): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
| --opsin-material-raised-tintvar(--opsin-neutral-0) | Rung 2 (raised): the tint over what is behind it. Menus, popovers, tooltips, a dragged card. Still opaque: the shadow, not translucency, is what says 'above'. | surface |
| --opsin-material-raised-tint-alpha1 | Rung 2 (raised): how opaque that tint is. | surface |
| --opsin-material-raised-blur0px | Rung 2 (raised): backdrop blur radius. | surface |
| --opsin-material-raised-saturation1 | Rung 2 (raised): backdrop saturation multiplier. | surface |
| --opsin-material-raised-bordervar(--opsin-neutral-200) | Rung 2 (raised): the boundary. | surface |
| --opsin-material-raised-shadow0 1px 2px oklch(0 0 0 / 0.06), 0 4px 12px oklch(0 0 0 / 0.06) | Rung 2 (raised): the shadow that separates it from what is behind. | surface |
| --opsin-material-raised-scrim0 | Rung 2 (raised): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-raised-opaquevar(--opsin-neutral-0) | Rung 2 (raised): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
| --opsin-material-sheet-tintvar(--opsin-neutral-0) | Rung 3 (sheet): the tint over what is behind it. A bottom sheet or side panel the reader can dismiss by dragging. Its tint is now near opaque, so the panel lands close to the card colour and a white control inside it no longer floats above a greyer surface. What keeps the covered page recognisable is the scrim around the panel rather than the page reading through the panel, because at this opacity very little of the page shows through the tint. | surface |
| --opsin-material-sheet-tint-alpha0.93 | Rung 3 (sheet): how opaque that tint is. | surface |
| --opsin-material-sheet-blur20px | Rung 3 (sheet): backdrop blur radius. | surface |
| --opsin-material-sheet-saturation1.6 | Rung 3 (sheet): backdrop saturation multiplier. | surface |
| --opsin-material-sheet-borderoklch(0 0 0 / 0.08) | Rung 3 (sheet): the boundary. | surface |
| --opsin-material-sheet-shadow0 -1px 2px oklch(0 0 0 / 0.05) | Rung 3 (sheet): the shadow that separates it from what is behind. | sheet, surface |
| --opsin-material-sheet-scrim0.93 | Rung 3 (sheet): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-sheet-opaquevar(--opsin-neutral-0) | Rung 3 (sheet): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
| --opsin-material-overlay-tintvar(--opsin-neutral-0) | Rung 4 (overlay): the tint over what is behind it. A pinned toolbar, a tab bar and a floating action bar are all chrome that content scrolls beneath. | surface |
| --opsin-material-overlay-tint-alpha0.74 | Rung 4 (overlay): how opaque that tint is. | surface |
| --opsin-material-overlay-blur28px | Rung 4 (overlay): backdrop blur radius. | surface |
| --opsin-material-overlay-saturation1.8 | Rung 4 (overlay): backdrop saturation multiplier. | surface |
| --opsin-material-overlay-borderoklch(0 0 0 / 0.10) | Rung 4 (overlay): the boundary. | surface |
| --opsin-material-overlay-shadow0 8px 32px oklch(0 0 0 / 0.12) | Rung 4 (overlay): the shadow that separates it from what is behind. | combobox, menu, popover, select, surface, toast, tooltip |
| --opsin-material-overlay-scrim0.74 | Rung 4 (overlay): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-overlay-opaquevar(--opsin-neutral-0) | Rung 4 (overlay): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
| --opsin-material-scrim-tintvar(--opsin-neutral-950) | Rung 5 (scrim): the tint over what is behind it. The dimming layer behind a modal dialog or a consent sheet. Its job is to remove the page from consideration, not to look like glass. | surface |
| --opsin-material-scrim-tint-alpha0.44 | Rung 5 (scrim): how opaque that tint is. | surface |
| --opsin-material-scrim-blur2px | Rung 5 (scrim): backdrop blur radius. | surface |
| --opsin-material-scrim-saturation1 | Rung 5 (scrim): backdrop saturation multiplier. | surface |
| --opsin-material-scrim-bordernone | Rung 5 (scrim): the boundary. | surface |
| --opsin-material-scrim-shadownone | Rung 5 (scrim): the shadow that separates it from what is behind. | surface |
| --opsin-material-scrim-scrim0.44 | Rung 5 (scrim): the minimum scrim opacity needed for text on this rung to clear the contrast floor. | surface |
| --opsin-material-scrim-opaqueoklch(0.205 0.006 250 / 0.72) | Rung 5 (scrim): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported. | surface |
Accessibility impact
Performance is an accessibility property, and in this system it is a safety property too.
- A dropped frame during a scroll is a reading failure. A reader scanning a list of readings on a stuttering screen misreads or re-reads. The people most affected are the ones on the cheapest hardware, which in a consumer health product correlates with the people who most need the product to work.
- Motion sensitivity and jank interact. Irregular, stuttering motion is
reported as more uncomfortable than smooth motion at the same speed. A screen
that respects
prefers-reduced-motionand then janks has not helped. - Reduced transparency is also a performance path. A reader who has turned transparency off gets zero composited surfaces, and their device gets the cheapest rendering path in the system. That is a happy accident of the fallback, and it is a reason to make sure the opaque path is genuinely first-class rather than a degradation.
Stand up a device-lab measurement and publish real frame-time figures per rung and per surface count, with the device, the browser build and the date next to each. Until that exists, the budget of three is an engineering judgement drawn from the cost model above, and this page does not pretend otherwise. There is no measured millisecond figure anywhere on it.
Related
- Choosing a layer covers rule N1, which is what keeps the surface count inside the budget in the first place.
- The ladder has the radii, and why they stop where they stop.
- Performance and bundle size is the other performance budget, which is about JavaScript rather than pixels.