CSS 实现渐变毛玻璃:从 backdrop-filter 到多层 mask

CSS 实现渐变毛玻璃:从 backdrop-filter 到多层 mask

在很多现代网页中,底部工具栏、悬浮导航、弹窗和卡片并不是一块完全不透明的背景。它们会保留后方内容的颜色和轮廓,同时把细节柔化掉,看起来像一块半透明的磨砂玻璃。

普通毛玻璃的模糊强度是均匀的,整个区域看起来像盖了一层相同厚度的雾。渐变毛玻璃则不同:模糊会沿着某个方向逐渐增强或减弱,边界更柔和,也更适合做页面底部的过渡遮罩。

例如,一个固定在页面底部的渐变毛玻璃栏,可以让靠近内容区的位置仍然比较清晰,越接近底部越模糊。这样既能让内容自然地过渡到工具栏,又不会突然出现一条明显的白色分界线。

常见使用场景包括:

  • 页面底部固定操作栏、返回按钮或聊天输入区;
  • 顶部导航和悬浮工具栏;
  • 图片或渐变背景上的浮层卡片;
  • 需要弱化背景、但又不希望完全遮挡背景的弹窗;
  • 滚动内容与固定区域之间的视觉过渡。

这篇文章只讨论渐变毛玻璃,不讨论完整的液态玻璃材质。核心问题也很明确:如何让 backdrop-filter 的模糊效果沿一个方向平滑变化?

一、普通毛玻璃为什么不够

最常见的毛玻璃写法是:

css 复制代码
.glass {
  background: rgb(255 255 255 / 20%);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}

backdrop-filter 会处理元素背后的内容。blur(12px) 表示整个元素区域使用同一个模糊半径,因此它得到的是均匀的毛玻璃。

这种效果适合面积较小的卡片、弹窗和按钮。但如果它被用在一个高度较大的底部区域,就容易出现两个问题:

  1. 玻璃区域的顶部和底部模糊程度完全一样,层次感比较弱;
  2. 玻璃区域和页面内容之间像是硬切了一刀,过渡不够自然。

直觉上,我们可能会尝试这样写:

css 复制代码
.glass {
  backdrop-filter: blur(0) blur(12px);
}

但这并不会产生从 0px12px 的渐变。blur() 是滤镜函数,不是可以直接做插值的背景属性;同一个元素上的多个 blur() 也不是"前面小、后面大"的空间渐变。

要实现渐变毛玻璃,需要把问题拆成两部分:

  • 用不同元素提供不同强度的模糊;
  • 用蒙版控制每个模糊层在哪些位置显示。

二、渐变毛玻璃的基本原理

一个简单的渐变毛玻璃可以拆成下面几层:

text 复制代码
背景内容
    ↓
轻度模糊层:覆盖范围最大
    ↓
中度模糊层:覆盖范围变小
    ↓
重度模糊层:只覆盖目标区域
    ↓
内容和交互层

每个模糊层都使用 backdrop-filter,但模糊半径不同。例如:

css 复制代码
.layer-1 {
  backdrop-filter: blur(1px);
}

.layer-2 {
  backdrop-filter: blur(3px);
}

.layer-3 {
  backdrop-filter: blur(5px);
}

.layer-4 {
  backdrop-filter: blur(7px);
}

如果四层都完整显示,效果会变得过于厚重。于是再给每层加一个垂直渐变蒙版:

