如果说布局决定了一个页面的“骨架”,那么动效决定的是它的“呼吸”。一个按钮在悬停时轻轻抬起 2 像素,一个下拉菜单用 180ms 滑出而不是瞬间弹出,一个卡片在滚动到视口时淡入——这些细节加起来,就是用户口中那句说不清道不明的“这个网站用起来很舒服”。CSS 过渡与动画,正是实现这一切成本最低、性能最好的手段:不需要任何第三方库,不需要 JavaScript 参与逐帧计算,交给浏览器的合成器线程就能跑满 60fps。但低门槛也意味着滥用——很多人写的动画又卡又乱,根源往往不是“写得不好看”,而是压根不了解过渡的四要素、缓动曲线的数学含义、以及浏览器渲染流水线里哪些属性便宜、哪些属性昂贵。这篇 45 分钟的长文,从 transition 的每一个子属性讲起,途经缓动函数、transform 变换、@keyframes 八大属性、渲染性能、实战模式、无障碍与调试技巧,最后给出一份可以直接贴到工位上的检查清单。读完它,你应该能在任何项目里自信地写出既好看又快、还对所有人都友好的动效。
一、transition:两个状态之间的补间
过渡(transition)解决的问题极其简单:当某个 CSS 属性的值发生变化时,不要让浏览器瞬间切换,而是在一段时间内平滑地从旧值插值到新值。它的语法由四个子属性组成:
.card {
transition: transform 0.3s ease 0s;
}
/* 等价的长写法 */
.card {
transition-property: transform;
transition-duration: 0.3s;
transition-timing-function: ease;
transition-delay: 0s;
}
/* 多个属性用逗号分隔,各自的时长可以不同 */
.card {
transition: transform 0.25s ease-out, box-shadow 0.4s ease, opacity 0.2s linear;
}
/* 触发状态变化 */
.card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
opacity: 0.95;
}
这段代码有一个容易被忽略的细节:transition 写在基础状态(.card)上,而不是 :hover 上。写在基础状态,意味着进入和离开两个方向都有过渡;只写在 :hover 里,则鼠标移入时有动画、移出时瞬间弹回。当然,有时候“进出不对称”正是你想要的效果——比如进入快、离开慢,那就分别写在两个规则里。
哪些属性可以被过渡
过渡的本质是“插值”(interpolation),所以只有那些存在中间值的属性才能参与。长度(px/rem/%)、颜色、透明度、角度、transform、filter、clip-path 都可以;而 display、position、font-family、visibility 的切换行为要么是离散的、要么根本没有中间态。
| 属性类别 | 代表属性 | 能否过渡 | 渲染代价 |
|---|---|---|---|
| 变换类 | transform、translate、rotate、scale |
可以 | 仅合成 |
| 透明度 | opacity |
可以 | 仅合成 |
| 颜色类 | color、background-color、border-color |
可以 | 重绘 |
| 滤镜 | filter、backdrop-filter |
可以 | 合成 + 栅格化 |
| 形状 | clip-path、border-radius |
可以 | 重绘 |
| 尺寸位置 | width、height、top、left、margin |
可以 | 重排 |
| 显示模式 | display、position |
不可以 | — |
| 关键字值 | height: auto、width: fit-content |
不可以 | — |
最后一行值得单独说明:auto 不是长度,浏览器不知道从 0 到 auto 之间该怎么插值,所以 height: 0 → height: auto 的展开动画是无效的。想实现“高度自适应展开”,要么用 max-height 从一个足够大的值过渡到 0,要么用现代的 grid-template-rows: 0fr → 1fr 技巧,要么直接改用 transform: scaleY()(但它会拉伸内容)。
.panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease;
}
.panel.open { max-height: 500px; }
/* 方案二:grid-template-rows 的 0fr → 1fr(推荐) */
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.4s ease;
}
.panel.open { grid-template-rows: 1fr; }
.panel > div { overflow: hidden; min-height: 0; }
transition-property: all 是个温柔的陷阱
很多人图省事写 transition: all 0.3s;。它在小项目里确实方便,但随着样式表增长,问题会逐渐暴露:
- 任何属性变化都会走动画,包括你根本没打算动的东西。比如某个布局属性因为窗口缩放发生变化,也会被“平滑过渡”,产生莫名其妙的抖动。
- 性能不可控。今天你只改了颜色,明天有人在同一个元素上加了
width,动画就悄悄从“仅重绘”升级成了“重排”。 - 调试困难。当页面出现奇怪的延迟感,你得逐个排查到底是哪个属性在动。
正确的做法是显式列出需要过渡的属性。如果确实需要很多属性,也应该分组写、分别给时长,而不是一把梭。all 只在你写原型、确认完所有属性之后再考虑保留。
transition 的三个触发条件
一段过渡要真正跑起来,必须同时满足:
- 元素在两个状态下都有明确的值。如果起始值是
auto、或者元素刚刚被display: none隐藏,就没有起点。 - 属性值确实发生了变化。把
opacity从 0.5 改到 0.5,不会有任何动画。 - 元素处于渲染树中。用 JS 刚
createElement出来、还没插入文档的元素,设置样式不会触发过渡。
el.style.display = 'block';
el.style.opacity = 1;同一帧内先
display: block 再改透明度,浏览器还没有为新元素建立起始样式,透明度会瞬间变成 1,完全看不到淡入。
el.style.display = 'block';
void el.offsetWidth; // 强制回流,确立起始样式
el.style.opacity = 1;先让浏览器“看一眼”初始状态,再触发变化。
更现代的写法是用 requestAnimationFrame 把第二次赋值推到下一帧,或者干脆用 @starting-style 规则声明入场起点。后者是 CSS 的新特性,专门用来解决“元素首次出现时无法过渡”这个老问题:
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
@starting-style {
.toast {
opacity: 0;
transform: translateY(-12px);
}
}
这段代码的意思是:“当这个元素第一次被渲染时,先把它的初始样式设定为透明并上移 12px,然后过渡到正常状态。”不需要任何 JS,toast 就自动拥有了入场动画。
二、缓动函数:动画的“性格”来自哪里
时长决定动画有多快,缓动决定动画的“性格”。同样的 300ms,用 linear 是机械的,用 ease-out 是轻快的,用 cubic-bezier(0.34, 1.56, 0.64, 1) 则带一点俏皮的回弹。很多人写动画时只改时长不改缓动,结果页面怎么调都“差点意思”,问题通常就出在这里。
五个关键字,记住它们背后的曲线
cubic-bezier(0, 0, 1, 1)。适合进度条、旋转加载图标这类“持续稳定”的运动。
cubic-bezier(0.25, 0.1, 0.25, 1)。中段快、两端慢,是个“万金油”,但也因此缺乏个性。
有一条经验法则值得记住:用户看向哪里,哪里就应该慢。元素从屏幕外飞入时,用户的目光会追随它到达终点,所以入场用 ease-out;元素飞出屏幕时,用户的注意力还留在原地,所以离场用 ease-in 更自然。反过来用会让人产生“还没看清就没了”或“卡住了”的错觉。
cubic-bezier:四个数字的物理学
cubic-bezier(x1, y1, x2, y2) 描述的是三次贝塞尔曲线的两个控制点,起点固定在 (0,0)、终点固定在 (1,1)。横轴是时间进度(0 到 1),纵轴是属性值的变化进度(可以超出 0~1,这就是回弹效果的来源)。
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
/* 轻微回弹,y1 大于 1 就会“冲过头再回来” */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
/* 强回弹 */
transition-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55);
/* 阶跃:6 步走完,适合打字机、帧动画 */
transition-timing-function: steps(6, end);
steps() 是缓动函数家族里的异类,它不做插值,而是把整个过程切成 N 个离散的台阶。steps(6, jump-end) 的意思是“分 6 段跳,最后一段结束时不再跳”。它最常见的用途是打字机效果、逐帧精灵图动画,以及任何需要“卡顿感”的复古风格。
动手试一试
下面的实验台可以让同一个小球用不同的缓动函数跑完同样的距离。注意观察:距离和时长完全一致,但因为缓动不同,你会觉得它们速度差别巨大——其中回弹曲线还会冲出终点再折返。
缓动与“反向播放”的坑
还有一个大多数人没意识到的细节:当过渡反向播放时,缓动曲线也会反向。也就是说,一个用 ease-in(慢起快终)的元素,在鼠标移出、动画倒放时,会变成“快起慢终”。如果你希望移入和移出都符合直觉,就要在基础状态和 :hover 状态分别指定缓动函数:
transform: translateY(0);
/* 离开时:慢慢回到原位 */
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn:hover {
transform: translateY(-4px);
/* 进入时:干脆利落地抬起 */
transition: transform 0.18s ease-out;
}
三、transform:不触发重排的几何变换
如果只能记住一条 CSS 动画的性能建议,那一定是这条:能用 transform 实现的位移和缩放,绝对不要用 top / left / width / height。前者在渲染流水线的最后一步(合成)完成,后者则会引发整个页面的重新布局。
四类基础变换
transform: translateX(20px);
transform: translateY(-50%);
transform: translate3d(0, 20px, 0);
/* 缩放:1 是原尺寸 */
transform: scale(1.08);
transform: scaleX(1.2);
/* 旋转:正值为顺时针 */
transform: rotate(45deg);
transform: rotate(-0.5turn);
/* 斜切:会让文字变形,慎用 */
transform: skewX(-12deg);
translate 的百分比基准是元素自身的宽高,这带来一个极其常用的技巧:绝对定位居中。
.center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
更重要的是,当这个元素的尺寸变化时(比如文字增多),-50% 会自动重新计算,不需要像 margin-left: -100px 那样写死数值。
组合顺序决定最终结果
多个变换函数可以写在同一个 transform 里,但顺序不可交换。浏览器会按从左到右的顺序依次应用矩阵乘法,而矩阵乘法不满足交换律:
transform: translateX(100px) rotate(45deg);
/* B:先旋转 45°,再沿旋转后的坐标轴平移 —— 轨迹是一条 45° 斜线 */
transform: rotate(45deg) translateX(100px);
记忆方法:把它想象成你手里拿着一个物体。写在前面的操作,相当于先移动你的手,再转动物体。写反了,物体就会跑到意想不到的位置。
transform-origin:变换的支点
默认的变换原点是元素的几何中心(50% 50%)。把原点改掉,同样的变换会呈现出完全不同的效果。下面这个“菜单图标旋转成叉号”的动画,就非常依赖原点与旋转角度的配合:
transform-origin: center;
transition: transform 0.3s ease, opacity 0.2s ease;
}
/* 上下两条线各旋转 45°,中间那条淡出 */
.burger.open .line-1 { transform: translateY(8px) rotate(45deg); }
.burger.open .line-2 { opacity: 0; transform: scaleX(0); }
.burger.open .line-3 { transform: translateY(-8px) rotate(-45deg); }
注意 transform-origin 本身也是可以过渡的,但在动画过程中改变它会显得非常怪异,通常只在静态切换时使用。
3D 变换与 perspective
只要加上 perspective,平面的 transform 就立刻拥有了纵深。这是卡片翻转、3D 轮播、悬浮倾斜效果的基础:
perspective: 1000px; /* 观察者到 z=0 平面的距离 */
perspective-origin: 50% 50%;
}
.card-3d {
transform-style: preserve-3d; /* 让子元素保留三维空间 */
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.card-3d.flipped {
transform: rotateY(180deg);
}
.card-face {
backface-visibility: hidden; /* 背面不可见 */
position: absolute;
inset: 0;
}
.card-back { transform: rotateY(180deg); }
perspective 的值越小,透视越夸张、元素看起来离你越近;值越大,越接近正交投影。做卡片翻转时,800px 到 1200px 是比较舒服的区间。
为什么不建议用 scale 放大文字
scale 是纯视觉缩放,浏览器不会重新排版,也不会重新栅格化文本到新的字号——它只是把已经画好的像素放大。所以从 1 放大到 3,文字会明显发虚。如果确实需要清晰的大字号,应该改变 font-size 并接受重排的代价,或者把缩放幅度控制在小范围内(1.05~1.2 通常肉眼很难察觉模糊)。
四、@keyframes 与 animation 的八大属性
过渡只能描述“从 A 到 B”,而且必须由状态变化触发。当动画需要多个阶段、需要自动播放、需要循环、需要中途改变方向时,就该 @keyframes 出场了。
关键帧的写法
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5);
}
70% {
transform: scale(1.05);
box-shadow: 0 0 0 14px rgba(37, 99, 235, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
}
}
/* from / to 是 0% / 100% 的别名 */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
关于关键帧有三条实用规则:
- 没写的属性会被插值。如果
0%写了transform而100%没写,浏览器会拿元素的“默认值”(即没有动画时的计算值)来补全。 - 只写一个关键帧是合法的。
@keyframes blink { to { opacity: 0 } }表示“从当前状态过渡到透明”,等价于省略from。 - 关键帧内的
animation-timing-function作用于该段。这让你可以在一段动画里用不同的缓动,比如“弹起用回弹、落下用加速”。
八个长属性,一次讲透
0s,也就是说不写这个属性动画根本不会动。
ease。
infinite 表示无限循环。
normal / reverse / alternate / alternate-reverse。
none / forwards / backwards / both。
running / paused,可以在不重置进度的情况下暂停。
animation-fill-mode 是最常被忽略、也最容易造成困惑的一个。用一句话概括:
forwards:动画结束后,保持最后一帧的样式。做“淡入后一直可见”的入场动画必须加它,否则动画一结束元素就会闪回原始状态。backwards:在animation-delay期间,提前应用第一帧的样式。做错峰入场时,如果不加它,元素会在延迟期间以原始样式显示,然后突然跳到起点。both:前后都应用,最保险的选择。none:默认值,动画前后都不影响元素的样式。
.item { animation: fadeIn 0.5s ease 0.3s; }动画结束后元素闪回
opacity: 1 的默认状态;延迟期间还可能先以完全可见的状态“闪一下”。
.item { animation: fadeIn 0.5s ease 0.3s both; }延迟期间保持在起点,结束后停留在终点,视觉上完全连贯。
动手试一试
下面这个调参台把 animation 的几个关键长属性都做成了可调项。试着把 iteration-count 改成 1、把 fill-mode 改成 none,观察正方形最终停在哪里。
多个动画同时运行
用逗号可以给同一个元素挂多个动画,它们各自独立计时:
animation:
spin 1.2s linear infinite,
breathe 2.4s ease-in-out infinite alternate;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes breathe {
from { opacity: 0.5; }
to { opacity: 1; }
}
这里有个陷阱:两个动画都操作 transform 时,后声明的会完全覆盖先声明的。多个动画最好不要操作同一批属性,否则行为会变得难以预测。如果确实需要组合变换,应该在同一个关键帧里写完整的 transform 链。
用 animation-play-state 做“暂停/继续”
比起移除再添加动画类(会重置进度),直接切换 animation-play-state 更优雅:
.marquee.paused { animation-play-state: paused; }
/* 鼠标悬停时暂停跑马灯,是非常常见的交互 */
.marquee:hover { animation-play-state: paused; }
五、渲染流水线与动画性能
要写出流畅的动画,必须先知道浏览器在每一帧里做了什么。当一帧被触发时,渲染引擎会依次走过以下几个阶段:
点击每个阶段可以看到它的详细说明。核心结论是:动画的流畅程度,取决于它能跳过流水线中的多少步。
16.7 毫秒的帧预算
要让动画看起来是 60fps,每一帧的全部工作必须在约 16.7ms 内完成。这个预算需要被分摊到脚本执行、样式计算、布局、绘制、合成等所有环节上。留给你的动画的,往往只有几毫秒。
在 120Hz 的高刷屏上,这个预算进一步压缩到 8.3ms。这也是为什么“在我的电脑上看着挺流畅”不能作为验收标准——你需要在一台中端安卓机上验证。
属性代价速查表
| 动画属性 | 触发阶段 | 流畅度 | 建议 |
|---|---|---|---|
transform |
Composite | 极佳 | 位移、缩放、旋转的首选 |
opacity |
Composite | 极佳 | 淡入淡出的首选 |
filter / backdrop-filter |
Paint + Composite | 较好 | 模糊、亮度等效果,慎用于大面积元素 |
color / background-color |
Paint | 一般 | 小面积元素没问题,大面积会明显掉帧 |
box-shadow |
Paint | 一般 | 大范围阴影动画开销较高 |
width / height |
Layout | 差 | 改用 transform: scale() |
top / left / margin |
Layout | 差 | 改用 transform: translate() |
border-radius(配合布局变化) |
Paint | 一般 | 小范围使用无妨 |
下面的实验台把两种写法并排放在一起:上面一行用 left 驱动,下面一行用 transform: translateX 驱动。在性能较弱的设备上,你能明显看出差异。
will-change:提前告知,但别滥用
will-change 是给浏览器的一个提示:“这个元素的这个属性马上就要变了,请提前优化。”它最常见的用法是把元素提前提升为独立的合成层:
.card {
will-change: transform;
}
/* 更稳妥的写法:只在真正要动的时候加,动完就撤 */
.card:hover {
will-change: transform;
}
/* 绝对不要这样 —— 等于给页面上所有元素都开了图层 */
* { will-change: transform; }
will-change 的代价是什么?每个合成层都会占用显存,图层数量爆炸会显著增加内存占用,在低端设备上甚至会导致整个页面变慢。正确的心态是:把 will-change 当作一种性能补丁,而不是默认配置。只有当你在 DevTools 中确认某个动画确实掉帧、且瓶颈在合成阶段时,才考虑加上它。
用 DevTools 观察动画
Chrome DevTools 提供了几件诊断动效的利器:
- Performance 面板:录制一段交互动画,查看 FPS 曲线、主线程耗时,以及是否出现紫色的 Layout 块。如果动画期间有长条的 Layout,说明你在动画布局属性。
- Rendering 面板:勾选 Paint flashing,绿色高亮区域表示正在重绘的部分;勾选 Layer borders,可以看到合成层的边界,确认
will-change是否真的生效。 - Animations 面板:可以慢放动画、逐帧查看,还可以直接拖动时间轴修改缓动曲线并实时预览。调试入场动画时极其好用。
- CSS Overview / Coverage:检查是否有从未被使用、却写了一大堆过渡的属性。
一个非常实用的技巧是:在 Performance 面板里把 CPU 降速 4~6 倍,然后再去操作页面。中低端手机的真实体验,大致就相当于这个降速后的桌面浏览器。
六、过渡还是动画:如何选择
两者并非替代关系,而是各管一段。判断依据可以浓缩成三个问题:
| 维度 | transition | animation |
|---|---|---|
| 触发方式 | 必须有状态变化(hover / class / 属性) | 自动开始,无需触发 |
| 关键帧数量 | 只能两端(A → B) | 任意多段 |
| 循环 | 不支持 | 支持 infinite |
| 往返 | 需要两个状态互相切换 | alternate 一行搞定 |
| 暂停/继续 | 不能中途暂停 | animation-play-state |
| JS 事件 | transitionend |
animationstart / iteration / end |
| 典型场景 | 悬停反馈、菜单展开、颜色切换 | 加载动画、入场序列、持续脉动 |
几条实践建议
- 交互反馈一律用 transition。按钮、链接、输入框的状态变化天然是“状态驱动”的,用过渡最自然,也不会在页面加载时莫名其妙地动起来。
- 持续播放的动效一律用 animation。加载图标、呼吸提示、跑马灯,这些没有“另一个状态”的概念。
- 入场序列用 animation + 延迟。内容首次出现在屏幕上时没有“前一个状态”,过渡往往需要 JS 介入才能生效,而关键帧天生适合。
- 能不用 JS 就不用。除非需要根据滚动位置、鼠标坐标实时计算,否则纯 CSS 方案永远更可靠、更容易维护。
两者可以共存
一个元素上同时写 transition 和 animation 是完全合法的,只要它们不操作同一批属性。例如一个提示气泡,用 animation 做首次入场的淡入,用 transition 做悬停时的颜色变化:
/* 入场:自动播放一次 */
animation: tooltipIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
/* 悬停:状态驱动 */
background-color: #1e293b;
transition: background-color 0.2s ease;
}
@keyframes tooltipIn {
from { opacity: 0; transform: translateY(6px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
七、实战模式:八个可以直接抄的动效
理论说完了,下面是日常开发中复用率最高的几种动效写法。每一个都标注了推荐时长——这是经过大量产品验证的“体感舒适区”。
1. 按钮的悬停与按压
微交互的黄金时长是 120ms ~ 200ms。再慢就会显得迟钝,再快用户又感觉不到。悬停和按压应该用不同的幅度:悬停轻微抬起,按压则要“按下去”。
background: #2563eb;
color: #fff;
border: none;
padding: 10px 22px;
border-radius: 8px;
transform: translateY(0);
transition: transform 0.16s ease-out, box-shadow 0.16s ease-out, background-color 0.16s ease-out;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35);
}
.btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3);
}
/* 键盘用户也要有同样的反馈 */
.btn:focus-visible {
outline: 2px solid #93c5fd;
outline-offset: 2px;
}
2. 卡片浮起
卡片是列表页的主角。悬停时抬升 4px ~ 8px、阴影扩散,是最通用的处理方式。注意阴影本身是一个较贵的属性,如果列表很长,可以把过渡限制在 transform 上,用伪元素承载阴影:
position: relative;
border-radius: 12px;
background: #fff;
transform: translateY(0);
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
/* 阴影单独放在伪元素上,只过渡它的 opacity */
.card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14);
opacity: 0;
transition: opacity 0.28s ease;
pointer-events: none;
}
.card:hover { transform: translateY(-6px); }
.card:hover::after { opacity: 1; }
3. 下拉菜单的展开
菜单需要一个“从触发器生长出来”的感觉。transform-origin 指向触发按钮,配合缩放和透明度,视觉上非常连贯:
transform-origin: top left;
transform: scale(0.96) translateY(-4px);
opacity: 0;
visibility: hidden;
transition:
opacity 0.16s ease,
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
visibility 0s linear 0.2s;
}
.dropdown.open .dropdown-menu {
transform: scale(1) translateY(0);
opacity: 1;
visibility: visible;
transition-delay: 0s, 0s, 0s;
}
这里用到了一个小技巧:visibility 也是可以过渡的,只不过它的插值方式是“跳变”。把它的过渡延迟设为动画时长,就能实现“关闭时等淡出结束再隐藏”,从而避免菜单消失后仍然可以点击的问题。visibility: hidden 同时也把元素从无障碍树中移除了,比 opacity: 0 更彻底。
4. 模态框的入场
模态框通常由两层组成:半透明遮罩和内容面板。遮罩淡入,面板轻微上移放大。为了不显得拖沓,整体控制在 250ms 以内:
background: rgba(15, 23, 42, 0.45);
opacity: 0;
transition: opacity 0.22s ease;
}
.modal-panel {
transform: translateY(12px) scale(0.98);
opacity: 0;
transition:
opacity 0.22s ease,
transform 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal.show .overlay { opacity: 1; }
.modal.show .modal-panel {
transform: translateY(0) scale(1);
opacity: 1;
}
如果直接使用原生 <dialog>,可以配合 ::backdrop 伪元素和 @starting-style 实现同样的效果,同时还免费获得焦点管理。
5. 骨架屏的微光
骨架屏解决的是“加载期间页面一片空白”的焦虑感。它的关键是让高光从左到右循环扫过:
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 37%, #e2e8f0 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
@keyframes shimmer {
from { background-position: 100% 50%; }
to { background-position: 0 50%; }
}
这里动画的是 background-position,属于重绘类属性,但骨架屏通常面积不大,可以接受。如果页面里同时有几十个骨架块在动,建议改用伪元素 + transform: translateX() 来实现同样的效果。
6. 列表的错峰入场
让一列元素依次出现,比让它们同时出现更有节奏感。实现方式是为每个元素设置递增的 animation-delay:
.item {
opacity: 0;
transform: translateY(14px);
animation: staggerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes staggerIn {
to { opacity: 1; transform: translateY(0); }
}
/* 用 nth-child 生成延迟,避免写 JS */
.item:nth-child(1) { animation-delay: 0s; }
.item:nth-child(2) { animation-delay: 0.06s; }
.item:nth-child(3) { animation-delay: 0.12s; }
.item:nth-child(4) { animation-delay: 0.18s; }
.item:nth-child(5) { animation-delay: 0.24s; }
两点注意事项:单步延迟建议控制在 50ms ~ 80ms,太大就显得拖沓;列表很长时要给延迟设上限,否则第 50 个元素的入场要等到三秒后,用户早就滚过去了。用 :nth-child(n+6) 统一给后面的元素一个固定延迟即可。
7. 滚动到视口时显现
纯 CSS 无法感知元素是否进入视口,这里必须借助 IntersectionObserver。但动画本身仍然交给 CSS 负责,JS 只做一件事:加一个类。
.reveal {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.6s ease,
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
// JS:只负责切换类名
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target); // 只触发一次
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -60px 0px' });
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
两个容易踩的坑:第一,如果 JavaScript 加载失败,.reveal 会永远停留在 opacity: 0,内容彻底不可见。稳妥的做法是用 <noscript> 覆盖样式,或者在 JS 里加一个超时兜底,把元素全部显示出来。第二,unobserve 很重要——不然元素每次进出视口都会重新播放动画,页面会变得非常聒噪。
8. 加载指示器
最经典的环形加载动画,只需要两个关键帧和一点小技巧:
width: 28px;
height: 28px;
border: 3px solid #dbeafe;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
注意这里用的是 linear 而不是 ease。匀速旋转是加载动画的灵魂——用缓动会让它看起来一顿一顿的。另外,border-top-color 的变化会触发重绘,但一个 28px 的圆几乎不产生成本。
9. 列表项的删除动画
从列表中移除一项时,如果直接 display: none,其他的项会瞬间跳上来。正确做法是先播放一个退场动画,等动画结束再真正移除 DOM:
animation: itemOut 0.28s ease forwards;
pointer-events: none;
}
@keyframes itemOut {
to {
opacity: 0;
transform: translateX(28px);
max-height: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
}
// JS:等动画结束再移除节点
el.classList.add('removing');
el.addEventListener('animationend', () => el.remove(), { once: true });
注意 animationend 事件可能被多个动画触发,稳妥的做法是在监听回调里检查 event.animationName,或者给动画单独命名并只监听那一个。
八、无障碍:动画也可能是伤害
动效对大多数人来说是锦上添花,但对一部分人来说可能是实实在在的伤害。前庭功能障碍(vestibular disorders)患者在观看大幅度的位移、缩放、旋转动画时,可能出现眩晕、恶心甚至偏头痛。偏头痛和注意力障碍人群对持续闪烁的元素也非常敏感。
prefers-reduced-motion
操作系统提供了“减少动态效果”的开关(macOS:辅助功能 → 显示 → 减少动态效果;Windows:设置 → 辅助功能 → 视觉效果 → 动画效果)。CSS 可以通过媒体查询读取这个偏好:
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
这段“一刀切”的写法是最省事的做法,但它也会把一些完全无害的淡入淡出一起干掉。更细致的做法是分类处理:
.hero-card {
animation: floatUp 3s ease-in-out infinite alternate;
}
/* 减少动效:保留状态变化,去掉位移与循环 */
@media (prefers-reduced-motion: reduce) {
.hero-card {
animation: none;
/* 用透明度变化替代位移,仍然有反馈但不引起不适 */
transition: opacity 0.3s ease;
}
}
哪些动画最需要被削减
- 大面积位移:整屏滑动、视差滚动、大幅度的横向移动。
- 缩放与旋转:尤其是持续旋转的装饰元素和背景。
- 无限循环:任何
infinite的动画都可能成为视觉噪声源。 - 高频闪烁:每秒闪烁超过 3 次的元素,对光敏性癫痫患者有诱发风险。
- 自动播放的视频与轮播:应该默认暂停,或至少提供停止按钮。
动画与焦点
除了前庭问题,动效还会影响键盘用户:
- 不要用动画延迟焦点反馈。用户按下 Tab 时,焦点环应该立即出现,而不是先淡入 300ms。
- 不要在动画进行中移动焦点。如果元素正在飞入,此时聚焦它会让用户看不清焦点环在哪里。
- 尊重
prefers-reduced-motion时,别忘了scroll-behavior: smooth。平滑滚动对很多人来说是眩晕的元凶,上面那段媒体查询里已经包含了它。
outline: none 去掉焦点环,再用 300ms 的 box-shadow 动画代替它。键盘用户完全不知道自己当前在哪。
:focus-visible 轮廓,额外的动效只作为装饰叠加,不承担传递状态的职责。
九、十个最常见的坑与调试技巧
transition: all 0.3s,然后被某个布局属性的变化拖垮性能
display: none 隐藏元素,然后疑惑为什么淡入动画不生效
height: auto 或 width: fit-content,发现完全没有过渡
animation-duration,或者忘了 fill-mode: forwards
@keyframes 里同时改变 transform 和别的属性,导致组合顺序失控
will-change,显存被大量合成层吃光
left / top 做位移动画,在低端机上疯狂掉帧
transitionend 被多次触发,回调被执行了好几遍
prefers-reduced-motion,让部分用户直接无法使用页面
几个值得展开的细节
关于 transitionend:如果一个元素同时过渡了 transform 和 opacity,那么当 transform 先结束时就会触发一次 transitionend,opacity 结束时再触发一次。如果回调里有移除元素、切换路由这类重操作,就会被执行两次。解决办法是判断 event.propertyName:
if (e.propertyName !== 'transform') return;
// 真正的收尾逻辑
});
另外要注意:如果过渡在完成前被打断(比如用户快速来回悬停),transitionend 根本不会触发,取而代之的是 transitioncancel。做复杂的状态管理时需要考虑这种情况。
关于 !important 对动画的影响:关键帧内部的声明优先级高于普通声明,但 !important 的普通声明又会反过来压过关键帧。这会导致动画“看起来没生效”。如果发现某个属性怎么都不动,先检查它是不是被 !important 覆盖了。
关于动画与 CSS 变量的配合:@keyframes 里使用 var() 是合法的,但有一个重要限制——变量不会在关键帧内部被逐帧重新计算。也就是说,如果你在动画运行期间通过 JS 改变了变量的值,动画不会立刻反映新值,只有下一次重新开始动画时才会生效。
.slider {
--distance: 240px;
animation: slide var(--duration, 0.6s) ease both;
}
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(var(--distance)); }
}
/* 想让它重新读值:先移除动画,强制回流,再加回来 */
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';
这个“移除 → 回流 → 恢复”的三步模式,是重置 CSS 动画的标准套路,值得记在备忘录里。
三个调试小技巧
- 把时长放大 10 倍。调试复杂动画时,临时把 0.3s 改成 3s,能让你看清每一帧发生了什么。调完再改回去。
- 用 outline 标记正在动的元素。给动画元素临时加
outline: 1px solid red,比在 DevTools 里翻来翻去快得多。 - 在 DevTools 的 Animations 面板里暂停。按下暂停键后拖动时间轴,可以逐帧检查中间状态,尤其适合排查关键帧之间的插值是否符合预期。
十、动效设计检查清单与代码基线
把前面所有内容浓缩成一份可以在提交代码前扫一眼的清单。
- 时长有依据:微交互 120–200ms,面板开合 250–350ms,页面级过渡 400–600ms,不要全站一个值。
- 缓动有区分:入场用
ease-out,离场用ease-in,循环用linear,往返用ease-in-out。 - 不写
transition: all,显式列出需要过渡的属性。 - 位移与缩放一律用
transform,不用top/left/width/height。 - 淡入淡出用
opacity,不用visibility或display做视觉过渡。 - 关键帧动画记得写
animation-duration,并确认fill-mode符合预期。 - 入场动画用
both,避免延迟期间的样式闪烁和结束后的回弹。 - 错峰延迟有上限,单步 50–80ms,长列表封顶。
will-change用在刀刃上,不要全局声明,用完能撤就撤。- 提供
prefers-reduced-motion降级,至少关闭无限循环和大面积位移。 - 焦点反馈不延迟,
:focus-visible的轮廓必须即时可见。 - 滚动显现动画有 JS 失效兜底,确保内容在任何情况下都可见。
- 监听
transitionend/animationend时判断属性名或动画名,避免重复执行。 - 在 DevTools 里降速 CPU 验证过,不只是在开发机上看着流畅。
- 移动端实测过,尤其是中低端安卓设备。
:root {
/* 时长 */
--dur-fast: 0.15s; /* 悬停、按压 */
--dur-base: 0.25s; /* 面板、下拉 */
--dur-slow: 0.45s; /* 页面级过渡 */
/* 缓动 */
--ease-out-soft: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-soft: cubic-bezier(0.7, 0, 0.84, 0);
--ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 统一的无障碍降级 */
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 0.01ms;
--dur-base: 0.01ms;
--dur-slow: 0.01ms;
}
*, *::before, *::after {
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
/* 常用组合类 */
.lift {
transform: translateY(0);
transition: transform var(--dur-fast) var(--ease-out-soft);
}
.lift:hover { transform: translateY(-4px); }
.lift:active { transform: translateY(-1px) scale(0.99); }
.fade-in {
animation: fadeIn var(--dur-base) var(--ease-out-soft) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
CSS 过渡与动画最有趣的地方在于,它是一个投入产出比极不均衡的领域:写对一个缓动曲线可能只需要改几个字符,但用户对产品的“高级感”评价会因此完全不同。同时,它又是一个容易过犹不及的领域——动画加得越多,页面越像演示文稿,反而降低了可用性。
所以真正的功夫不在于会写多少种效果,而在于判断:这一处,到底需不需要动?如果要动,它应该多快、用什么曲线、在哪一层完成、对谁不友好?把这四个问题回答清楚,你的动效就已经超过了绝大多数项目。