高级阴影与模糊效果

张玥 2026年9月18日 阅读时间 40分钟
CSS阴影 模糊效果 毛玻璃 视觉层次 渲染性能
CSS高级技巧视觉效果之高级阴影与模糊效果

阴影与模糊,是界面从“能看”走向“好看”的分水岭。它们让扁平的矩形拥有了厚度、层次与空间感,也让一个普通的卡片在鼠标悬停的瞬间拥有了呼吸。但与此同时,它们也是浏览器绘制流水线里最昂贵的两种视觉效果:一次大面积的高斯模糊,可能吃掉整帧的预算;一串随手写的 box-shadow,可能让滚动列表在低端机上卡成幻灯片。高级阴影与模糊的核心,不是“加多少效果”,而是“在正确的层次上、用正确的属性、施加恰到好处的效果”。这篇 40 分钟的长文,从 box-shadow 的六个参数讲到分层阴影、物理光源、内阴影浮雕,再穿过 filter: blur()、backdrop-filter 毛玻璃、氛围光斑、暗色模式阴影策略与 DevTools 调试,配合五个可实时操作的实验台,帮你把阴影与模糊从“会用”升级到“用得好、用得快、用得对”。

阴影与模糊的本质:它们是“绘制”,不是“布局”

要理解阴影与模糊的性能特征,先要回到浏览器的渲染流水线。一条完整的流水线是:Style → Layout → Paint → Composite。而阴影与模糊,几乎全部发生在 Paint(绘制) 阶段。

这意味着两件事:

  • 好消息:改变 box-shadow、text-shadow、filter: blur() 都不会触发 Layout,因此不会引起兄弟节点与祖先节点的连锁重排。
  • 坏消息:它们会触发 Paint。而 Paint 的耗时与绘制面积成正比——一个覆盖整屏的模糊层,和一个小按钮的阴影,代价可能相差几百倍。

很多开发者误以为“阴影不占性能”,因为在小卡片上确实感觉不到。但当阴影铺满整屏、或者模糊半径达到 60px 以上、或者被放进一个长列表里循环出现时,Paint 的开销就会像滚雪球一样膨胀。

/* 三种视觉效果触发的流水线阶段 */

/* ① 阴影:Style → Paint → Composite */
.card {
  box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
/* 跳过 Layout,但整块阴影区域都要重新绘制 */

/* ② 模糊:Style → Paint → Composite */
.blurred {
  filter: blur(12px);
}
/* 高斯模糊是卷积运算,半径越大、面积越大越慢 */

/* ③ 毛玻璃:Style → Paint → Composite */
.glass {
  backdrop-filter: blur(16px);
}
/* 代价 = 背后的全部内容 × 模糊半径 */

/* 对比:transform 只走 Composite */
.moved {
  transform: translateY(-8px);
}
/* GPU 只改一个矩阵,几乎不花时间 */
最便宜 transform / opacity —— 只走合成
中等 小面积 box-shadow / text-shadow
昂贵 大半径 filter: blur() / backdrop-filter
关键结论 阴影与模糊不是不能用,而是不能“无脑大面积地用”

高斯模糊到底贵在哪里

浏览器实现 blur(radius) 时,本质上是在每个像素周围取一个半径为 3 × radius 的采样核,做加权平均。也就是说:

  • 半径翻倍,采样量翻四倍:因为面积是半径的平方。
  • 元素面积翻倍,总耗时翻倍:绘制像素数线性增长。
  • 元素数量翻倍,总耗时翻倍:每个模糊元素都是一次独立的绘制任务。

所以一个 800×600 区域上的 blur(40px),其计算量可能是一个 200×150 区域上 blur(8px) 的几十倍。这就是为什么“磨砂背景”做得太狠,页面立刻就会发烫。

box-shadow 语法全解:六个参数各自在做什么

box-shadow 的完整语法是:

box-shadow: [inset] offset-x offset-y blur-radius spread-radius color;

六个部分缺一不可(inset 可选),每一个都直接决定视觉结果:

参数 作用 常见取值 视觉语义
offset-x 水平偏移 -40px ~ 40px 正值阴影右移,负值左移
offset-y 垂直偏移 -40px ~ 40px 正值阴影下移(光源在上方)
blur-radius 模糊半径 0 ~ 80px 越大越柔和,也越贵
spread-radius 扩散半径 -20px ~ 40px 正值阴影膨胀,负值收缩
color 阴影颜色 rgba / hsl 永远不要用纯黑,带一点主色调更自然
inset 内阴影 关键字 阴影画在元素内部,产生凹陷感

一个最容易犯的错误:用纯黑做阴影

很多人的第一反应是 box-shadow: 0 4px 8px #000;,结果阴影看起来又脏又硬,像贴了一张黑色纸片。真实世界的阴影从来不是纯黑的,它会被环境光染色。

生硬的阴影
box-shadow: 0 6px 12px #000000;
纯黑、无透明度、无色彩倾向。在白色背景上会形成一道突兀的黑边。
自然的阴影
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
用深蓝灰代替纯黑,透明度控制在 8%~18%,阴影立刻变得透气。

颜色的三种常用写法

/* ① 中性灰蓝:通用场景,最保险 */
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);

/* ② 品牌色阴影:让按钮“发光”,增强主色识别 */
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);

