Performance

backdrop-filter froze the renderer twice — and what makes glass look like glass

Twenty-one blurred cards on one 3,800px page locked the renderer for thirty seconds. The mechanism behind it, and the four cheap layers we built the glass from.

backdrop-filter froze the renderer twice — and what makes glass look like glass

Twenty-one blurred cards on one page took the tab down. The page was about 3,800px tall, every card carried backdrop-filter, and the canvas underneath was a gradient. Scrolling it stopped being scrolling and became a slideshow, and then the renderer stopped answering at all: a screenshot request went unanswered for thirty seconds. Not a dropped frame. The tab was gone.

That was the first version of the glass on this site. The second version put the blur on two full-width panels only and left the twenty-one cards clear. It tore frames at some scroll positions and hung the renderer at others — reproducibly, and with the blur toggled off it stopped.

Two more things did the same thing, and they are the interesting part, because neither of them contains the string backdrop-filter at all.

Four failures, one mechanism

Here is the list, in the order they were hit:

  • 21 blurred cards over a gradient canvas, page ~3,800px. Renderer locked, thirty seconds unresponsive.
  • 2 large blurred panels, cards clear. Torn frames, then locked.
  • background-attachment: fixed on body. Same symptom, tried and reverted.
  • body { background: transparent }. Same symptom. This one was supposed to be a bug fix.

To draw one backdrop-filter element, the compositor has to produce the image behind it first. It walks up to what the spec calls the backdrop root, takes everything painted below the element inside that root, blurs it, and composites the element on top. The expensive word in that sentence is everything.

The shortcut that makes the feature affordable in practice is an opaque ancestor. If some ancestor paints a fully opaque background, nothing below it can contribute to the image, so the walk stops there. A small blurred bar over an opaque page body reads one opaque rectangle, blurs a strip of it, and is done.

Now take the opacity away. With body { background: transparent } there is no opaque floor anywhere in the chain, so every filtered element composites the whole ancestry to the root — for every filtered element, on every frame in which anything under it moves. Which, on a scrolling page, is every frame.

background-attachment: fixed arrives at the same place from the other direction. Chrome promotes a fixed background into its own composited layer, so it no longer participates in the scrolled content the way a normal painted background does; a backdrop-filter element sitting over that layer has to re-read and re-blur it every frame instead of reusing a raster. Same cost, different cause.

So the four failures are one failure. It is never “blur is slow” in the abstract. It is: how much of the page does this element have to reconstruct, and how often. Twenty-one elements each reconstructing a tall gradient per frame is the same arithmetic as one element with nothing opaque to stop at.

That is also why the fix for the original bug was so nearly a disaster. The canvas on this site used to be painted on body::before at z-index:-1, and a negative z-index child paints in step 2 of the painting order — above its parent’s background, but below the in-flow backgrounds of step 4. assets/css/home.css was setting body { background: var(--lav-ink-1) }, an opaque near-white. So an opaque sheet was being painted directly over the canvas on every page of the site, and every translucent surface in the theme was compositing against one flat colour. No amount of tuning the panels could have reached that. The obvious repair is background: transparent on the body, and the obvious repair is the thing that froze the renderer.

The canvas went onto body itself instead. Opaque, so the shortcut survives; coloured, so there is something to see through the glass.

What produces the look, once the blur is gone

Four things, none of which cost the compositor anything after the first raster.

1 · An opaque canvas with colour in it