css 复制代码
.layer-1 {
  mask: linear-gradient(to bottom, #000 0 85%, transparent 100%);
}

.layer-2 {
  mask: linear-gradient(to bottom, #000 0 61%, transparent 81%);
}

.layer-3 {
  mask: linear-gradient(to bottom, #000 0 36%, transparent 56%);
}

.layer-4 {
  mask: linear-gradient(to bottom, #000 0 12%, transparent 32%);
}

这样一来,轻度模糊覆盖更大的范围,重度模糊只在局部出现,最终叠加出逐渐增强的模糊效果。

这里的 #000 不是黑色背景,而是蒙版中的"完全显示";transparent 表示"完全隐藏"。渐变中间的灰度区域则表示按透明度显示这一层。

所以,渐变毛玻璃并不是让一个 blur() 自身渐变,而是:

text 复制代码
不同 blur 半径的多层结果
        +
每层不同范围的 mask
        =
空间上逐渐变化的模糊效果

三、从 FrostedSurface 看多层接力

当前组件的实现正是这个思路。它准备了四组配置:

js 复制代码
const FROSTED_LAYER_CONFIG_LIST = [
    { blur: '1px', solidEnd: '85%', transparentEnd: '100%' },
    { blur: '3px', solidEnd: '61%', transparentEnd: '81%' },
    { blur: '5px', solidEnd: '36%', transparentEnd: '56%' },
    { blur: '7px', solidEnd: '12%', transparentEnd: '32%' },
];

每组配置包含两个信息:

  • blur:当前层的模糊半径;
  • solidEndtransparentEnd:当前层从完全显示过渡到完全透明的位置。

组件再把配置转换为类似下面的 CSS 变量:

css 复制代码
.frosted-layer {
  --blur: 5px;
  --mask: linear-gradient(
    to bottom,
    #000 0%,
    #000 36%,
    transparent 56%
  );

  backdrop-filter: blur(var(--blur));
  -webkit-backdrop-filter: blur(var(--blur));
  -webkit-mask: var(--mask);
  mask: var(--mask);
}

这种设计有一个重要特点:相邻层的蒙版过渡区是重叠的。

例如,第二层在 61%81% 之间淡出,第三层在 36%56% 之间淡出。各层不是在同一条位置突然切换,而是通过重叠的透明度变化逐步接力。

如果把每层的蒙版写成完全不重叠的区间,就可能出现明显的横向分界线:某一层刚消失,下一层还没有完全出现,画面会出现断层。

模糊方向

渐变毛玻璃可以向上渐变,也可以向下渐变。改变方向时,通常只需要改变蒙版的方向:

css 复制代码
/* 从上到下逐渐淡出这一层 */
mask: linear-gradient(to bottom, #000 0 60%, transparent 100%);

/* 从下到上逐渐淡出这一层 */
mask: linear-gradient(to top, #000 0 60%, transparent 100%);

不要为每个方向复制一套层配置。更好的做法是让方向成为一个独立参数,所有模糊层复用同一方向,只保留各层不同的模糊半径和停止位置。

四、一个可以直接运行的 HTML 示例

下面的示例包含完整的 HTML 和内嵌 CSS。把它复制到任意 HTML 文件中即可看到效果。页面背景使用了几个彩色圆形,方便观察玻璃后方内容是如何逐渐变模糊的。

完整 HTML

html 复制代码
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>渐变毛玻璃 Demo</title>
    <style>
      :root {
        color: #f7f9ff;
        background: #11182b;
        font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      * {
        box-sizing: border-box;
      }

      html {
        min-height: 100%;
        background: #11182b;
      }

      body {
        min-width: 320px;
        min-height: 100vh;
        margin: 0;
      }

      .demo-page {
        position: relative;
        min-height: 1800px;
        overflow: hidden;
        padding: 72px clamp(24px, 7vw, 120px) 420px;
        background:
          linear-gradient(rgb(255 255 255 / 7%) 1px, transparent 1px),
          linear-gradient(90deg, rgb(255 255 255 / 7%) 1px, transparent 1px),
          radial-gradient(circle at 16% 12%, #fa558d 0 7%, transparent 23%),
          radial-gradient(circle at 85% 33%, #407eff 0 8%, transparent 24%),
          radial-gradient(circle at 42% 78%, #ffbd4a 0 7%, transparent 22%),
          linear-gradient(135deg, #10162b, #273965 52%, #10182d);
        background-size: 48px 48px, 48px 48px, auto, auto, auto, auto;
      }

      .demo-header {
        position: relative;
        z-index: 1;
        max-width: 720px;
      }

      .demo-kicker {
        margin: 0 0 18px;
        color: #96b9ff;
        font-size: 12px;
        font-weight: 700;
        letter-spacing: .18em;
      }

      h1 {
        max-width: 680px;
        margin: 0;
        font-size: clamp(48px, 9vw, 108px);
        letter-spacing: -.06em;
        line-height: .92;
      }

      .demo-header p {
        max-width: 560px;
        margin: 28px 0 0;
        color: rgb(247 249 255 / 70%);
        font-size: 18px;
        line-height: 1.7;
      }

      .background-content {
        position: relative;
        z-index: 1;
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 18px;
        margin-top: 180px;
      }

      .background-card {
        min-height: 220px;
        padding: 24px;
        border: 1px solid rgb(255 255 255 / 18%);
        border-radius: 20px;
        background: rgb(255 255 255 / 10%);
      }

      .background-card strong {
        display: block;
        margin-bottom: 54px;
        color: rgb(255 255 255 / 88%);
        font-size: 42px;
        line-height: 1;
      }

      .background-card span {
        color: rgb(255 255 255 / 64%);
        font-size: 14px;
        line-height: 1.5;
      }

      .background-line {
        position: absolute;
        top: 620px;
        right: 8%;
        left: 8%;
        height: 1px;
        background: rgb(255 255 255 / 54%);
        transform: rotate(-8deg);
      }

      .frosted-footer {
        position: fixed;
        right: 0;
        bottom: 0;
        left: 0;
        z-index: 10;
        height: 280px;
        overflow: hidden;
        border-top: 1px solid rgb(255 255 255 / 58%);
        background: rgb(255 255 255 / 5%);
        isolation: isolate;
        pointer-events: none;
      }

      .frosted-layer {
        position: absolute;
        inset: 0;
        background: rgb(255 255 255 / 5%);
        pointer-events: none;
        -webkit-backdrop-filter: blur(var(--blur));
        backdrop-filter: blur(var(--blur));
        -webkit-mask: var(--mask);
        mask: var(--mask);
      }

      /* 与 FrostedSurface 的 frosted-down 方向一致:越靠近底部越模糊。 */
      .frosted-layer-1 {
        --blur: 1px;
        --mask: linear-gradient(to top, #000 0%, #000 85%, transparent 100%);
      }

      .frosted-layer-2 {
        --blur: 3px;
        --mask: linear-gradient(to top, #000 0%, #000 61%, transparent 81%);
      }

      .frosted-layer-3 {
        --blur: 5px;
        --mask: linear-gradient(to top, #000 0%, #000 36%, transparent 56%);
      }

      .frosted-layer-4 {
        --blur: 7px;
        --mask: linear-gradient(to top, #000 0%, #000 12%, transparent 32%);
      }

      .footer-content {
        position: relative;
        z-index: 1;
        display: flex;
        align-items: flex-end;
        justify-content: space-between;
        height: 100%;
        padding: 32px clamp(24px, 7vw, 120px);
      }

      .footer-content h2 {
        margin: 0 0 8px;
        font-size: clamp(24px, 4vw, 44px);
        letter-spacing: -.04em;
      }

      .footer-content p {
        max-width: 440px;
        margin: 0;
        color: rgb(247 249 255 / 68%);
        line-height: 1.6;
      }

      .gradient-guide {
        display: flex;
        align-items: center;
        gap: 12px;
        color: rgb(247 249 255 / 68%);
        font-size: 12px;
        white-space: nowrap;
      }

      .gradient-guide::before {
        display: block;
        width: 110px;
        height: 8px;
        border-radius: 999px;
        background: linear-gradient(to right, rgb(255 255 255 / 12%), rgb(255 255 255 / 88%));
        content: "";
      }

      @media (max-width: 720px) {
        .demo-page {
          min-height: 1500px;
          padding-top: 48px;
        }

        .background-content {
          grid-template-columns: 1fr;
          margin-top: 100px;
        }

        .background-card {
          min-height: 150px;
        }

        .background-card strong {
          margin-bottom: 24px;
        }

        .frosted-footer {
          height: 240px;
        }

        .footer-content {
          display: block;
          padding-top: 96px;
        }

        .gradient-guide {
          margin-top: 22px;
        }
      }
    </style>
  </head>
  <body>
    <main class="demo-page">
      <header class="demo-header">
        <p class="demo-kicker">BACKDROP FILTER / MASK</p>
        <h1>清晰的背景,逐渐变模糊。</h1>
        <p>
          向下滚动页面,观察底部固定区域:越靠近顶部,背后的网格、文字和卡片越清晰;
          越靠近底部,模糊层越多,背景细节越柔和。
        </p>
      </header>

      <section class="background-content" aria-label="用于观察模糊效果的背景内容">
        <article class="background-card">
          <strong>01</strong>
          <span>高对比度文字与网格能够清楚显示 backdrop-filter 的模糊变化。</span>
        </article>
        <article class="background-card">
          <strong>02</strong>
          <span>每一层使用不同的 blur 半径,再由 mask 控制它的显示范围。</span>
        </article>
        <article class="background-card">
          <strong>03</strong>
          <span>相邻蒙版的透明过渡区域重叠,避免出现突兀的分界线。</span>
        </article>
      </section>

      <div class="background-line" aria-hidden="true"></div>

      <aside class="frosted-footer" aria-label="渐变毛玻璃示例">
        <div class="frosted-layer frosted-layer-1" aria-hidden="true"></div>
        <div class="frosted-layer frosted-layer-2" aria-hidden="true"></div>
        <div class="frosted-layer frosted-layer-3" aria-hidden="true"></div>
        <div class="frosted-layer frosted-layer-4" aria-hidden="true"></div>

        <div class="footer-content">
          <div>
            <h2>渐变毛玻璃</h2>
            <p>1px / 3px / 5px / 7px 四层模糊,沿底部方向逐步增强。</p>
          </div>
          <div class="gradient-guide">清晰 → 模糊</div>
        </div>
      </aside>
    </main>
  </body>
</html>

如果想让下方更模糊、上方更清晰,可以把层配置整体反过来,或者把 to bottom 改成 to top。关键是让更大的模糊半径只出现在需要增强模糊的区域。

五、为什么推荐多层,而不是一层渐变背景

有些实现会写成这样:

css 复制代码
.glass {
  background: linear-gradient(
    to bottom,
    rgb(255 255 255 / 4%),
    rgb(255 255 255 / 70%)
  );
  backdrop-filter: blur(12px);
}

这段代码可以产生"颜色渐变",但不能产生"模糊强度渐变"。整个元素仍然使用同一个 blur(12px),变化的只是白色覆盖层的透明度。

颜色渐变有时已经够用,但它和真正的渐变毛玻璃有明显区别:

实现方式 变化的是什么 背景细节的模糊程度
渐变背景 + 单层 blur 覆盖色的透明度 始终相同
多层 blur + mask 每层模糊结果的可见范围 沿方向变化

如果需求只是让区域下方颜色更白,第一种写法更简单;如果需要让背景细节也从清晰逐渐变模糊,就需要多层 backdrop-filter

六、mask-imagebackdrop-filter 容易踩的坑

1. mask 不是背景色

css 复制代码
.layer {
  mask-image: linear-gradient(to bottom, #000, transparent);
}

这里的黑色不会显示在页面上。mask 做的是透明度裁剪:黑色区域显示元素,透明区域隐藏元素,灰色区域半透明显示元素。

因此,单独给一个空元素加 mask-image,通常看不到任何东西。需要先确认这个元素本身确实有可见内容,例如 backdrop-filter 或半透明背景。

2. mask-imagemask 的关系

mask 是一个 shorthand,除了图像外,还可以统一描述遮罩的位置、大小、重复方式等属性。简单场景可以写 mask-image,但在需要兼容 Safari 或维护多个相关属性时,推荐成对写:

css 复制代码
.layer {
  -webkit-mask: var(--mask);
  mask: var(--mask);
}

这里的 -webkit-mask 不是为了替代标准属性,而是为了覆盖部分 WebKit 内核环境。两者最好使用同一份变量,避免前缀版本和标准版本的渐变参数不一致。

3. 不要把 mask-composite 当成渐变必需品

mask-composite 用来规定多个蒙版之间如何合成,例如相加、相减或相交。

本文的渐变毛玻璃只需要每个模糊层使用一个线性渐变蒙版,因此不需要 mask-composite。只有在做镂空、边框蒙版或多个遮罩相减时,才需要引入它。

不必要地加入 mask-composite 会增加浏览器差异,尤其是不同内核对组合关键字和前缀的支持并不完全一致。

4. backdrop-filter 处理的是背后的内容

backdrop-filter 处理的是元素背后的区域,不是当前元素中的文字和子元素。

因此,下面的元素需要是半透明的:

css 复制代码
.glass {
  background: rgb(255 255 255 / 12%);
  backdrop-filter: blur(12px);
}

如果写成 background: #fff,背景内容会被不透明白色盖住,模糊效果就很难观察到。调试时可以先使用非常低的透明度,例如 rgb(255 255 255 / 8%)

5. 圆角和溢出裁剪要放在外层

渐变模糊层一般会使用绝对定位铺满容器:

css 复制代码
.frosted-panel {
  position: relative;
  overflow: hidden;
  border-radius: 28px;
}

.frosted-layer {
  position: absolute;
  inset: 0;
}

外层的 overflow: hidden 负责裁剪模糊层,border-radius 负责让内部效果跟随容器圆角。否则模糊可能超出卡片边界,四角出现不规则的矩形区域。

如果页面中还有其他定位层,建议让玻璃容器建立独立层叠上下文:

css 复制代码
.frosted-panel {
  isolation: isolate;
}

isolation 可以减少玻璃装饰层和外部元素发生意外混合的问题,但它不能替代对 z-index 和 DOM 层级的检查。

七、如何调节渐变效果

渐变毛玻璃主要调四类参数。

模糊半径

css 复制代码
--blur: 1px;
--blur: 3px;
--blur: 7px;

模糊半径差距太小,视觉上不容易看出渐变;差距太大,又容易出现层次断裂。通常可以从 1px / 3px / 5px / 7px 这类小步进开始,再根据背景对比度调整。

实色区位置

css 复制代码
linear-gradient(to bottom, #000 0 60%, transparent 100%);

60% 之前是完全显示区域。这个值越大,该层影响的范围越大。

透明过渡区

css 复制代码
linear-gradient(to bottom, #000 0 60%, transparent 85%);

60%85% 是过渡区。过渡区太短会显得生硬,太长则会让多层效果变得过于平均。相邻层的过渡区应该有一定重叠。

渐变方向

css 复制代码
to bottom
to top

底部固定栏通常会让模糊从内容区域向底部增强;顶部导航则可能需要反方向。不要直接照搬停止点,要根据组件所在的位置决定方向。

八、性能和兼容性

1. 模糊层越多,成本越高

多层方案的代价是需要多次处理背景。四层可以提供比较平滑的过渡,但并不意味着所有场景都应该固定使用四层。

如果容器面积大、页面滚动频繁,或者运行在性能较弱的移动 WebView 中,可以降为一层:

css 复制代码
.frosted-panel--simple {
  background: rgb(255 255 255 / 22%);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}

.frosted-panel--simple .frosted-layer {
  display: none;
}

2. 提供不支持时的背景

不支持 backdrop-filter 的浏览器至少应该有一个可读的背景:

css 复制代码
.frosted-panel {
  background: rgb(238 244 255 / 92%);
}

@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .frosted-panel {
    background: rgb(255 255 255 / 8%);
  }
}

这样即使模糊没有生效,文字和按钮也不会直接落在复杂背景上。

3. 不要只用桌面浏览器截图判断性能

静态截图只能说明效果是否大致正确,不能说明滚动时是否流畅。实际验证时至少要关注:

  • 固定玻璃层是否覆盖了过大的区域;
  • 滚动时是否出现明显掉帧;
  • Safari、移动浏览器和目标 WebView 是否都能看到模糊;
  • 不支持 backdrop-filter 时文字是否仍然清晰。

九、排查顺序

如果渐变毛玻璃没有效果,可以按这个顺序检查:

  1. 玻璃层后面是否有明显的彩色或高对比度内容;
  2. 玻璃层是否使用了半透明背景,而不是完全不透明的背景;
  3. 是否同时写了 backdrop-filter-webkit-backdrop-filter
  4. 是否同时写了 mask-webkit-mask
  5. 临时删除蒙版后,单层模糊是否可以正常显示;
  6. 外层是否设置了正确的尺寸、圆角和 overflow: hidden
  7. 各层的蒙版过渡区是否有重叠;
  8. 最后再检查 z-index、层叠上下文和浏览器支持情况。

调试时不要一开始同时修改模糊半径、透明度、蒙版方向和层级。先验证单层 backdrop-filter,再加 mask,最后增加其他模糊层,问题会更容易定位。

十、总结

渐变毛玻璃的重点,不是找到一个"神奇的 CSS 属性",而是把一个模糊效果拆成多个强度不同的层:

  • backdrop-filter 负责把背景内容模糊;
  • mask 负责控制每一层在哪些区域可见;
  • 多层不同的 blur 半径负责形成强度差异;
  • 重叠的渐变蒙版负责让层与层之间平滑接力;
  • 半透明背景、圆角和降级背景负责保证最终可用性。

如果只是需要一个普通的半透明卡片,一层 backdrop-filter 就足够了;如果需要让固定区域和页面内容自然过渡,再考虑多层模糊与蒙版。

最实用的经验是:先用两层或三层做出方向正确的效果,再根据背景复杂度调整模糊半径和蒙版停止点。层数越多不一定越好,真正影响观感的是层之间是否连续,以及页面在真实设备上是否仍然流畅。

相关推荐
程序员黑豆1 小时前
鸿蒙开发实战:使用 List 组件构建新闻列表
前端·华为·harmonyos
xiaobaoyu2 小时前
谈谈你对iframe的了解
前端
爱勇宝2 小时前
我做了一个排版器,也踩了一遍富文本复制的坑
前端·后端·微信
铁皮饭盒2 小时前
面试官:如何用 Bun + JS 实现安全的文件 MCP 工具集
前端·javascript·后端
探索前端3 小时前
Cesium图层加载及影像服务添加
前端·cesium
阿懂在掘金3 小时前
Vue 弹窗新范式——代码减少、复用翻倍与 AI 时代的前端基建
前端·设计模式·前端框架
千纸鹤安安3 小时前
Compose Runtime → KuiklyUI:Applier 搭起的渲染桥梁
前端
李明卫杭州3 小时前
mise 管理工具详解
前端·后端
颜进强3 小时前
从零搭建私人 RAG 实战:让 Claude Code 按需查询知识库
前端·后端·ai编程