/* ③ 同色系深浅:卡片自身的颜色加深后作为阴影 */
/* 例如卡片是 #10b981,阴影用 hsl(160 84% 39% / 0.35) */
box-shadow: 0 10px 24px hsl(160 84% 39% / 0.35);

/* 用 CSS 变量统一管理,暗色模式可一键切换 */
:root {
  --shadow-color: 15 23 42; /* 只有 RGB 分量 */
}
.card {
  box-shadow: 0 8px 24px rgb(var(--shadow-color) / 0.12);
}

把颜色拆成“RGB 分量变量 + 透明度”是工程上最推荐的写法。这样在暗色模式下只需要改一个变量,整站的阴影风格就能统一切换,而不必逐条修改几十处 box-shadow。

交互实验室:亲手调出一个完美阴影

下面这个调参台把 box-shadow 的全部参数暴露出来。拖动滑块,观察阴影如何随每一个参数变化,同时下方会实时生成对应的 CSS 代码——这是建立“参数直觉”最快的方式。

观察要点:

  • 偏移为 0、模糊适中时,阴影均匀环绕,适合“悬浮感”;
  • 垂直偏移大于水平偏移时,暗示光源在上方,这是最符合直觉的默认形态;
  • 扩散半径为负值时,阴影比元素更小,会产生“贴地”的紧凑感;
  • 模糊半径超过 40px 后,视觉上的柔和度提升开始变得边际递减,但性能开销仍在持续增长——这就是性价比的分界点。

阴影与模糊的代价清单

不是所有“看起来很柔和”的写法都一样贵。下面把常见的阴影 / 模糊相关属性按代价分成三档。

属性 触发阶段 代价 建议
box-shadow(静态小面积) Paint + Composite 低 卡片、按钮、菜单,放心用
text-shadow(短文本) Paint + Composite 低 标题光晕、文字描边
opacity(配合伪元素阴影) Composite 低 阴影过渡的首选替代方案
filter: drop-shadow() Paint + Composite 中 需要跟随 alpha 轮廓时使用(如 PNG 图标)
box-shadow(大面积动画) Paint + Composite 中 改用伪元素 + opacity 过渡
filter: blur()(小半径) Paint + Composite 中 装饰性小元素可用
filter: blur()(大半径大面积) Paint + Composite 高 改用 radial-gradient 伪造柔和渐变
backdrop-filter: blur() Paint + Composite 高 面积要小、半径要克制,避免全屏使用
多层叠加 box-shadow(10 层以上) Paint + Composite 高 分层阴影控制在 3~5 层
常见反例
在长列表的每个卡片上叠加 6 层阴影,并且让阴影随 hover 做 0.4s 过渡。滚动时每一帧都有几十个元素在重绘。
推荐做法
默认只给卡片 2 层轻阴影;把“重阴影”放在 ::after 伪元素上,只过渡 opacity。

分层阴影:用多层叠加伪造真实的柔和

单层 box-shadow 的问题是:它只有一次模糊,明暗过渡是一条单调的曲线,看起来“假”。真实世界的光源是有面积的,阴影会同时存在近处的硬边与远处的柔散。

解决方式是:把多层阴影叠在一起。每一层偏移略微增加、模糊半径略微增加、透明度略微降低,叠加后就得到一条非常接近真实的衰减曲线。

/* ❌ 单层:边缘生硬,过渡单调 */
.single {
  box-shadow: 0 12px 24px rgba(15, 23, 42, 0.25);
}

/* ✅ 分层:近硬远柔,接近物理真实 */
.layered {
  box-shadow:
    0 1px 2px rgba(15, 23, 42, 0.07),
    0 2px 4px rgba(15, 23, 42, 0.07),
    0 4px 8px rgba(15, 23, 42, 0.07),
    0 8px 16px rgba(15, 23, 42, 0.07),
    0 16px 32px rgba(15, 23, 42, 0.07);
}

/* 规律:偏移 ×2、模糊 ×2、透明度不变 */
/* 也可以让透明度逐层递减,得到更轻的观感 */
.layered-soft {
  box-shadow:
    0 1px 2px rgba(15, 23, 42, 0.06),
    0 4px 10px rgba(15, 23, 42, 0.05),
    0 12px 28px rgba(15, 23, 42, 0.04);
}
单层阴影
分层阴影

同样的“阴影量”,分层版本的边缘过渡明显更自然

分层数量的取舍

  • 1~2 层:适合小按钮、标签等微小元素,性能优先。
  • 3~5 层:卡片、弹窗、菜单的最佳区间,视觉与性能平衡。
  • 6 层以上:只建议用在极少数“主角元素”上,例如首页的大卡片。
  • 10 层以上:基本属于过度设计,收益递减而开销线性增长。

还有一个小技巧:分层阴影可以用 CSS 变量参数化,这样只需要调整一个缩放系数,就能生成整套 elevation 体系。

/* 用变量驱动的分层阴影生成器 */
.elevation {
  --e: 1; /* 层级系数 */
  --c: 15 23 42; /* 阴影颜色 RGB */
  box-shadow:
    0 calc(1px * var(--e)) calc(2px * var(--e)) rgb(var(--c) / 0.07),
    0 calc(4px * var(--e)) calc(10px * var(--e)) rgb(var(--c) / 0.06),
    0 calc(12px * var(--e)) calc(28px * var(--e)) rgb(var(--c) / 0.05);
}

