For filter and backdrop-filter, Lightning CSS merges a prefixed and unprefixed declaration into one, keeping the prefix flags of whichever you wrote last. So backdrop-filter followed by -webkit-backdrop-filter compiles to WebKit-only. Chrome doesn't support -webkit-backdrop-filter at all, so the blur silently disappears in production while the source looks correct. Most other prefixed properties survive the same treatment unharmed — which is exactly what makes this one easy to miss.
The symptom
A sticky header on one of my templates had a frosted-glass effect: translucent background, blur behind it. It worked on my machine for weeks. Then I looked at the deployed site in Chrome and the header was just flat translucent. No blur. Content scrolled underneath it perfectly sharp.
The source said otherwise. The rule was right there in globals.css, the class was on the element, no media query was hiding it, and there was no @supports gate. DevTools showed the element matched the rule.
What I assumed
I assumed a CSS specificity problem, or a stacking-context problem — backdrop-filter is famously sensitive to what's behind it and to ancestors that create containing blocks. I spent an embarrassing amount of time on transform and filter on parent elements.
That was wrong, and the reason it was wrong is that I was reading the source, not the stylesheet the browser downloaded.
The actual mechanism
The templates build with Next.js 16.2.10 (pinned in package.json); tailwindcss and @tailwindcss/postcss are declared as ^4 and resolve to 4.3.2 in my install. The PostCSS config is just:
// launch-template/postcss.config.mjs
const config = { plugins: { "@tailwindcss/postcss": {} } };
export default config;Tailwind v4 compiles CSS through Lightning CSS — lightningcss@1.32.0 in my install. Lightning CSS doesn't treat -webkit-backdrop-filter and backdrop-filter as two unrelated properties. It parses both into one internal property that carries a set of vendor-prefix flags, and then re-emits whatever flags survive.
Two declarations for the same property means the later one wins, as the cascade requires. But the thing that "wins" is the whole declaration including its prefix flags. Write the standard property first and the WebKit one second, and the standard flag is gone.
I ran the compiler directly to confirm. Same lightningcss@1.32.0 the build uses:
### Tailwind v4 default targets (safari 16.4, chrome 111, firefox 128)
standard, then -webkit- => .a { -webkit-backdrop-filter: blur(16px); }
-webkit-, then standard => .b { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
standard alone => .c { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }Read that last line carefully. Authoring only the standard property produces both. The compiler already knows Safari needed the prefix and adds it. My "belt and braces" second declaration didn't add safety — it destroyed the standard property.
It really is last-flag-wins, not a WebKit preference. With no targets configured at all, the collapse runs the other direction:
### no targets at all
standard, then -webkit- => .a { -webkit-backdrop-filter: blur(16px); }
-webkit-, then standard => .b { backdrop-filter: blur(16px); }It's the filter family, not every property
My first instinct was to rip out every hand-written prefix in the codebase. Before doing that I fed the same standard-then-webkit pair through the compiler for a spread of properties, and the result stopped me:
filter => -webkit-filter: blur(4px) ← standard GONE
backdrop-filter => -webkit-backdrop-filter: blur(16px) ← standard GONE
mask-image => both emitted
user-select => both emitted
background-clip => both emitted
hyphens => both emitted
box-decoration-break => both emitted
appearance => appearance: none (collapses to standard)
clip-path, transform => standard only, prefix droppedOnly the filter family loses the standard property. Everything else either emits both forms or collapses toward the standard one, so a redundant prefix there is genuinely harmless.
filter is arguably the nastier of the two. With these targets filter: blur(4px) alone compiles to filter: blur(4px) — no prefix needed by any target — so writing -webkit-filter after it replaces a working declaration with one that no browser in your support matrix asked for.
This inconsistency is the real trap. You check user-select, see both forms in the output, conclude your defensive prefixes are fine, and never think to check the two properties where they aren't.
Why Chrome specifically renders nothing
I'd half-assumed Chrome would accept -webkit-backdrop-filter as a legacy alias, since Blink is WebKit-derived. It does not. In Chrome 151:
CSS.supports('backdrop-filter', 'blur(16px)') // true
CSS.supports('-webkit-backdrop-filter', 'blur(16px)') // falseSetting it inline is dropped on the floor — it doesn't even map onto the standard property. So I probed it on a real element:
const mk = (css) => { /* build a div, apply css, read computed style */ };
mk('-webkit-backdrop-filter:blur(16px)') // "none"
mk('-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)') // "blur(16px)"
mk('backdrop-filter:blur(16px)') // "blur(16px)""none". The declaration the compiler emitted is one Chrome ignores completely. Safari kept working, which is exactly why it survived review for so long.
The diagnostic that settles it
Stop reading your source. Read the bytes the browser actually got:
# find the stylesheet the deployed page links
curl -sL https://launch.violettadev.com/en | grep -oE '[^"]+\.css' | sort -u
# then count the two forms in it
curl -sL https://launch.violettadev.com/_next/static/chunks/<hash>.css \
| grep -o -- '-webkit-backdrop-filter' | wc -l
curl -sL https://launch.violettadev.com/_next/static/chunks/<hash>.css \
| grep -o -- '[^-]backdrop-filter' | wc -lIf those two numbers don't match, you have collapsed declarations. On the current build they do — 11 and 11. Here is the shipped nav rule, verbatim from the deployed stylesheet:
.lv-nav{-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}And here is what I wrote to get it, from launch-template/src/app/globals.css:
.lv-nav {
position: sticky; top: 0; z-index: 100;
height: 64px; display: flex; align-items: center;
border-bottom: 1px solid transparent; transition: all .2s;
background: color-mix(in srgb, var(--bg) 80%, transparent);
backdrop-filter: blur(16px);
}One declaration in, two out. The compiler even prefixed the transition property list for me on the overlay variant: transition:...,-webkit-backdrop-filter .4s,backdrop-filter .4s.
If you want to confirm causation on a page that's already broken, do the inverse in DevTools: select the element and add backdrop-filter: blur(16px) by hand in the Styles pane. If the blur appears instantly, the compiler ate your standard property.
How I stop it happening again
I removed the hand-written backdrop-filter pairs and left a comment where the temptation lives, in four of the five templates' globals.css (wording identical, wrapped to fit each file):
/* Do NOT hand-write `-webkit-backdrop-filter` here — or anywhere else in this
file. The build (Tailwind v4 → Lightning CSS) treats the prefixed and
unprefixed property as ONE declaration and keeps only the prefix it saw LAST,
so an authored `backdrop-filter` + `-webkit-backdrop-filter` pair silently
ships as webkit-only and Chrome renders no blur at all. Author the standard
property alone; the build re-adds `-webkit-` when targets need it. */A comment is a reminder, not a guard. What actually catches this is a script that scans rule bodies for any property appearing both prefixed and unprefixed in the same block — currently run on demand rather than wired into CI. Across all five templates, 933 CSS/TSX/TS files, it finds zero filter or backdrop-filter pairs. Those are the ones that bite.
It does still find pairs for other properties: three mask-image pairs and two background-clip pairs in one template, two user-select pairs and an appearance pair in another. I put each through the compiler before deciding, and left them alone — they all emit both forms. Redundant, not dangerous. The remaining -webkit- declarations are properties with no standard equivalent at all: -webkit-font-smoothing, ::-webkit-scrollbar, -webkit-text-size-adjust, ::-webkit-details-marker, -webkit-overflow-scrolling, -webkit-user-drag.
If you build the same guard, don't make it fail on every pair — you'll bury the two real cases under a pile of harmless ones. Fail the build on filter and backdrop-filter; warn on the rest.
The general rule, and the part worth keeping: modern CSS pipelines prefix for you, and a hand-written prefix is not redundant insurance — it is an extra declaration that can overwrite the real one. If you migrated to Tailwind v4 and inherited a stylesheet full of defensive prefixes from 2019, most of them are merely dead weight, but the filter and backdrop-filter ones are live bugs — and they are invisible in source, invisible in the browser you happen to be testing, and invisible to your test suite. The only place they show up is the stylesheet the browser downloaded.
This is the pattern I ship in launch — live demo at launch.violettadev.com.