24 August 2026 · performance · css · browsers
Building a window manager for this site, I wrote a performance rule
on the first day: no backdrop-filter on anything that moves. The glass on the
menu bar and the taskbar is the whole visual identity, so the rule was there to
protect dragging from it.
Months later I measured a drag properly and the rule had never done anything.
Where the cost actually is
The mistake was in what "anything that moves" meant. I had applied the rule to
the window being dragged, which is the one element in the scene that does not
need it. A window moving by transform is on its own compositor layer. The
browser is not repainting it; it is moving a texture it already has.
backdrop-filter does not work like that. It samples whatever is painted behind
the element and blurs the result. That sample is only reusable while the content
behind it stays still. Drag a window underneath the menu bar and the bar's
backdrop changes every single frame, so the browser resamples and reblurs it
every single frame, for the full width of the bar.
The expensive element is not the one moving. It is the stationary one the moving element passes under, and in the DOM the two are nowhere near each other.
So the flag has to be global. .is-dragging on the window frame is still useful
for will-change and the cursor, but the switch that matters is .os-gesture on
<html>, held for the length of any drag or resize.
Turning it off is harder than it looks
Two things stopped this working after I knew what to fix.
The shorthand is not where the value lives. Tailwind composes
backdrop-filter out of --tw-backdrop-* custom properties. Overriding the
shorthand, even with !important, leaves those properties in place, and I had
written both the prefixed and unprefixed spellings of the declaration. The
minifier kept one of the two. It kept -webkit-backdrop-filter, Chromium went
on reading Tailwind's unprefixed declaration, and the blur stayed exactly where
it was.
Emptying the custom property instead survives all of that:
.os-gesture [class*='backdrop-blur'] {
--tw-backdrop-blur: ;
}
Inheritance is not enough on its own. I also drive our own glass through an
inherited variable, set once on <html>. That works until something sets the
same variable on an element, which the refraction effect does. A value set on
the element wins over an inherited one no matter how specific the ancestor's
selector is, so the same switch has to be repeated at element level. The
selector starts with html for the same reason: without it, it ties with the
rule it is trying to beat and source order decides the winner.
The measurement
Production build, eleven windows open, dragging one across the menu bar. With the blur left on, the drag ran at roughly a fifth of the frame rate it ran at with the blur switched off.
That is the number that made the rule real, and it is also why the test suite now asserts it rather than trusting the CSS to still be doing its job. A performance rule with nothing checking it is a comment.
What I would take from this
Two things worth generalising.
The first is that compositing intuition runs backwards here. "What is moving" is
usually the right question for animation performance, and for this class of
effect it is the wrong one. backdrop-filter, and anything else that reads its
surroundings, costs where it is read, not where the change happened.
The second is that a rule you cannot see failing will eventually fail. This one was written down, agreed, and inert, and nothing about the site looked wrong. It took a profiler to notice, and a test to stop it happening again.