backdrop-filter 这个属性只有两种取值:none 或者一串滤镜函数。麻烦从来不在"怎么写",而在"为什么这次没生效"。这篇文章只讲属性本身——每个滤镜函数的参数与默认值、计算值长什么样、背后那块内容是怎么算出来的、以及三条会让它看起来"失效"的硬边界。
三年前的 Safari 还在用 -webkit-backdrop-filter,Firefox 要等到 103 才支持,如今它已经是 Baseline 里"新可用"的一员(MDN 标注 Baseline 2024,自 2024 年 9 月起跨最新浏览器版本可用)。所以现在值得把细节一次讲清楚。
本文所有数字都是在本机 headless Chromium 151.0.7922.34 上真跑出来的,量测方法写在第 15 节,示例代码可以直接复制。如果你想看的是"怎么把毛玻璃铺到整个网站、以及铺完之后文字对比度怎么保",那是另一篇(给网站换上毛玻璃效果);这篇不重复那部分,只在相关处给出链接。
一、先来一个能跑的最小示例
/* glass.css —— 外部样式表,class 驱动 */
.glass-panel {
/* 回退:不支持时用接近实色的底,保证文字可读 */
background-color: rgb(13 17 23 / 92%);
color: #f0f6fc;
border-radius: 12px;
}
@supports (backdrop-filter: blur(1px)) {
.glass-panel {
/* 必须留出半透明的部分,否则规范判定效果不可见 */
background-color: rgb(13 17 23 / 45%);
backdrop-filter: blur(10px) saturate(160%);
}
}<div class="glass-panel">面板内容</div>代码里三件事是刻意的:底色先给不透明的、@supports 里才降透明度;滤镜写成函数列表(blur() 加 saturate());样式全部走外部样式表——本站的 CSP 是 script-src/style-src 'self',内联样式和 style 属性会被拦掉,所以下面所有示例都按外部样式表的写法给。
二、语法与属性表
属性取值就两种:
backdrop-filter: none;
backdrop-filter: blur(4px) saturate(150%);
backdrop-filter: url("#svg-filter") blur(2px);规范给出的形式语法(Filter Effects Level 2 与 Level 1 一致):
backdrop-filter = none | <filter-value-list>
<filter-value-list> = [ <filter-function> | <url> ]+
<filter-function> = <blur()> | <brightness()> | <contrast()> | <drop-shadow()> |
<grayscale()> | <hue-rotate()> | <invert()> | <opacity()> |
<sepia()> | <saturate()>
<blur()> = blur( <length>? )
<brightness()> = brightness( [ <number> | <percentage> ]? )
<contrast()> = contrast( [ <number> | <percentage> ]? )
<drop-shadow()> = drop-shadow( [ <color>? && <length>{2,3} ] )
<grayscale()> = grayscale( [ <number> | <percentage> ]? )
<hue-rotate()> = hue-rotate( [ <angle> | <zero> ]? )
<invert()> = invert( [ <number> | <percentage> ]? )
<opacity()> = opacity( [ <number> | <percentage> ]? )
<sepia()> = sepia( [ <number> | <percentage> ]? )
<saturate()> = saturate( [ <number> | <percentage> ]? )其他几条属性信息:
| 项目 | 值 |
|---|---|
| 初始值 | none |
| 是否继承 | 否 |
| 计算值 | as specified(按指定值,见第六节) |
| 动画类型 | 滤镜函数列表(Filter Effects 1 §14) |
| 适用范围 | 所有元素;在 SVG 中适用于除 <defs> 外的容器元素与所有图形元素 |
<filter-value-list> 里可以出现 url(),引用一个 SVG 滤镜(<filter> 元素)。它和 filter 属性用的是同一套取值语法,但两者的作用对象完全不同:filter 过滤的是元素自己(含后代)画出来的像素,backdrop-filter 过滤的是元素背后已经被画出来的内容。动画一个元素的时候前者会连内容一起变形,后者只动"背景玻璃"。
三、十个滤镜函数逐个拆解
十个函数都在 Filter Effects Level 1 §6.1 里有定义,backdrop-filter 直接复用。参数写法统一,但几个默认值上的差异是最容易写错的地方。
| 函数 | 参数 | 省略参数时 | 关键行为 |
|---|---|---|---|
blur() |
<length>(不接受百分比) |
0px |
参数是高斯函数的标准差 σ,不是 box-shadow 的模糊半径;负值不允许 |
brightness() |
<number> 或 <percentage> |
1 |
线性乘数;0% 全黑,100% 不变,可大于 100% |
contrast() |
同上 | 1 |
0% 全灰,100% 不变,可大于 100% |
drop-shadow() |
[<color>? && <length>{2,3}] |
缺省长度为 0,缺省颜色取自 color |
第三个长度是标准差;不收 spread,也不收多重阴影 |
grayscale() |
同上 | 省略时 1 |
100% 全灰;超过 100% 被实现钳到 1 |
hue-rotate() |
<angle> 或 <zero> |
0deg |
实现不得归一化该值,以便支持超过 360deg 的动画 |
invert() |
<number> 或 <percentage> |
省略时 1 |
100% 完全反相 |
opacity() |
同上 | 1 |
把输入样本乘上该值;>100% 被钳到 1 |
saturate() |
同上 | 1 |
0% 完全不饱和,可大于 100% 得到超饱和 |
sepia() |
同上 | 省略时 1 |
100% 完全棕褐 |
四个细节值得单独说:
blur() 的参数是 σ,不是半径。 规范专门写了一句提示:Standard deviation is different to box-shadow's blur radius. 高斯模糊理论上范围无限,实现里都用有限区域近似——规范注释说,主流实现在写作时(2024 年 1 月)都用"三遍盒式模糊"近似,其影响范围是 (3 * sqrt(2 * π) / 4) * σ,约等于 1.88σ。所以 blur(12px) 大约会影响面板边缘外 22px 的范围,这个数值在做密集排布时有用。
grayscale() / sepia() / invert() 的"省略即 1"是给作者方便的。 规范原文区分了两个概念:这三个函数省略参数时默认取 1(也就是"应用 100% 效果"),而它们插值时的初始值是 0("无效果")。也就是说 grayscale() 等于 grayscale(100%),而在动画补间里它的起点是 0 而不是 1 —— 这个区别在写关键帧动画时会直接决定方向。
opacity() 不是 opacity 属性的简写。 规范明确指出这一点,并提醒:如果它出现在滤镜列表的最后,元素自身的 opacity 会再乘一次,结果可能比预期更透明。写 .glass { opacity: .8; backdrop-filter: opacity(50%) } 得到的是两层相乘。
drop-shadow() 的第三个数是标准差。 和 box-shadow 的"第三个数是模糊半径"不是一个意思,而且它只接受 2 到 3 个长度值,没有 spread、也没有多重阴影。
十二种写法放在同一张背景图上长这样(面板底色统一是 rgb(255 255 255 / 18%),只有滤镜不同):