body {
  background-color:#080F1E;
  background-image:
    radial-gradient(30% 15% at 12% 14%, rgba(37,99,235,.42)  0%, rgba(37,99,235,0)  82%),
    radial-gradient(30% 14% at 84% 30%, rgba(13,148,180,.34) 0%, rgba(13,148,180,0) 82%),
    radial-gradient(28% 13% at 22% 48%, rgba(88,40,170,.36)  0%, rgba(88,40,170,0)  82%),
    radial-gradient(32% 15% at 90% 66%, rgba(4,57,217,.42)   0%, rgba(4,57,217,0)   82%),
    radial-gradient(30% 14% at 10% 84%, rgba(13,148,180,.30) 0%, rgba(13,148,180,0) 82%),
    linear-gradient(180deg, #0B1220 0%, #091023 50%, #060B16 100%);
  background-size:
    100% 1500px, 100% 1500px, 100% 1500px, 100% 1500px, 100% 1500px,
    100% 100%;
  background-repeat:
    repeat-y, repeat-y, repeat-y, repeat-y, repeat-y,
    no-repeat;
}

The background-size and background-repeat lists are the part worth stealing. The first version of this canvas used a few fields sized in the tens of percent, spanning the whole document. Measured across the whole page that is a rich drifting wash. Measured across one viewport — the only thing anybody ever looks at — it is nearly flat, because a 44%-tall field on a 6,000px document is 2,600px of gradient and the colour barely moves over the 950px on screen.

A flat backdrop is the one thing glass cannot survive. A pane reads as glass because what is behind it differs from what is beside it; over an even field, a translucent panel and its background composite to almost the same colour and the panel is a white rectangle again. That is the whole reason the surfaces looked flat no matter how the fills were tuned. The fault was never in the panels.

Smaller fields fix the appearance and reintroduce the cost: several radial gradients evaluated per pixel, for every tile the compositor rasterises, down the length of a very tall document — while the sticky topbar, which still carries a blur, re-reads whatever is under it every frame. That combination took the tab down as well. The third time on this project that a backdrop was the thing that broke.

Pinning the field layers to a 1,500px band with background-size and stamping them with repeat-y bounds it: the raster cost is one band whether the document is 3,000px or 30,000px, and the band gets cached. The base wash underneath stays 100% 100% / no-repeat, because it is a top-to-bottom page gradient and tiling that would be visible.

The trade-off is real and worth writing down: nothing may cross the band edge. A field that did would be sliced, and the slice would repeat every 1,500px as a hard horizontal seam all the way down the page. Every field here is 14–15% of the band — 210–225px of vertical radius, fading out at 82% of that — and the first sits at 14% with 25px of clearance, the last at 84% with 55px. Edit one of those numbers and that is the thing to re-check.

2 · A translucent fill

:root[data-theme="light"] {
  --glass-fill:rgb(255 255 255 / calc(0.42 * var(--lav-glass-intensity,1)));
  --glass-fill-strong:rgb(255 255 255 / calc(0.58 * var(--lav-glass-intensity,1)));
}
:root {
  --glass-fill:rgb(150 190 255 / calc(0.075 * var(--lav-glass-intensity,1)));
  --glass-fill-strong:rgb(150 190 255 / calc(0.12 * var(--lav-glass-intensity,1)));
}

Light mode used to composite to about 96% white — an opaque sheet with the word “glass” in its class name. The number that matters is not the blur radius, it is this one, and it is bounded on the other side by contrast: the fill has to be light enough that the canvas reads through it and dense enough that 16px text still clears AA against it. That is measured, per theme, not chosen by eye.

3 · A two-gradient sheen

--glass-sheen:
  linear-gradient(118deg, rgba(255,255,255,0) 18%, rgba(255,255,255,.78) 36%,
                          rgba(255,255,255,.18) 50%, rgba(255,255,255,0) 64%),
  linear-gradient(180deg, rgba(255,255,255,.72) 0%, rgba(255,255,255,.14) 40%,
                          rgba(196,216,255,.20) 100%);

The 118° streak is the highlight across the pane and the single most recognisable thing about the material; the vertical one is the only cue that says where the light is coming from. Both are baked into one background-image rather than being a separate element with a blend mode, which keeps them free.

They are applied as background-color plus background-image, never the background shorthand. The shorthand resets background-clip, and on this project that once freed a gradient off the letters of the 404 numeral and painted it as a solid blue slab.

4 · A three-layer rim

--glass-rim:
  inset 0 1px 0 rgba(255,255,255,.98),      /* the lit lip */
  inset 0 0 0 1px rgba(255,255,255,.42),    /* the thickness, all the way round */
  inset 0 -1px 0 rgba(4,57,217,.07);        /* where the pane meets what it sits on */

Drop any one of the three and the panel goes back to being a rectangle with a border. The cast is two shadows, not one — a tight dark one for the contact edge, a wide soft blue one for distance — because a single mid shadow reads as blur rather than as height.

The thing that actually reads as liquid

None of the above moves. What sells the material in iOS is not frosting, it is the specular: the highlight on a pane travels when you or it move. That is one radial gradient whose centre is two custom properties, and it does more for the effect than any blur radius.

.lq::after {
  content:""; position:absolute; inset:0;
  border-radius:inherit; pointer-events:none; z-index:-1;
  background:radial-gradient(var(--lq-spec-r) circle at var(--mx,50%) var(--my,50%),
             var(--lq-spec-core) 0%,
             var(--lq-spec-mid-1) 22%,
             var(--lq-spec-mid-2) 46%,
             transparent 72%);
  opacity:0;
  transition:opacity 260ms cubic-bezier(0.22,1,0.36,1);
}
.lq:hover::after,
.lq:focus-within::after,
.lq.is-lit::after { opacity:1; }

One delegated listener writes the two properties, and the write is deferred into a requestAnimationFrame so a burst of pointermove events between two frames collapses into a single style write:

document.addEventListener('pointermove', function (e) {
  if (e.pointerType === 'touch') { return; }
  var el = e.target && e.target.closest ? e.target.closest('.lq') : null;
  if (!el) { return; }
  target = el; px = e.clientX; py = e.clientY;
  if (pending) { return; }
  pending = requestAnimationFrame(function () {
    pending = null;
    if (!target) { return; }
    var r = target.getBoundingClientRect();
    if (!r.width || !r.height) { return; }
    target.style.setProperty('--mx', ((px - r.left) / r.width * 100).toFixed(2) + '%');
    target.style.setProperty('--my', ((py - r.top) / r.height * 100).toFixed(2) + '%');
  });
}, { passive: true });

With twenty-odd surfaces on the page, per-element listeners would be twenty-odd closures and twenty-odd getBoundingClientRect calls competing on one frame. This does exactly one of each, for the card the pointer is over. The custom property is the cheapest possible write: its only consumer is a gradient inside a pseudo-element, so it invalidates paint for that pseudo and nothing else — no layout, no style recalc on siblings.

The defaults are 50%, which is not decoration. A keyboard user hitting :focus-within gets a stationary highlight rather than none, and touch devices — which have no pointer to follow — skip the listener entirely behind a (hover: hover) and (pointer: fine) query.

One non-obvious detail cost an hour. The pseudo-elements sit at z-index:-1 on a card with isolation:isolate, rather than the more natural z-index:0 on the pseudo plus position:relative; z-index:2 on the card’s children. The natural version breaks the site invisibly: half these cards are stretched-link cards, where the title contains a::after { position:absolute; inset:0 } to grow the anchor’s hit area over the whole card. inset:0 resolves against the nearest positioned ancestor, so making the h3 positioned moves that ancestor from the card to the heading, and every one of those cards silently shrinks its click target to the two words in its title. Putting the layers below the content instead touches no child. isolation:isolate keeps the negative index local — without a stacking context on the card, z-index:-1 escapes to the nearest ancestor that has one and the highlight paints behind the section instead of inside the card — and it costs nothing, because isolation draws a boundary rather than promoting a layer.

Where the blur still lives

Two elements: the topbar and the mobile tab bar.

-webkit-backdrop-filter:blur(var(--glass-blur,26px)) saturate(var(--glass-sat,180%));
        backdrop-filter:blur(var(--glass-blur,26px)) saturate(var(--glass-sat,180%));

Both are small, both are pinned, and both are the only surfaces that ever have arbitrary page content sliding underneath them — which is the one case where a blur is doing a job nothing else can do. Everything else declares backdrop-filter:none explicitly, including the shared .glass class, because that class is defined in an older stylesheet that still sets a blur. On the shop page that was six live blurred panes: the same mistake as the twenty-one, with a smaller number in front of it, shipping on the busiest non-home template on the site.

The saturate is worth keeping where blur survives. Real glass concentrates the colour it transmits; without it the pinned bars read as tracing paper.

Where this measurement is crude

Honest accounting, because the numbers above are load-bearing and they are not a profile.

“The tab stopped answering a screenshot request for thirty seconds” is the evidence. There is no compositor trace, no frame-time distribution, no layer count. It is a binary observation on one machine, in Chrome, at one window size, and it was reproducible — blur on, dead; blur off, fine — which is enough to make a decision and not enough to publish a graph.

The ceiling was never bisected. Twenty-one panes killed it; two large panes killed it; nobody established how many mid-sized blurred panes over this canvas are survivable, or how that number moves on a machine with a different GPU. The rule shipped is “none outside the two pinned bars”, which is conservative on purpose. If the whole thing was a driver-level pathology on one laptop, the site pays only the cost of a look it prefers anyway.

Nothing here has been verified in Safari or Firefox. Both support the properties involved; neither has been watched under this load.

And the honest version of the conclusion is narrower than “don’t use backdrop-filter“. It is: know what your filtered element has to reconstruct, and how often. One small pinned bar over an opaque page costs nearly nothing. Twenty-one cards over a page-tall gradient is a different program with the same declaration in it.

Comments

No comments yet — be the first to share what you think.

Leave a comment

Your email address stays private. Required fields are marked