阴影与模糊,是界面从“能看”走向“好看”的分水岭。它们让扁平的矩形拥有了厚度、层次与空间感,也让一个普通的卡片在鼠标悬停的瞬间拥有了呼吸。但与此同时,它们也是浏览器绘制流水线里最昂贵的两种视觉效果:一次大面积的高斯模糊,可能吃掉整帧的预算;一串随手写的 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 只改一个矩阵,几乎不花时间 */
高斯模糊到底贵在哪里
浏览器实现 blur(radius) 时,本质上是在每个像素周围取一个半径为 3 × radius 的采样核,做加权平均。也就是说:
- 半径翻倍,采样量翻四倍:因为面积是半径的平方。
- 元素面积翻倍,总耗时翻倍:绘制像素数线性增长。
- 元素数量翻倍,总耗时翻倍:每个模糊元素都是一次独立的绘制任务。
所以一个 800×600 区域上的 blur(40px),其计算量可能是一个 200×150 区域上 blur(8px) 的几十倍。这就是为什么“磨砂背景”做得太狠,页面立刻就会发烫。
box-shadow 语法全解:六个参数各自在做什么
box-shadow 的完整语法是:
六个部分缺一不可(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 层 |
::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; }
光源方向:让整站阴影说同一种语言
一个容易被忽略的细节:同一个页面里的所有阴影,应该来自同一个光源方向。如果有的卡片阴影朝下、有的朝左、有的朝右,人眼虽然说不出来,但会觉得“哪里怪怪的”。
最常见的约定是:光源在左上方,阴影落在右下方。这与自然界(太阳、顶灯)的直觉一致,也与绝大多数设计系统的默认值吻合。
点击任意方向按钮,观察阴影如何随光源移动。注意几个反直觉的规律:
- 光源在上 → 阴影向下:这是默认心智模型,也是最自然的。
- 光源在右 → 阴影向左:视觉上会显得元素“被右侧的光推过来”,适合侧边栏抽屉。
- 光源在正前方 → 阴影几乎没有偏移:此时应该用小偏移 + 大模糊,形成均匀环绕的“发光”效果。
- 光源在下方 → 阴影向上:非常反直觉,通常只用于刻意制造“漂浮”或“恐怖”氛围。
: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);
}
同一个底色,仅靠内 / 外阴影就产生了四种不同的物理语义
内阴影的三个实用场景
- 表单控件:输入框、下拉框用浅内阴影,暗示“这里可以输入”。
- 按压反馈:外阴影变内阴影,是最符合直觉的“按下”隐喻,比缩放更自然。
- 进度条 / 滑块轨道:轨道用内阴影,填充部分用外阴影,形成明显的层次对比。
需要注意的是,内阴影的性能开销与外阴影相同,同样属于 Paint 阶段。而且在移动端小尺寸元素上,过度使用内阴影会让画面显得脏。建议内阴影的透明度控制在 6%~18%,模糊半径不超过元素短边的 1/4。
文字阴影与光晕:让标题自己发光
text-shadow 的语法比 box-shadow 简单——它没有 spread,也没有 inset:
但它的表现力一点不弱。把多层不同半径的 text-shadow 叠起来,就能做出非常漂亮的霓虹光晕。
.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);
}
一个实用经验:光晕只给短标题用,正文永远不要加 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 最大的不同在于:它是作用于整个元素的渲染结果,包括子元素、背景、边框、文字。这也意味着它的绘制面积通常是元素的完整包围盒。
/* 便宜:仅颜色矩阵运算 */
.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%);
/* 零模糊开销,静态绘制一次即可 */
}
小元素上完全无感
适合局部装饰
大面积时明显掉帧
低端机上直接卡住
drop-shadow 与 box-shadow 的区别
这两个经常被混淆。核心差异是:box-shadow 画的是元素的矩形包围盒阴影,drop-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 中最受欢迎的视觉效果之一:它把元素背后的内容拿来做模糊,从而产生“磨砂玻璃”的质感。但它的代价也最容易被低估——因为它模糊的不是自己,而是背后的一切。
毛玻璃的三个性能原则
- 面积要小:一个 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;
}
模糊作为氛围层:Aurora 与光斑的正确做法
近几年的产品官网特别流行“极光背景”“流动光斑”——几团柔和的彩色光晕在深色背景上缓慢飘移。很多人第一反应是写几个 div,加上 filter: blur(100px),然后祈祷浏览器扛得住。
实际上,有两条路可以走:
.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 的变换矩阵 */
面积 × 半径,每帧重算
绘制一次,之后只改矩阵
叠加混合模式后几乎无法分辨
除非确有必须,否则不用真模糊
让光斑更耐看的小技巧
- 用
mix-blend-mode: screen:让光斑与背景产生加法混合,颜色更通透。 - 加一层噪点:纯渐变容易出现色带(banding),叠一层极淡的 SVG 噪点可以显著改善。
- 降低不透明度、增加数量:3 个 40% 不透明的光斑,比 1 个 100% 不透明的光斑更有层次。
- 动画只用 transform:永远不要动画光斑的
width、filter或background-position。 - 用
overflow: hidden裁剪:避免大尺寸光斑撑出滚动条或超出视口。
让阴影动起来:过渡的正确姿势
阴影过渡是 hover 效果中最常见的需求,也是最容易写出性能问题的地方。核心原则只有一条:不要让浏览器逐帧重绘阴影。
.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;
}
悬停试试:卡片上浮的同时阴影加深,但浏览器只做了一次合成
为什么“伪元素 + 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 令牌,每个组件只声明“我在第几层”。
鼠标悬停可以看到每一级的实际效果。层级越高,阴影的偏移、模糊与不透明度同步增长。
: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);
}
② 卡片:默认克制,悬停分层
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 面板(实验性) */
/* 统计页面中颜色、字体、阴影的使用情况 */
/* 快速发现“一百种不同的阴影”这种混乱 */
常见症状与对应诊断
- 滚动时大面积闪烁绿色 → 检查是否有元素在滚动中改变阴影(如 sticky 导航栏的阴影切换)。
- hover 时明显掉帧 → 检查是否直接过渡了
box-shadow,改用伪元素 +opacity。 - 页面首次加载就卡 → 检查是否有大量元素带大半径模糊,考虑用渐变替代。
- 低端机上滑动卡顿 → 检查
backdrop-filter的使用面积,尤其是全屏遮罩。 - GPU 内存占用异常高 → 检查合成层数量,阴影 + 模糊 +
will-change的组合容易导致图层爆炸。
一个实用的现场检查方法
如果怀疑某个阴影或模糊是罪魁祸首,可以在 DevTools 中临时禁用它,然后重新录制:
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、氛围光斑用渐变实现、暗色模式重新设计层次的页面,既好看又快——而这恰恰是“高级”二字真正的含义。
如果你在项目中遇到过阴影糊成一团、毛玻璃卡到掉帧、或者暗色模式下一片死黑的问题,不妨回到上面的实验台,亲手调一调参数、切一切模式、比一比帧率。手感这种东西,看十篇文章都不如自己拖一次滑块。