.elevation-1 { --e: 1; }
.elevation-2 { --e: 1.6; }
.elevation-3 { --e: 2.4; }

光源方向:让整站阴影说同一种语言

一个容易被忽略的细节:同一个页面里的所有阴影,应该来自同一个光源方向。如果有的卡片阴影朝下、有的朝左、有的朝右,人眼虽然说不出来,但会觉得“哪里怪怪的”。

最常见的约定是:光源在左上方,阴影落在右下方。这与自然界(太阳、顶灯)的直觉一致,也与绝大多数设计系统的默认值吻合。

Card
左上
上
右上
左
光源
右
左下
下
右下
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.18);

点击任意方向按钮,观察阴影如何随光源移动。注意几个反直觉的规律:

  • 光源在上 → 阴影向下:这是默认心智模型,也是最自然的。
  • 光源在右 → 阴影向左:视觉上会显得元素“被右侧的光推过来”,适合侧边栏抽屉。
  • 光源在正前方 → 阴影几乎没有偏移:此时应该用小偏移 + 大模糊,形成均匀环绕的“发光”效果。
  • 光源在下方 → 阴影向上:非常反直觉,通常只用于刻意制造“漂浮”或“恐怖”氛围。
/* 把光源方向固化成设计令牌 */
:root {
  /* 光源在左上,阴影落右下 */
  --shadow-x: 0px;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-color: 15 23 42;
  --shadow-alpha: 0.10;
}

.card {
  box-shadow:
    var(--shadow-x) var(--shadow-y) var(--shadow-blur)
    rgb(var(--shadow-color) / var(--shadow-alpha));
}

/* 需要更深的一级时,只覆盖变量 */
.card--raised {
  --shadow-y: 8px;
  --shadow-blur: 24px;
  --shadow-alpha: 0.14;
}

内阴影与浮雕:让元素“凹进去”或“凸出来”

加上 inset 关键字后,阴影会画在元素内部,视觉上元素就“凹”了下去。它是做输入框、按压态、拟物化面板的核心工具。

/* ① 凹陷:输入框的默认状态 */
.input {
  background: #f8fafc;
  box-shadow:
    inset 0 2px 4px rgba(15, 23, 42, 0.08),
    inset 0 0 0 1px rgba(15, 23, 42, 0.06);
}

/* ② 按压态:按钮被按下的瞬间 */
.btn:active {
  box-shadow:
    inset 0 3px 6px rgba(15, 23, 42, 0.18),
    inset 0 -2px 4px rgba(255, 255, 255, 0.6);
}

/* ③ 新拟态(Neumorphism):上下双内阴影 */
.neumorph {
  background: #eef2f7;
  border-radius: 14px;
  box-shadow:
    6px 6px 12px rgba(15, 23, 42, 0.14),
    -6px -6px 12px rgba(255, 255, 255, 0.95);
}

/* ④ 浮雕:上亮下暗的一像素高光 */
.bevel {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.9),
    0 1px 2px rgba(15, 23, 42, 0.12);
}
flat
outline
raised
pressed

同一个底色,仅靠内 / 外阴影就产生了四种不同的物理语义

内阴影的三个实用场景

  • 表单控件:输入框、下拉框用浅内阴影,暗示“这里可以输入”。
  • 按压反馈:外阴影变内阴影,是最符合直觉的“按下”隐喻,比缩放更自然。
  • 进度条 / 滑块轨道:轨道用内阴影,填充部分用外阴影,形成明显的层次对比。

需要注意的是,内阴影的性能开销与外阴影相同,同样属于 Paint 阶段。而且在移动端小尺寸元素上,过度使用内阴影会让画面显得脏。建议内阴影的透明度控制在 6%~18%,模糊半径不超过元素短边的 1/4。

文字阴影与光晕:让标题自己发光

text-shadow 的语法比 box-shadow 简单——它没有 spread,也没有 inset:

text-shadow: offset-x offset-y blur-radius color;

但它的表现力一点不弱。把多层不同半径的 text-shadow 叠起来,就能做出非常漂亮的霓虹光晕。

GLOW
text-shadow × 4 层叠加
/* 霓虹光晕:四层叠加,半径递增 */
.neon {
  color: #f8fafc;
  text-shadow:
    0 0 6px rgba(147, 197, 253, 0.9),
    0 0 14px rgba(96, 165, 250, 0.75),
    0 0 28px rgba(37, 99, 235, 0.6),
    0 0 52px rgba(37, 99, 235, 0.4);
}

/* 描边:四个方向的硬阴影 */
.outlined {
  color: #ffffff;
  text-shadow:
    -1px -1px 0 #0f172a,
    1px -1px 0 #0f172a,
    -1px 1px 0 #0f172a,
    1px 1px 0 #0f172a;
}

/* 立体投影:偏移 + 小模糊 */
.chunky {
  text-shadow: 3px 3px 0 rgba(37, 99, 235, 0.9);
}

