Why is backdrop blur priced in pixels read rather than pixels painted?
Backdrop blur is the most expensive visual effect I put in a dark interface, and the expense comes from reading pixels rather than painting them: the compositor captures the region behind the element, applies the filter chain to that capture, composites the result, and discards the capture the moment the backdrop changes. An element whose own content is one line of text can cost more than the rest of the page, because its price is set by what sits behind it, how far the blur reaches for it, and how many elements are reaching at the same time.
I have shipped the version of this that reads as a design decision in review. A card grid where each tile carries a translucent plate with backdrop-filter: blur(40px) saturate(140%) looks like restraint; what it produces is one capture-filter-composite operation per tile per frame, on a list that scrolls. I have watched that change take a mid-range Android device from smooth scrolling to visibly stuttering with nothing else different in the build. That is my observation on one device, not a benchmark, and it is why I now attach a budget to the effect instead of leaving designers a taste knob.
A backdrop blur is priced by the pixels it reads and how often it must read them, so its cost rises with scroll distance, blur radius and the number of blurred elements, none of which appear anywhere in the CSS you wrote.
How large a region does a single blur actually read?
The element's box is not the region the browser samples. A blur pulls colour from outside the box, so the backdrop capture has to be larger than the element or the kernel would clamp to transparent at the edges; Chromium grows the capture by roughly three times the blur radius on each side. I treat that as an approximation to verify on your own target browser rather than a spec constant, but even as an approximation it turns an argument about taste into arithmetic.
Take a sticky header 1440 CSS pixels wide and 64 tall with blur(24px), one pass. The sampled region is (1440 + 6 × 24) by (64 + 6 × 24), which is 1584 by 208, or 329,472 CSS pixels of read per frame. The element itself is 92,160, so the read is 3.6 times the box the designer is thinking about. At DPR 2 that becomes 1,317,888 device pixels read and filtered during every frame in which the backdrop changes, and there are 60 such frames per second of scrolling, so one 64-pixel-tall bar asks for about 79 million device pixels per second of use.
Radius is the multiplier that is easiest to move without noticing. The same header at blur(12px) reads 205,632 CSS pixels per frame; at blur(48px) it reads 608,256, which is 2.96 times the cost of the 12px version for a header whose element box did not change by a single pixel. The reason designers reach for 48 in the first place is covered below, and it is not a good reason.
Reading is also only the first pass. Each function in the filter list is applied to the capture in sequence, so blur(24px) saturate(140%) brightness(1.05) is three passes over that same expanded region rather than one. Nothing in the source reads as a threefold request.
Why is one sticky header cheaper than three ordinary cards?
Both re-filter on every scroll frame, so the invalidation rate is not the difference. Multiplicity and bound are. A fixed or sticky header has geometry the viewport fixes for you, so its cost is a number you can write down in advance and defend in review. Blur inside a scroll container has a count that grows with content, and content grows in pull requests that do not mention rendering.
| Placement | Regions per frame | Area bound | Backdrop invalidated by | Layers | Verdict |
|---|---|---|---|---|---|
| Fixed or sticky chrome | 1, or 2 with a bottom bar | viewport width times a small height | every scroll frame | 1 per region | Acceptable if budgeted |
| Blurred card or tile in a scroller | 1 per visible tile | the content, unbounded | every scroll frame, per tile | 1 per tile, allocated as tiles enter and leave | Not acceptable |
| Scrim behind an open modal | 1, while open | the viewport, and only while open | rarely; the backdrop is static while open | 1 | Acceptable, and cheap because it is static |
| Element over a fixed background image | 1 | the element | never | 1, cached | Ship a pre-blurred asset instead |
filter: blur() on the element's own decoration | 0 backdrop reads | the element | only when its own content repaints | 1 | The cheap substitute for a static backdrop |
The second row costs more than most estimates. Three tiles at 380 by 220 with blur(40px) and one pass each read (380 + 240) by (220 + 240), or 620 by 460, which is 285,200 CSS pixels each and 855,600 for the row. That is 2.6 times the per-frame read of the full-width header above, and the tiles' own boxes total only 250,800 CSS pixels, so the read is 3.4 times the area being blurred from the designer's point of view. The row is also three composited layers rather than one, and at DPR 2 a layer's backing store is at least its box in device pixels times four bytes: 1.3 MB per tile, 3.8 MB for the row, against 1.4 MB for the single header layer. That memory is allocated and released as tiles enter and leave a virtualised list, which is exactly when the frame budget is tightest.
The shape I keep for the accepted case is short, and the part worth reading is the fallback rather than the blur, because contrast may never depend on an effect a browser can decline to apply.
/* Chrome tier: one region, fixed geometry, bounded area. */
.site-header {
position: sticky;
top: 0;
/* Painted first, so a browser without backdrop-filter still occludes the
content that scrolls under it. The plate must hold contrast alone. */
background-color: color-mix(in oklab, var(--surface) 94%, transparent);
border-block-end: 1px solid var(--line);
}
@supports (backdrop-filter: blur(4px)) {
.site-header {
background-color: color-mix(in oklab, var(--surface) 72%, transparent);
/* 16px, not 48px: the capture grows by about 3x the radius per side, so
this value multiplies a sampled area rather than tuning a taste. */
backdrop-filter: blur(16px) saturate(120%);
}
}
/* Forbidden tier. A blurred plate inside a scroller re-reads its capture on
every frame of scroll, once per visible tile. If the backdrop is static,
ship a pre-blurred asset or blur the plate's own background instead. */
What does a dark interface add to the bill?
Two things, both specific to dark themes. Translucency is how a dark interface builds depth where a light one uses shadow, so blur spreads across navigation, cards, popovers and toasts rather than living in one considered place. And dark low-contrast gradients band in 8-bit colour, so hiding a band means a wider blur over a noisy surface, and radii drift from 12 to 32 to 48 during polish. Each step multiplies the capture area while the element box stays identical, so the largest radius in a design system is usually its least examined number.
The other dark-theme habit is the saturation bump. A translucent plate over a dark background reads as grey, so saturate(140%) gets added, and sometimes brightness(1.05) with it. Because each function is another pass over the same grown region, the header with a three-function chain reads 3,953,664 device pixels per frame at DPR 2, which is 15 percent more than the three-card row that made me write this down, despite covering a fraction of the screen. If the goal is lifting a dark panel off its background, raising the panel's own lightness or adding a 1px border costs one static raster and no per-frame read at all.
Banding is worth separating from blur: a 2 percent noise overlay tiled from a 64 by 64 PNG is rasterised once and never re-read, while a 48px backdrop blur that hides the same band is re-read on every scroll frame for the life of the page. Only one of those is priced per frame.
How do I make the blur budget fail a build instead of a frame?
The uncomfortable part is that my Core Web Vitals gate will not catch any of this. Backdrop filtering does not block the main thread and adds no bytes, so it barely moves Total Blocking Time and does not change LCP unless it delays first paint. The ledger I keep appears in the CI gate that makes a Core Web Vitals budget real, and every number that gate measures would stay green while forty blurred tiles shipped. Blur needs a static check, so the constraint has to exist as data first.
/** The two placements that are allowed to carry a backdrop filter. */
type BlurTier = 'chrome' | 'static';
/** One entry per element allowed to paint a backdrop filter. */
export interface BlurSurface {
name: string;
tier: BlurTier;
/** Border box in CSS pixels at the widest breakpoint. */
width: number;
height: number;
radiusPx: number;
/** Functions in the filter list; each is applied to the capture in turn. */
passes: number;
/** True when the element is scrolling content rather than fixed chrome. */
isScrollingContent: boolean;
}
const MAX_RADIUS_PX = 24;
const MAX_CHROME_SURFACES = 1;
/**
* Device pixels read per frame at DPR 2. The header in the CSS above spends
* 1,966,080 of these at blur(16px) saturate(120%), about two thirds of the
* budget, which is why the ceiling on chrome surfaces is one.
*/
const FRAME_READ_BUDGET = 3_000_000;
/** Capture size: the box grown by about three times the radius per side. */
export function readPixels(surface: BlurSurface, dpr: number): number {
const capture = (surface.width + 6 * surface.radiusPx) * (surface.height + 6 * surface.radiusPx);
return capture * surface.passes * dpr ** 2;
}
export function audit(surfaces: BlurSurface[], dpr: number): string[] {
const failures = surfaces.flatMap((s) => {
const problems: string[] = [];
if (s.isScrollingContent) problems.push(`${s.name}: blurred inside a scroll container`);
if (s.radiusPx > MAX_RADIUS_PX) problems.push(`${s.name}: blur(${s.radiusPx}px) over the ceiling`);
return problems;
});
const chrome = surfaces.filter((s) => s.tier === 'chrome');
if (chrome.length > MAX_CHROME_SURFACES) {
failures.push(`${chrome.length} chrome surfaces, budget is ${MAX_CHROME_SURFACES}`);
}
const perFrame = surfaces.reduce((total, s) => total + readPixels(s, dpr), 0);
if (perFrame > FRAME_READ_BUDGET) failures.push(`${perFrame} device px read per frame`);
return failures;
}
Two properties matter more than the thresholds. The isScrollingContent flag is a boolean a reviewer can argue about in a pull request diff, where a radius in a stylesheet cannot be. And the read budget is in device pixels per frame at a stated density, so it halves when you grade against 120Hz, where the whole frame is 8.33ms rather than 16.67ms.
The ledger is intent and the built CSS is what shipped, so the first check greps the compiled output rather than the source.
# Declarations only. The [;{] anchor skips the @supports feature test, which
# is not a blurred surface. Radii in shipped order, most common first.
grep -rhoE '[;{]backdrop-filter:[^;}]*' .next/static/css/ \
| grep -oE 'blur\([0-9.]+px\)' \
| sort | uniq -c | sort -rn
# Rules shipping only the prefixed property still paint, and the pattern above
# cannot see them. This count is an upper bound, since a rule that emits both
# properties matches it as well.
grep -rhoE '[;{]-webkit-backdrop-filter:' .next/static/css/ | wc -l
Minified output is why the pattern is [^;}]* rather than a space after the colon: I have had a check pass for a month against backdrop-filter:blur(40px) because the pattern required whitespace the minifier had removed. The prefix is the same trap in the other direction: a count that includes it silently disagrees with the ledger.
How do I find a blur that never reached the ledger?
A static check only sees CSS you generate. Blur can arrive from a CMS-authored page, a third-party embed or a component library, so the ledger needs a second witness that reads the live DOM.
import { chromium } from 'playwright';
interface BlurredRegion { tag: string; cls: string; w: number; h: number; filter: string }
/** Every element in the document that resolves to a backdrop filter. */
export async function blurredRegions(baseUrl: string): Promise<BlurredRegion[]> {
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(baseUrl, { waitUntil: 'load' });
return await page.evaluate((): BlurredRegion[] =>
Array.from(document.querySelectorAll<HTMLElement>('body *')).flatMap((el) => {
const filter = getComputedStyle(el).backdropFilter;
if (filter === 'none') return [];
const box = el.getBoundingClientRect();
return [{
tag: el.tagName.toLowerCase(),
cls: typeof el.className === 'string' ? el.className : '',
w: Math.round(box.width),
h: Math.round(box.height),
filter,
}];
}),
);
} finally {
await browser.close();
}
}
The typeof el.className === 'string' guard is there because body * matches SVG elements, where className is an SVGAnimatedString. Run this at 390 and 1440 against a preview deployment and fail the job when the set of blurred selectors differs from the ledger in either direction: a missing entry means someone deleted chrome you still want, and an extra one means a per-frame read shipped with no budget attached. For manual confirmation, the paint-flashing overlay draws the capture rects directly.
When is backdrop blur the wrong tool?
Four cases, and I have hit all of them.
When the backdrop is static, the runtime blur is pure waste: the plate over a fixed image should be a pre-blurred asset or a filter: blur() layer on a pseudo-element holding the same background, rasterised once. When the blur exists only to make text legible over a photo, the fix is a gradient scrim, which is one static raster and zero reads. When the blurred element sits inside an ancestor with filter, opacity: 0.99, mask or will-change: filter, that ancestor becomes the backdrop root and the glass samples the wrong pixels entirely, which is a correctness bug no budget will surface. And will-change: backdrop-filter should not be added at all: the element is already promoted, so the hint buys nothing and retains memory you were trying to save.
What should change first?
Enumerate every blurred surface on the site and move the boundary, not the blur: delete the ones that are scrolling content, and make what remains either chrome or static, which is one or two class names and no visual redesign. Write the ledger as data with the tier, the box, the radius and the pass count, then run audit(ledger, 2) in a unit test so the constraint fails on a review comment rather than on a device. Add the two checks over the built CSS, because the minifier disagrees with your source in ways your source cannot show you, and run the DOM probe at 390 and 1440 against a preview URL to catch blur that arrived from outside your own stylesheets.
Then measure the version you have not seen yet: open the site on the oldest phone you own, scroll a page with the effect on it, and watch the frame timing rather than the screenshot. The blur that survives that test is usually one region with a moderate radius, which is the whole design.
Keep reading
- Theme Flash Is a Server Problem, Not a JavaScript Problem2026-02-139 minDesign Engineering
- A Scroll Animation Must Hide Content After the Observer Attaches, Never in the Markup2026-01-269 minDesign Engineering
- Design Engineering: Closing the Figma-to-Production Gap Without a Handoff2026-03-047 minDesign Engineering