图 1:none、blur(6px)、brightness(140%)、contrast(160%)、drop-shadow(0 6px 10px #000)、grayscale(100%)、hue-rotate(120deg)、invert(100%)、opacity(40%)、saturate(200%)、sepia(100%)、blur(8px) saturate(180%)。
四、三条硬边界
规范 §2.1 的注释只有一句关键话:除非元素的某个部分是半透明的,否则看不出 backdrop-filter 的效果。 加上"背后得有东西"和"别被祖先截断",合起来是三条边界。
4.1 元素本身要有半透明的部分
我把两套完全相同的面板叠在同一位置(一层有滤镜一层没有),逐帧切换 visibility 后再逐像素比较——背景完全相同,差值只可能来自 backdrop-filter:
| 场景(面板尺寸、位置完全一致) | 平均绝对像素差 | 最大像素差 |
|---|---|---|
| 对照组:两层都无滤镜 | 0.000 | 0 |
不透明底 + blur(12px) |
0.000 | 3 |
半透明底(rgb(255 255 255 / 18%))+ blur(12px) |
20.762 | 157 |
透明底 + blur(12px) |
37.097 | 164 |
不透明底那一行的平均差是 0.000,最大 2~3 是边缘抗锯齿的噪声——滤镜确实执行了,但你看不见任何变化。规范里给了对应的写法:想要"完整透出过滤后的背景",用 background-color: transparent;那只剩纯滤镜,没有底色的遮挡,这也解释了为什么透明底那行差值最大。
4.2 背后要有内容
把面板放在一块纯色(#1f6feb)上面,blur(12px) 与无滤镜的平均差是 0.090(最大 2)。模糊一片纯色得到的还是同一片颜色。这条在真实项目里对应两种情况:面板背后是纯色页面背景,或者背景图在这个位置恰好是单色。要么给底色多留一点透明度让内容透过来,要么这个位置就别放毛玻璃。
4.3 不能被祖先截断
backdrop-filter 能"看到"的内容,从最近的 backdrop root 祖先起算——规范 §3 用七个步骤定义 Backdrop Root Image:从最近的 backdrop root 元素开始,把它和当前元素之间按绘制顺序画出的内容展平进一个 2D 缓冲,再按当前元素的 border box 裁剪。所以只要中间某个祖先成了 backdrop root,模糊范围就被截在那个祖先内部。
哪些东西会成为 backdrop root:文档根元素、filter 非 none、opacity 小于 1、mask/mask-image/mask-border/clip-path 非 none、backdrop-filter 非 none、mix-blend-mode 非 normal,以及 will-change 指定了上面任意一个属性。
我把同一种面板放进四种父元素里,父元素都放在同一块棋盘格背景上,结果一目了然:
| 父元素 | 平均绝对像素差 | 最大像素差 |
|---|---|---|
will-change: opacity |
0.000 | 0 |
opacity: .99 |
0.000 | 0 |
filter: blur(0px) |
0.000 | 0 |
| 普通父元素(对照组) | 24.890 | 27 |
前三种父元素都成了 backdrop root,子元素能模糊的只剩"父元素自己画出来的那点内容",在这组实验里父元素没有背景,于是差值精确地是 0;普通父元素那一组能看到页面棋盘格,差值 24.890。注意 opacity: .99 和 filter: blur(0px) 这种"看起来什么都没做"的值同样会截断——它们不是"写了等于没写"的无害值,而是实打实地创建了边界。
好消息是规范明确说了:z-index、fixed/sticky 定位、transform 都不会形成 backdrop root。这比 stacking context 的触发条件宽松得多,所以外层容器用 transform 做位移动画、内部放毛玻璃面板,是允许的。
三个底色状态和两组父元素的直观对比:

图 2:上半部分四块面板只有透明度与是否带 blur() 不同;下半部分左右两组结构完全相同,唯一差别是左边父元素带 will-change: opacity,圆内棋盘格因此保持清晰,而右边能看到并模糊页面背景。
五、背后那块内容是怎么算出来的
规范 §2.1 把渲染过程拆成七步,理解它就能预判大多数"效果不对":
- 把 Backdrop Root Image 复制进一个临时缓冲 T′;
- 对 T′ 的全部内容应用滤镜函数(列表里第一个函数拿 Backdrop Root Image 当输入,后续函数拿前一个的输出);
- 如果元素和 backdrop root 之间有
transform,对 T′ 应用这些变换的逆; - 用元素的 border box 裁剪 T′,包含
border-radius;这一步不考虑子元素的大小和位置; - 把元素自身(背景、边框、全部子元素)画进 T′;
- 如果元素自身还有变换、效果或裁剪,应用它们;
- 用 source-over 把 T′ 合成进父级。
两个可直接利用的推论:
- 第 4 步意味着圆角会被正确尊重:滤镜结果只出现在 border box 内、按
border-radius裁切,不会在圆角外漏出一个直角方块。 - 滤镜列表里含
blur()时,规范要求以edgeMode="mirror"应用,边界由"裁剪并变换后的元素 border box"定义。所以毛玻璃面板的边缘不会像filter: blur()那样向透明淡出——它没有可淡出的外部区域,边缘是镜像填充的。
六、计算值长什么样(调试时很有用)
规范 §6.2 说,<filter-function> 里的值按指定值计算,但有两个例外:省略的值会被补上并计算成默认值;drop-shadow() 先把 <color> 的计算值放前面再跟长度。
我在页面上写了十个"故意省略参数"的元素,getComputedStyle() 读回来是:
| 写的 | 计算值 |
|---|---|
blur() |
blur(0px) |
grayscale() |
grayscale(1) |
sepia() |
sepia(1) |
invert() |
invert(1) |
brightness() |
brightness(1) |
hue-rotate() |
hue-rotate(0deg) |
contrast() |
contrast(1) |
saturate() |
saturate(1) |
opacity() |
opacity(1) |
drop-shadow() |
none |
前九行的规律就是第三、四节讲的默认值。表格里唯一落空的是 drop-shadow():它的语法要求至少两个长度值,所以空参数的 drop-shadow() 整个声明非法,属性回落到初始值 none——不是"投影变成 0 偏移",而是没有滤镜。
另外,百分比会被序列化成数字。写 backdrop-filter: brightness(140%) saturate(180%) hue-rotate(0),读回来是:
brightness(1.4) saturate(1.8) hue-rotate(0deg)这解释了一个常见的困惑:用 JS 比对计算值和写的字符串永远不相等——saturate(180%) 和 saturate(1.8) 是同一个东西的两种写法。要判断"浏览器到底认不认",用 CSS.supports() 或者读计算值再解析,别做字符串比较。
七、顺带改变的两件事
规范 §2.1 有一句容易被忽略的话:计算值不是 none 时,会创建 stacking context,并为 absolute/fixed 定位的后代创建包含块(元素本身是文档根元素时除外),行为与 filter 属性一致。
这不是理论问题。同一个 position: fixed 的子元素,在普通容器里和带 backdrop-filter 的容器里位置完全不同(视口 1300×900):
| 普通容器 | 容器带 backdrop-filter: blur(6px) |
|
|---|---|---|
| 容器位置 | x=40, y=357.3 | x=358, y=357.3 |
fixed 子元素位置 |
x=1206.9, y=4(视口右上角) | x=563.9, y=362.3(容器内右上角) |
子元素 position 计算值 |
fixed |
fixed |
position 的计算值还是 fixed,但包含块换了:左边那个跑到了整个视口右上角,右边那个贴在容器内部的右上角。

图 3:两个容器的结构相同,各自的 position: fixed 子元素都标成黄色小标签「fixed 子元素」。左边普通容器的标签跑到了画面右上角,右边带 backdrop-filter 的标签留在了容器内部。
所以给工具栏、侧栏、模态层之外的普通容器加毛玻璃之前,先确认它内部没有依赖视口定位的子元素——下拉菜单、浮层、position: fixed 的提示条都属于高危对象。
八、嵌套,以及用 url() 引 SVG 滤镜
嵌套:外层已经有 backdrop-filter 时,它自己就成了内层的 backdrop root。我在一个带 blur(12px) 的外层里再放一块带 blur(12px) 的内层,与"内层不写滤镜"相比:
| 场景 | 平均绝对像素差 | 最大像素差 |
|---|---|---|
外层 blur(12px),内层再 blur(12px) |
14.897 | 42 |
内层不是完全无效——它拿到的是外层已经模糊过的画面,再模糊一次仍然会继续抹掉细节。但代价是第二遍渲染,而收益远小于第一层(对比第 4.1 节里单层模糊的 20.762/37.097 这个量级)。结论很直接:嵌套毛玻璃要当成"额外的渲染开销"来算,别当成免费的分层效果。
url():<filter-value-list> 允许引用 SVG 滤镜,这条是真能用的。我用一个 <feComponentTransfer> 做反相,写成 backdrop-filter: url("#svg-invert") blur(6px),与无滤镜相比平均差 95.743、最大 208 —— 滤镜和模糊都生效了。写法上 <filter> 可以直接内联在页面里(用 id 引用),也可以是同源的 SVG 文件。要注意的是 SVG 滤镜自己有一套颜色空间设定(见下一节)。
<svg width="0" height="0" aria-hidden="true">
<filter id="svg-invert" color-interpolation-filters="sRGB">
<feComponentTransfer>
<feFuncR type="table" tableValues="1 0"/>
<feFuncG type="table" tableValues="1 0"/>
<feFuncB type="table" tableValues="1 0"/>
</feComponentTransfer>
</filter>
</svg>九、颜色空间,以及 hue-rotate() 的真相
Filter Effects 1 里有一句很硬的话:color-interpolation-filters has no affect for Filter Functions. Filter Functions must operate in the sRGB color space.(color-interpolation-filters 对滤镜函数无效,滤镜函数必须在 sRGB 颜色空间里运算。)backdrop-filter 的滤镜函数同样如此。
而 SVG 滤镜原语的默认值不是 sRGB——color-interpolation-filters 的初始值是 linearRGB。所以同一个视觉效果,写成 backdrop-filter: contrast(150%) 和写成 backdrop-filter: url("#contrast-filter") 的数值结果可能不一样;后者的 <filter> 上要显式写 color-interpolation-filters="sRGB" 才能对齐(第八节的示例就是这么写的)。
hue-rotate() 更值得单独说:它不是把色相在 HSL 里转一圈,而是一个线性矩阵近似。 规范在 feColorMatrix 的 type="hueRotate" 里给出了矩阵,三行共用一组亮度系数 0.213 / 0.715 / 0.072:
a00 = 0.213 + cos(θ)*0.787 - sin(θ)*0.213
a01 = 0.715 - cos(θ)*0.715 - sin(θ)*0.715
a02 = 0.072 - cos(θ)*0.072 + sin(θ)*0.928
a10 = 0.213 - cos(θ)*0.213 + sin(θ)*0.143
a11 = 0.715 + cos(θ)*0.285 + sin(θ)*0.140
a12 = 0.072 - cos(θ)*0.072 - sin(θ)*0.283
a20 = 0.213 - cos(θ)*0.213 - sin(θ)*0.787
a21 = 0.715 - cos(θ)*0.715 + sin(θ)*0.715
a22 = 0.072 + cos(θ)*0.928 + sin(θ)*0.072拿一块纯红 #e01b1b 试一下就知道差距有多大。实测结果:
| R | G | B | |
|---|---|---|---|
原色 #e01b1b |
224 | 27 | 27 |
| 规范矩阵、θ=120deg(按上式计算) | 0 | 114 | 0 |
本机 Chromium 实测(hue-rotate(120deg),透明底) |
0 | 114 | 0 |
| 如果在 HSL 里把色相 +120° | 27 | 224 | 27 |
实测值和规范矩阵算出来的值逐通道一致(0/114/0),说明实现走的就是规范那条矩阵;而它和"真·色相旋转"差了十万八千里——纯红转 120° 之后是暗绿而不是亮绿,因为负值被钳到 0 之后亮度信息被丢掉了。设计上要"换个色相"时,别指望 hue-rotate() 等于在取色器里拖色相环;要做精确调色,用 filter: url() 或直接在源头上换颜色。
十、动画与过渡
backdrop-filter 的动画类型是"滤镜函数列表",逐函数插值规则(规范 §6.4):
blur()按长度插值;brightness()、contrast()、grayscale()、invert()、opacity()、saturate()、sepia()先把百分比换算成数字(0% 对应 0、100% 对应 1),再按数字插值;hue-rotate()按数字插值;drop-shadow()按阴影列表插值。
配合第三节那条"hue-rotate() 不得归一化",就能写出跨越 360° 的连续旋转:
/* hue-rotate.css */
.scan {
backdrop-filter: hue-rotate(0deg);
animation: scan 3s linear infinite;
}
@keyframes scan {
to { backdrop-filter: hue-rotate(720deg); }
}如果实现把 720deg 归一化成 0deg,这段动画就会纹丝不动;规范正是为了这种用法才禁止归一化。当然,动画一个毛玻璃表面意味着每一帧都要重新渲染它背后那块缓冲,第 11 节的代价会按帧数乘上去——页面级的大面积毛玻璃别这么玩。
十一、性能:规范自己怎么说
Filter Effects 2 §3.1 的动机一节把话说得很直白:如果把 backdrop-filter 定义成"过滤它之前绘制的一切",每次应用都需要单独的一次渲染,好把半成品 stacking context 临时补完;那会让渲染时间翻倍,并可能需要两倍的内存与 GPU 带宽来存放待过滤的中间纹理;而如果允许嵌套,这个翻倍会变成指数级的性能崩塌。
也就是:
- 每个毛玻璃元素都会让它背后的内容多渲染一份——数量是要算成本的;
- 嵌套会叠加,一层比一层贵;
- 规范没有给任何"一个页面能放几个"的阈值,任何具体数字都是站点自测结论,不是规范结论。
可操作的判断只有一条:固定不动的、面积可控的表面(工具栏、侧栏、底栏、弹层)比列表项划算得多。一个虚拟滚动列表里放 50 个毛玻璃卡片,等于让浏览器为一屏内容反复处理 50 份背景缓冲。真要做"每个卡片都像玻璃",用一张固定背景 + 半透明色块加边框的静态方案,视觉差别在下滑过程中几乎看不出来。
十二、兼容性与稳妥写法
MDN 的 browser-compat-data 给出的版本:
| 浏览器 | 支持情况 |
|---|---|
| Chrome | 76 起 |
| Edge | 无前缀 79 起;-webkit- 前缀 17 起、79 移除 |
| Firefox | 103 起;123 之前,在 GPU 厂商未知的系统上不支持(bug 1867937) |
| Safari | 无前缀 18 起;-webkit- 前缀 9 起 |
两个直接结论:
旧 Safari 必须带前缀。 这是 -webkit-backdrop-filter 至今仍有意义的唯一理由。写法是两条一起给(现代浏览器会忽略认不出的那条):
/* prefix.css */
.glass-toolbar {
-webkit-backdrop-filter: blur(14px) saturate(160%);
backdrop-filter: blur(14px) saturate(160%);
}新 Chromium 已经不认这个前缀了。 我在 151 上实测:CSS.supports('backdrop-filter', 'blur(1px)') 返回 true,CSS.supports('-webkit-backdrop-filter', 'blur(1px)') 返回 false;只写前缀的属性计算值是 none,像素差为 0.000。所以前缀是"发给老 Safari 的",不是"多写一条更保险"。
Firefox 123 之前那条 GPU 限制说明回退分支必须"可读"而不是"糊成一片":那类环境会直接命中你的回退路径。默认给不透明底色、@supports 通过后才降 alpha,这个顺序不能反。
十三、实战:工具栏 + 卡片 + 对话框
把上面几节拼起来就是一个可用界面。工具栏压在页面顶部、卡片排在正文里、对话框带一层模糊遮罩:
/* practical.css */
.toolbar {
position: sticky;
top: 0;
z-index: 10;
background-color: rgb(13 17 23 / 55%);
border-bottom: 1px solid rgb(255 255 255 / 12%);
backdrop-filter: blur(14px) saturate(160%);
}
.glass-card {
border-radius: 16px;
border: 1px solid rgb(255 255 255 / 14%);
background-color: rgb(13 17 23 / 55%);
backdrop-filter: blur(14px) saturate(160%);
box-shadow: 0 10px 30px rgb(0 0 0 / 35%);
}
/* 遮罩只模糊背后的页面,对话框本体用更实的底保证正文可读 */
.scrim {
background-color: rgb(6 9 13 / 35%);
backdrop-filter: blur(6px);
}
.dialog {
border-radius: 14px;
border: 1px solid rgb(255 255 255 / 16%);
background-color: rgb(13 17 23 / 72%);
backdrop-filter: blur(10px);
box-shadow: 0 18px 50px rgb(0 0 0 / 45%);
}三个数值不是随手写的:工具栏和卡片用 / 55% 的底——半透明但要够深,文字才能压在确定的范围里;对话框用 / 72%,因为弹层里的文字最需要读清楚;遮罩用 / 35% 加 blur(6px),只负责"把后面推远"。

图 4:同一个页面里三种用法。工具栏贴顶不遮挡内容、卡片承担正文、右下角的对话框放在局部模糊遮罩上;未被覆盖的背景保持清晰。
对话框那一处还有个细节值得留意:它在遮罩内部,而遮罩自己有 backdrop-filter,所以对话框自己的 blur(10px) 只能拿到遮罩内部的内容(第八节的嵌套情形)。视觉效果主要来自它更实的底色和阴影,模糊只是额外的柔化。想省掉一遍渲染,把对话框内层的 backdrop-filter 删掉是安全的。
至于文字对比度——半透明底意味着文字背后的颜色是不确定的,WCAG 2.2 对正文有 4.5:1(大号文本 3:1)的量化要求,这套核算方法在另一篇里逐条列过,这里只提醒一句:毛玻璃面板上的文字要按"背后最亮的情况"核算,别按截图里那一眼看着还行来定。
十四、落地清单
- [ ] 每个毛玻璃表面都有半透明部分(不透明底等于没加滤镜,实测像素差 0.000);
- [ ] 背后确实有内容(纯色背后实测差值 0.090,等于白做);
- [ ] 从元素往上检查祖先有没有
will-change: opacity、opacity < 1、filter、mask/clip-path、mix-blend-mode——它们都会截断模糊范围; - [ ] 容器内部有没有
position: fixed/absolute的子元素(浮层、下拉菜单),它们会改挂到这个容器上; - [ ] 嵌套毛玻璃按"额外的渲染开销"记账,能删就删;
- [ ] 列表项不要逐个加毛玻璃,改用"固定背景 + 半透明色块"的静态近似;
- [ ] 大面积表面不做逐帧动画,除非确认过动画期间的帧率;
- [ ] 默认给不透明可读底色,
@supports通过后才降 alpha;旧 Safari 补-webkit-前缀;不要只写前缀。
十五、这些数字是怎么来的
量测页面是自包含的:一张自己用 PIL 生成的彩色测试图(竖细线 + 色块 + 同心圆 + 灰阶条,明暗反差大,模糊/去色/反相一眼可见)、一个 index.html 放可视对比、一个 measures.html 放测量盒。测量手法是本文数字可靠的关键——同一个位置叠两层,一层有滤镜一层没有,逐帧切 visibility 再对比:
/* 关键:两层绝对定位重叠在同一位置 */
.stack { position: relative; width: 240px; height: 130px; }
.layer { position: absolute; inset: 0; border-radius: 10px; }
.layer.b { visibility: hidden; } /* 默认只显示带滤镜的 .a */# 逐帧切换后逐像素比较(Playwright + Pillow)
TOGGLE_JS = """
([id, which]) => {
const stack = document.getElementById(id);
for (const layer of stack.querySelectorAll('.layer')) {
layer.style.visibility = layer.classList.contains(which) ? 'visible' : 'hidden';
}
}
"""
def shoot_stack(page, stack_id):
out = {}
for which in ("a", "b"):
page.evaluate(TOGGLE_JS, [stack_id, which])
page.wait_for_timeout(50)
path = f"/tmp/shots/{stack_id}-{which}.png"
page.locator("#" + stack_id).screenshot(path=path)
img = Image.open(path).convert("RGB")
out[which] = img.crop((6, 6, img.width - 6, img.height - 6)) # 裁掉圆角抗锯齿
return out
def compare(a, b):
d = ImageChops.difference(a, b)
st = ImageStat.Stat(d)
return {"mean_abs": round(sum(st.mean) / 3, 3),
"max_abs": max(e[1] for e in d.getextrema())}同一个位置的对照组差值是 0.000 / 最大 0,证明这套方法本身没有噪声,剩下的差值只可能来自 backdrop-filter。环境是 headless Chromium 151.0.7922.34,device_scale_factor=2,视口 1300×900(页面 scrollWidth - innerWidth = 0,没有横向溢出)。临时服务只绑 127.0.0.1,脚本退出即关闭。
需要注意的是:这些数字是"某个浏览器某个版本"的实测,不是规范结论。 规范只规定行为(半透明才可见、backdrop root 会截断、模糊用 edgeMode="mirror"),具体像素差取决于渲染实现和背后的图。要复核,把你的页面用上面这段脚本跑一遍即可。
十六、参考来源
- W3C CSSWG, Filter Effects Module Level 2(§2 属性定义、§2.1 七步渲染流程与半透明条件、§3 Backdrop Root、§3.1 性能动机、§3.2 触发条件)— https://drafts.csswg.org/filter-effects-2/
- W3C CSSWG, Filter Effects Module Level 1(§5
filter属性、§6.1 十个滤镜函数的定义与默认值、§6.2 计算值、§6.3 序列化、§6.4 插值、feColorMatrix的hueRotate矩阵)— https://drafts.csswg.org/filter-effects-1/ - MDN, backdrop-filter(取值清单、backdrop root 列表、Baseline 2024 状态、
-webkit-用法)— https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter - MDN browser-compat-data, css/properties/backdrop-filter.json(Chrome 76 / Edge 79 与前缀 17–79 / Firefox 103 与 123 的 GPU 说明 / Safari 18 与前缀 9)— https://github.com/mdn/browser-compat-data/blob/main/css/properties/backdrop-filter.json
- MDN, <filter-function> 与 filter — https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function
- 本文实测环境:headless Chromium 151.0.7922.34,Pillow 12.3.0 做像素统计,Playwright 驱动截图