/* 可读性提升:浅色文字加深色阴影 */
.over-image {
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
光晕 多层 0 偏移 + 递增半径,营造发光感
描边 四方向 1px 无模糊阴影
投影 单层偏移 + 小模糊,保持可读
注意 长文本慎用光晕,会显著增加绘制面积

一个实用经验:光晕只给短标题用,正文永远不要加 text-shadow

图片上文字的可读性

在图片上叠文字时,直接用 text-shadow 往往不够——因为背景亮度不可控。更好的做法是叠加一层半透明渐变遮罩:

/* ✅ 推荐:渐变遮罩 + 轻文字阴影 */
.hero-caption {
  position: relative;
}
.hero-caption::after {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  height: 60%;
  background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
}
.hero-caption .text {
  position: relative;
  z-index: 1;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}

渐变遮罩的面积是固定的,不随文字长度变化;而 text-shadow 的绘制面积随文字长度线性增长。对于长标题,遮罩方案的性价比明显更高。

filter: blur() 的性能真相

filter 与 box-shadow 最大的不同在于:它是作用于整个元素的渲染结果,包括子元素、背景、边框、文字。这也意味着它的绘制面积通常是元素的完整包围盒。

/* 常见 filter 函数与代价 */

/* 便宜:仅颜色矩阵运算 */
.cheap {
  filter: brightness(1.1) contrast(1.05);
}

/* 中等:需要采样邻域像素 */
.medium {
  filter: blur(4px);
}

/* 昂贵:大半径 × 大面积 */
.expensive {
  filter: blur(48px);
}

/* 更昂贵:模糊 + 阴影 + 饱和度连锁 */
.very-expensive {
  filter: blur(32px) saturate(1.6) drop-shadow(0 10px 20px rgba(0,0,0,0.4));
}

/* ✅ 想做出“柔和光斑”?用径向渐变替代模糊 */
.glow-blob {
  width: 300px;
  height: 300px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(59,130,246,0.9), transparent 70%);
  /* 零模糊开销,静态绘制一次即可 */
}
blur(2px)
几乎免费

小元素上完全无感

blur(10px)
可以接受

适合局部装饰

blur(30px)
开始吃力

大面积时明显掉帧

blur(60px+)
危险区

低端机上直接卡住

drop-shadow 与 box-shadow 的区别

这两个经常被混淆。核心差异是:box-shadow 画的是元素的矩形包围盒阴影,drop-shadow 画的是元素不透明像素轮廓的阴影。

/* box-shadow:矩形阴影,忽略透明区域 */
.icon-a {
  box-shadow: 0 6px 14px rgba(15, 23, 42, 0.3);
}
/* 一个透明背景的 PNG 图标 → 会得到一个方块阴影 ❌ */

/* drop-shadow:跟随 alpha 轮廓 */
.icon-b {
  filter: drop-shadow(0 6px 14px rgba(15, 23, 42, 0.3));
}
/* 同样一张 PNG → 阴影紧贴图标形状 ✅ */

/* 代价对比:drop-shadow 明显更贵 */
/* 它需要先分析 alpha 通道,再生成阴影,再合成 */

结论很简单:需要跟随形状时用 drop-shadow,其余一切情况都用 box-shadow。对于静态图标,更好的做法是把阴影直接烘焙进 SVG 或图片里,运行时零开销。

毛玻璃实验室:backdrop-filter 的正确打开方式

backdrop-filter 是现代 UI 中最受欢迎的视觉效果之一:它把元素背后的内容拿来做模糊,从而产生“磨砂玻璃”的质感。但它的代价也最容易被低估——因为它模糊的不是自己,而是背后的一切。

Glassmorphism
背后是三层径向渐变光斑。调整下方的滑块,观察毛玻璃的模糊强度与通透感如何变化。

毛玻璃的三个性能原则

  • 面积要小:一个 300×200 的卡片可以接受,一块覆盖半屏的面板就要慎重,全屏导航栏则必须评估低端设备表现。
  • 半径要克制:blur(8px) 到 blur(16px) 已经足够出效果,继续加大只是徒增开销。
  • 避免动态背景:如果毛玻璃背后有持续动画的元素(视频、滚动列表、动画光斑),模糊每一帧都要重算,代价翻倍。
/* 一个可直接复用的毛玻璃配方 */
.glass {
  background: rgba(255, 255, 255, 0.16);
  border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: 16px;
  -webkit-backdrop-filter: blur(10px) saturate(1.4);
  backdrop-filter: blur(10px) saturate(1.4);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}

/* ✅ 给一个实色兜底背景,防止不支持时不可读 */
.glass-fallback {
  background: rgba(15, 23, 42, 0.72);
}
@supports (backdrop-filter: blur(1px)) {
  .glass-fallback {
    background: rgba(255, 255, 255, 0.16);
    backdrop-filter: blur(10px);
  }
}

/* 性能缓解:给毛玻璃容器加隔离 */
.glass-panel {
  contain: paint;
  isolation: isolate;
}
适合 小面积卡片、悬浮操作栏、弹窗头部
慎用 全屏导航栏、长列表项、频繁出现的气泡
避免 背后是视频 / 动画 / 滚动内容的大面积区域
退化 用 @supports 提供实色半透明兜底

模糊作为氛围层:Aurora 与光斑的正确做法

近几年的产品官网特别流行“极光背景”“流动光斑”——几团柔和的彩色光晕在深色背景上缓慢飘移。很多人第一反应是写几个 div,加上 filter: blur(100px),然后祈祷浏览器扛得住。

实际上,有两条路可以走:

/* ❌ 方案 A:真模糊 —— 昂贵 */
.aurora-bad {
  position: absolute;
  width: 600px;
  height: 600px;
  background: #3b82f6;
  border-radius: 50%;
  filter: blur(120px); /* 每帧都在算 */
  animation: drift 20s ease-in-out infinite alternate;
}

/* ✅ 方案 B:径向渐变 —— 便宜且效果相近 */
.aurora-good {
  position: absolute;
  width: 900px;
  height: 900px;
  background: radial-gradient(circle, rgba(59,130,246,0.55), transparent 65%);
  animation: drift 20s ease-in-out infinite alternate;
  will-change: transform;
}

@keyframes drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(120px, 60px, 0) scale(1.15); }
}

/* 两个方案的关键差异: */
/* A:每一帧都要重新做一次 120px 半径的卷积 */
/* B:渐变静态绘制一次,动画只改 GPU 的变换矩阵 */
真模糊方案
GPU 持续占用

面积 × 半径,每帧重算

渐变方案
只走合成

绘制一次,之后只改矩阵

视觉差异
极小

叠加混合模式后几乎无法分辨

推荐度
渐变优先

除非确有必须,否则不用真模糊

让光斑更耐看的小技巧

  • 用 mix-blend-mode: screen:让光斑与背景产生加法混合,颜色更通透。
  • 加一层噪点:纯渐变容易出现色带(banding),叠一层极淡的 SVG 噪点可以显著改善。
  • 降低不透明度、增加数量:3 个 40% 不透明的光斑,比 1 个 100% 不透明的光斑更有层次。
  • 动画只用 transform:永远不要动画光斑的 width、filter 或 background-position。
  • 用 overflow: hidden 裁剪:避免大尺寸光斑撑出滚动条或超出视口。

让阴影动起来:过渡的正确姿势

阴影过渡是 hover 效果中最常见的需求,也是最容易写出性能问题的地方。核心原则只有一条:不要让浏览器逐帧重绘阴影。

/* ❌ 直接过渡 box-shadow:每帧都要重绘 */
.card-a {
  box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08);
  transition: box-shadow 0.35s ease;
}
.card-a:hover {
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
/* 0.35s ≈ 21 帧,每帧重绘一次阴影区域 */

/* ✅ 方案一:伪元素承载重阴影,只过渡 opacity */
.card-b {
  position: relative;
  box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08);
  transition: transform 0.35s cubic-bezier(0.2, 0, 0, 1);
}
.card-b::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
  z-index: -1;
}
.card-b:hover {
  transform: translateY(-6px);
}
.card-b:hover::after {
  opacity: 1;
}

/* ✅ 方案二:用 filter: drop-shadow 做整体过渡(元素少时可用) */
.card-c {
  transition: transform 0.35s ease, filter 0.35s ease;
}
悬停卡片 鼠标移上来,观察阴影如何平滑加深
纯 CSS 实现 只过渡 transform 与 opacity
零 JS 不写一行脚本也能丝滑
可复用 抽出成 .elevate 工具类

悬停试试:卡片上浮的同时阴影加深,但浏览器只做了一次合成

为什么“伪元素 + opacity”更快

因为伪元素上的 box-shadow 是静态的——它只在首次绘制时计算一次。之后的过渡只改变 opacity,而 opacity 属于合成属性,浏览器只需要把这个已经绘制好的图层乘以一个透明度系数,完全不需要重新做高斯模糊。

唯一需要注意的是层级:::after 设置了 z-index: -1 后,父元素必须不能有背景色(或者背景色放在另一个伪元素上),否则阴影会被父元素背景挡住。常见的解决方案是给父元素设置 background: transparent,把背景交给 ::before。

/* 完整的三层结构:背景 / 内容 / 阴影 */
.card-full {
  position: relative;
  background: transparent; /* 关键 */
  isolation: isolate;
}
.card-full::before {
  content: '';
  position: absolute;
  inset: 0;
  background: #ffffff;
  border-radius: 12px;
  z-index: -2;
}
.card-full::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 12px;
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
  opacity: 0;
  transition: opacity 0.35s ease;
  z-index: -1;
}

isolation: isolate 是这里的隐藏功臣——它创建了一个新的层叠上下文,保证 z-index: -1 的伪元素不会被页面中其他元素的层级关系干扰。

暗色模式下的阴影策略

一个残酷的事实:在深色背景上,传统阴影几乎完全失效。因为阴影的本质是“比背景更暗”,而深色背景已经没有多少变暗的空间了。硬生生把阴影调黑,只会得到一团糊状的脏斑。

浅色模式:深色阴影有效
暗色模式:靠描边 + 微光

在暗色模式下,正确的层次表达方式是:

/* 暗色模式的三个层次手法 */

/* ① 提亮表面:层级越高,背景越亮 */
.dark .surface-0 { background: #0f172a; }
.dark .surface-1 { background: #1e293b; }
.dark .surface-2 { background: #334155; }

/* ② 一像素描边:模拟“被光照到的上边缘” */
.dark .card {
  background: #1e293b;
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.06),
    inset 0 1px 0 rgba(255, 255, 255, 0.06),
    0 8px 24px rgba(0, 0, 0, 0.5);
}

/* ③ 品牌色微光:用颜色代替明度做层次 */
.dark .card--primary {
  box-shadow:
    0 0 0 1px rgba(96, 165, 250, 0.18),
    0 8px 28px rgba(37, 99, 235, 0.28);
}

/* 用变量统一管理,两种主题只改一处 */
:root {
  --surface: #ffffff;
  --shadow-color: 15 23 42;
  --shadow-alpha: 0.12;
  --ring: 0 0 0 1px rgb(15 23 42 / 0.06);
}
@media (prefers-color-scheme: dark) {
  :root {
    --surface: #1e293b;
    --shadow-color: 0 0 0;
    --shadow-alpha: 0.5;
    --ring: 0 0 0 1px rgb(255 255 255 / 0.06);
  }
}

.card {
  background: var(--surface);
  box-shadow:
    var(--ring),
    0 8px 24px rgb(var(--shadow-color) / var(--shadow-alpha));
}
浅色模式 深色阴影 + 低透明度,表现层次
暗色模式 提亮表面 + 描边,弱化阴影
关键差异 暗色下阴影的作用从“投影”变成“分离”
常见错误 直接复用浅色阴影 → 一片死黑

好的暗色模式不是“把颜色反过来”,而是重新设计一套层次语言

Elevation 体系:把阴影变成设计令牌

当项目规模变大,几十个组件各自写阴影,很快就会失控:有的深、有的浅、有的偏蓝、有的偏灰。解决方案是建立一套层级化的 elevation 令牌,每个组件只声明“我在第几层”。

elev-0
elev-1
elev-2
elev-3
elev-4
elev-5

鼠标悬停可以看到每一级的实际效果。层级越高,阴影的偏移、模糊与不透明度同步增长。

/* 一套完整的 elevation 令牌(分层阴影版) */
:root {
  --shadow-c: 15 23 42;

  --elev-1:
    0 1px 2px rgb(var(--shadow-c) / 0.06),
    0 1px 3px rgb(var(--shadow-c) / 0.05);

  --elev-2:
    0 2px 4px rgb(var(--shadow-c) / 0.06),
    0 4px 8px rgb(var(--shadow-c) / 0.06);

  --elev-3:
    0 4px 8px rgb(var(--shadow-c) / 0.07),
    0 8px 20px rgb(var(--shadow-c) / 0.08);

  --elev-4:
    0 8px 16px rgb(var(--shadow-c) / 0.08),
    0 16px 36px rgb(var(--shadow-c) / 0.10);

  --elev-5:
    0 12px 24px rgb(var(--shadow-c) / 0.10),
    0 28px 60px rgb(var(--shadow-c) / 0.14);
}

/* 组件只消费语义,不关心具体数值 */
.card { box-shadow: var(--elev-1); }
.card:hover { box-shadow: var(--elev-3); }
.dropdown { box-shadow: var(--elev-3); }
.modal { box-shadow: var(--elev-5); }

/* 暗色模式只换变量,组件代码零改动 */
@media (prefers-color-scheme: dark) {
  :root { --shadow-c: 0 0 0; }
}

层级语义的约定

层级 典型组件 视觉语义
elev-0 页面背景、表格行 与页面同层,仅靠描边区分
elev-1 普通卡片、列表项 轻微浮起,几乎察觉不到
elev-2 可点击卡片、输入框聚焦 可交互,开始有存在感
elev-3 下拉菜单、Tooltip、Popover 脱离文档流,悬浮于内容之上
elev-4 侧边抽屉、通知面板 大面积浮动面板
elev-5 模态框、全屏对话框 最高层级,需要吸引全部注意力

建立这套体系最大的收益不是视觉,而是决策成本的降低。设计师和工程师不再需要每次纠结“阴影要多深”,而是直接问“这个元素在第几层”。

实战:四个常见组件的阴影配方

下面把前面所有的原则落到具体组件上。这些配方可以直接复制使用,也可以作为自己项目的起点。

① 按钮:从静止到按下的完整状态机

/* 主按钮:品牌色阴影 + 按压内阴影 */
.btn-primary {
  background: #2563eb;
  color: #ffffff;
  border-radius: 10px;
  box-shadow:
    0 1px 2px rgba(15, 23, 42, 0.10),
    0 4px 12px rgba(37, 99, 235, 0.28);
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.btn-primary:hover {
  background: #1d4ed8;
  transform: translateY(-1px);
  box-shadow:
    0 2px 4px rgba(15, 23, 42, 0.10),
    0 8px 20px rgba(37, 99, 235, 0.35);
}
.btn-primary:active {
  transform: translateY(1px);
  box-shadow:
    inset 0 3px 6px rgba(15, 23, 42, 0.25);
  transition-duration: 0.05s;
}

/* 次要按钮:只靠描边与极浅阴影 */
.btn-secondary {
  background: #ffffff;
  border: 1px solid #cbd5e1;
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.btn-secondary:hover {
  box-shadow: 0 2px 8px rgba(15, 23, 42, 0.10);
}

② 卡片:默认克制,悬停分层

.card {
  background: #ffffff;
  border-radius: 12px;
  box-shadow: var(--elev-1);
  transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1), box-shadow 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.card:hover {
  transform: translateY(-4px);
  box-shadow: var(--elev-3);
}

/* 卡片数量很多时,改用伪元素 + opacity 方案 */
.card--many {
  transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.card--many:hover { transform: translateY(-4px); }
.card--many:hover::after { opacity: 1; }

③ 下拉菜单与浮层

/* 菜单需要明确的“悬浮”语义,阴影更重 */
.dropdown {
  background: #ffffff;
  border-radius: 10px;
  border: 1px solid rgba(15, 23, 42, 0.06);
  box-shadow:
    0 4px 8px rgba(15, 23, 42, 0.06),
    0 12px 28px rgba(15, 23, 42, 0.10),
    0 24px 56px rgba(15, 23, 42, 0.08);
  transform-origin: top center;
  animation: dropdownIn 0.18s cubic-bezier(0, 0, 0.2, 1) both;
}
@keyframes dropdownIn {
  from {
    opacity: 0;
    transform: translateY(-6px) scale(0.97);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* 用 @starting-style 做入场(现代浏览器) */
@supports (transition-behavior: allow-discrete) {
  .popover {
    opacity: 0;
    transform: translateY(-6px) scale(0.97);
    transition: opacity 0.2s ease, transform 0.2s ease;
  }
  .popover:popover-open {
    opacity: 1;
    transform: translateY(0) scale(1);
    @starting-style {
      opacity: 0;
      transform: translateY(-6px) scale(0.97);
    }
  }
}

④ 模态框与遮罩

/* 遮罩用模糊 + 半透明,制造景深 */
.modal-backdrop {
  background: rgba(15, 23, 42, 0.5);
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  animation: fadeIn 0.2s ease both;
}

/* 模态框:最高层级阴影 + 轻微缩放入场 */
.modal {
  background: #ffffff;
  border-radius: 16px;
  box-shadow: var(--elev-5);
  animation: modalIn 0.26s cubic-bezier(0.34, 1.3, 0.64, 1) both;
}
@keyframes modalIn {
  from {
    opacity: 0;
    transform: translateY(12px) scale(0.96);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* ⚠️ 遮罩上的 backdrop-filter 覆盖全屏,代价很高 */
/* 低端设备建议降级为纯半透明 */
@media (max-width: 768px) {
  .modal-backdrop {
    background: rgba(15, 23, 42, 0.65);
    backdrop-filter: none;
  }
}

性能对比实验:阴影动画的两种写法

下面用 24 个方块做一次直观对比。点击按钮切换两种实现方式,观察右上角的实时帧率。

两种模式的视觉结果几乎一致,但实现机制完全不同:

  • 模式 A(box-shadow):每一帧,24 个元素都要重新计算并绘制一次阴影区域。模糊半径越大,绘制面积越大。
  • 模式 B(伪元素 opacity):阴影在首次绘制时就已经计算完成,之后的动画只是把图层乘以一个透明度系数,属于纯合成操作。

在性能强劲的桌面设备上,两者的差异可能不明显。但把元素数量放大到 100 个、把模糊半径加大到 40px,或者在低端手机上运行,差距会立刻显现。这也是为什么列表类组件必须使用方案 B。

用 DevTools 定位阴影与模糊的瓶颈

阴影和模糊的性能问题非常隐蔽:它们不会报错,只会让帧率悄悄下降。Chrome DevTools 提供了几件关键工具来把它们揪出来。

/* 排查流程:四步定位法 */

/* Step 1:Rendering 面板开启 Paint flashing */
/* 整个屏幕变绿 = 全屏重绘,问题严重 */
/* 只有小范围闪烁 = 局部重绘,可以接受 */

/* Step 2:Performance 面板录制 */
/* 展开 Main 线程火焰图 */
/* 找到 "Paint" 与 "Rasterize" 的长条 */
/* 点击可定位到具体的 DOM 节点 */

/* Step 3:Layers 面板检查合成层 */
/* 查看每个图层的内存占用 */
/* 带阴影的大元素往往会被独立提升 */

/* Step 4:CSS Overview 面板(实验性) */
/* 统计页面中颜色、字体、阴影的使用情况 */
/* 快速发现“一百种不同的阴影”这种混乱 */
Paint flashing 绿色面积 = 重绘面积,越小越好
Performance 帧级时间线,定位到具体代码
Layers 查看合成层数量与显存占用
CSS Overview 发现样式碎片化与不一致

常见症状与对应诊断

  • 滚动时大面积闪烁绿色 → 检查是否有元素在滚动中改变阴影(如 sticky 导航栏的阴影切换)。
  • hover 时明显掉帧 → 检查是否直接过渡了 box-shadow,改用伪元素 + opacity。
  • 页面首次加载就卡 → 检查是否有大量元素带大半径模糊,考虑用渐变替代。
  • 低端机上滑动卡顿 → 检查 backdrop-filter 的使用面积,尤其是全屏遮罩。
  • GPU 内存占用异常高 → 检查合成层数量,阴影 + 模糊 + will-change 的组合容易导致图层爆炸。

一个实用的现场检查方法

如果怀疑某个阴影或模糊是罪魁祸首,可以在 DevTools 中临时禁用它,然后重新录制:

/* 在 Console 中临时移除所有阴影,观察帧率变化 */
const style = document.createElement('style');
style.textContent = '* { box-shadow: none !important; text-shadow: none !important; }';
document.head.appendChild(style);

/* 帧率明显回升 → 阴影就是瓶颈 */
/* 帧率无变化 → 继续排查 filter 或 backdrop-filter */

/* 排查模糊 */
style.textContent = '* { filter: none !important; backdrop-filter: none !important; }';

/* 排除法定位完成后,移除临时样式 */
style.remove();

这种“二分法排除”看起来粗暴,但在真实项目的性能排查中极其有效——尤其是在接手一个不熟悉的遗留项目时。

高级阴影与模糊最佳实践清单

把全文的核心结论浓缩成一份可以贴在工位上的清单。

  • 永远不要用纯黑做阴影:用 rgba(15, 23, 42, 0.1) 这类带色调的半透明色。
  • 阴影颜色参数化:拆成 RGB 变量 + 透明度变量,暗色模式一键切换。
  • 统一光源方向:全站约定光源在左上,阴影落右下。
  • 分层阴影控制在 3~5 层:偏移与模糊成倍递增,透明度保持恒定或递减。
  • 建立 elevation 令牌:组件只声明层级,不写具体数值。
  • hover 阴影用伪元素 + opacity:绝不要直接过渡 box-shadow。
  • 模糊半径与面积要克制:blur(8px)~blur(16px) 通常足够。
  • 氛围光斑用径向渐变:不要用 filter: blur(120px),代价相差一个数量级。
  • backdrop-filter 只用于小面积:全屏遮罩务必评估低端设备表现并准备降级。
  • drop-shadow 只在需要跟随轮廓时使用:其余情况一律用 box-shadow。
  • 内阴影透明度控制在 6%~18%:太重会显得脏,太轻又看不出凹陷。
  • 暗色模式重新设计层次:提亮表面 + 一像素描边,而不是加深阴影。
  • 用 contain: paint 隔离重绘范围:让阴影的绘制不影响外部区域。
  • 用 DevTools 的 Paint flashing 验证:先看清重绘面积,再决定优化哪一处。
  • 在低端真机上测试:桌面浏览器的 GPU 性能会掩盖绝大多数问题。
/* ===== 一套可直接复用的阴影与模糊基线 ===== */

/* 1. 设计令牌 */
:root {
  /* 阴影颜色(RGB 分量,便于调节透明度) */
  --shadow-c: 15 23 42;

  /* 五级 elevation,全部使用分层阴影 */
  --elev-1:
    0 1px 2px rgb(var(--shadow-c) / 0.06),
    0 1px 3px rgb(var(--shadow-c) / 0.05);
  --elev-2:
    0 2px 4px rgb(var(--shadow-c) / 0.06),
    0 4px 8px rgb(var(--shadow-c) / 0.06);
  --elev-3:
    0 4px 8px rgb(var(--shadow-c) / 0.07),
    0 8px 20px rgb(var(--shadow-c) / 0.08);
  --elev-4:
    0 8px 16px rgb(var(--shadow-c) / 0.08),
    0 16px 36px rgb(var(--shadow-c) / 0.10);
  --elev-5:
    0 12px 24px rgb(var(--shadow-c) / 0.10),
    0 28px 60px rgb(var(--shadow-c) / 0.14);

  /* 描边环,用于暗色模式 */
  --ring: 0 0 0 1px rgb(var(--shadow-c) / 0.06);

  /* 模糊令牌 */
  --blur-sm: 4px;
  --blur-md: 10px;
  --blur-lg: 18px;
}

/* 2. 通用卡片:hover 用伪元素承载重阴影 */
.card {
  position: relative;
  isolation: isolate;
  background: #ffffff;
  border-radius: 12px;
  box-shadow: var(--ring), var(--elev-1);
  transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: var(--elev-3);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
  z-index: -1;
}
.card:hover { transform: translateY(-4px); }
.card:hover::after { opacity: 1; }

/* 3. 毛玻璃:小面积 + 实色兜底 + 支持性检测 */
.glass {
  background: rgba(255, 255, 255, 0.16);
  border: 1px solid rgba(255, 255, 255, 0.32);
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
}
@supports (backdrop-filter: blur(1px)) {
  .glass {
    -webkit-backdrop-filter: blur(var(--blur-md)) saturate(1.4);
    backdrop-filter: blur(var(--blur-md)) saturate(1.4);
  }
}

/* 4. 氛围光斑:径向渐变替代真模糊 */
.glow {
  position: absolute;
  width: 900px;
  height: 900px;
  background: radial-gradient(circle, rgb(59 130 246 / 0.5), transparent 65%);
  pointer-events: none;
  will-change: transform;
  animation: glowDrift 22s ease-in-out infinite alternate;
}

/* 5. 暗色模式:换颜色 + 加描边,而不是加深阴影 */
@media (prefers-color-scheme: dark) {
  :root {
    --shadow-c: 0 0 0;
    --ring: 0 0 0 1px rgb(255 255 255 / 0.07);
  }
  .card { background: #1e293b; }
}

/* 6. 无障碍:减少动态效果时削弱模糊与位移 */
@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
  .card:hover { transform: none; }
  .glow { animation: none; }
}

/* 7. 性能隔离:把重绘范围锁在组件内部 */
.card,
.glass,
.hover-card {
  contain: paint;
}

阴影与模糊的本质,是用绘制成本换取视觉层次。这笔交易划不划算,取决于你把它用在哪里、用了多少、以及有没有更便宜的替代方案。一个把阴影控制在 3 层以内、hover 只过渡 opacity、氛围光斑用渐变实现、暗色模式重新设计层次的页面,既好看又快——而这恰恰是“高级”二字真正的含义。

如果你在项目中遇到过阴影糊成一团、毛玻璃卡到掉帧、或者暗色模式下一片死黑的问题,不妨回到上面的实验台,亲手调一调参数、切一切模式、比一比帧率。手感这种东西,看十篇文章都不如自己拖一次滑块。