如果说 transition 是“两个状态之间的自动补间”,那 @keyframes 就是“把一整条时间轴交到你手里”。它让你能在 0% 到 100% 之间任意插入控制点,让元素在多个状态间来回穿梭、停顿、加速、阶跃、循环。但真正把关键帧动画用好的人并不多:有人写了一屏 @keyframes 却不知道插值规则,有人用 steps() 打字却对不齐字符,有人给动画加了负延迟却解释不清为什么,还有人把 animation-play-state 当成“暂停按钮”却不知道它背后发生了什么。这篇 40 分钟的深度长文,从 @keyframes 的解析机制讲起,穿过关键帧选择器、插值规则、八大子属性、阶跃函数、负延迟相位、CSS 变量、WAAPI 控制、性能与无障碍,配合多个可实时操作的实验台,帮你把关键帧动画从“会写”升级到“能控”。
关键帧动画的本质:把时间轴交给你
transition 的世界里只有两个点:起始状态和结束状态,浏览器在这两点之间做一次插值,走完就结束。而 @keyframes 的世界里,你可以定义任意多个点,浏览器会把这些点按百分比串成一条完整的时间轴,然后在相邻的两个关键帧之间逐段插值。
理解这一点非常关键:关键帧动画不是“从 A 到 B”,而是“在 0% 到 100% 之间,依次经过 A、B、C、D……”。每一个百分比都是一个“必须准时到达的检查点”,浏览器只负责把这些检查点之间的空隙填满。
关键帧之间的每一段都可以独立控制,但整条动画只能有一个 timing-function——
除非你在关键帧内部单独声明 animation-timing-function,这一点后面会详细讲。
关键帧动画的四个解析阶段
浏览器处理一段 @keyframes 动画,大致会经历下面四个阶段。点击每个阶段查看细节。
@keyframes 里的每个选择器解析成 0~1 之间的进度值,from 等价于 0%,to 等价于 100%。同一进度上的多个关键帧会被合并。!important 覆盖(关键帧内部的 !important 会被忽略)。transform 与 opacity,则只走合成,主线程几乎不参与。@keyframes float {
from { transform: translateY(0); }
to { transform: translateY(-12px); }
}
/* 使用它 */
.card {
animation: float 2s ease-in-out infinite alternate;
}
/* 完全等价于 */
.card {
animation-name: float;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
@keyframes 语法完全解剖
看似简单的 @keyframes,其实藏着不少容易被忽略的规则。把它们一次讲清楚,能避免大量“为什么我的动画没生效”的困惑。
命名规则
关键帧名称是一个自定义标识符,遵循 CSS 标识符的一般规则:可以包含字母、数字、下划线、连字符,不能以数字开头,也不能是 none、initial、inherit、unset 这些 CSS 关键字。名称区分大小写吗?在 CSS 中,关键帧名称是区分大小写的,Float 和 float 是两个不同的动画。
@keyframes fadeIn { }
@keyframes fade-in { }
@keyframes fade_in_2 { }
@keyframes 淡入 { } /* 中文也可以,但不推荐 */
/* ❌ 非法命名 */
@keyframes 2fade { } /* 数字开头 */
@keyframes none { } /* 保留关键字 */
@keyframes fade in { } /* 含空格 */
关键帧选择器:不只是 from / to
关键帧选择器可以是 from、to,也可以是任意百分比。百分比可以是小数,可以是负数(负数会被当作 0%),也可以大于 100%(会被当作 100%)。
@keyframes bounce {
0% { transform: translateY(0); }
15% { transform: translateY(-40px); }
30% { transform: translateY(0); }
45% { transform: translateY(-22px); }
60% { transform: translateY(0); }
75% { transform: translateY(-10px); }
100% { transform: translateY(0); }
}
/* 多选择器合并:逗号分隔 */
@keyframes fade {
from, 40% { opacity: 0; }
60%, to { opacity: 1; }
}
/* 同一百分比写多个块 —— 会被合并 */
@keyframes merge {
50% { opacity: 0.5; }
50% { transform: scale(1.2); }
}
/* 等价于 50% { opacity: 0.5; transform: scale(1.2); } */
关键帧里不该出现的东西
!important会被忽略:关键帧声明中的!important是无效的,浏览器会直接丢弃该声明。- 不要重复写不变的属性:只写需要变化的部分,其余属性会从元素的计算样式继承。
- 不要在关键帧里写选择器:关键帧内部只能包含属性声明,不能嵌套选择器(CSS 嵌套也不会在里面生效)。
- 不要依赖未定义属性的“默认值”:如果在
0%写了opacity却没写transform,那么transform会取元素自身的计算值,而不是none。
@keyframes bad {
to { opacity: 1 !important; }
}
/* ✅ 正确:直接写声明 */
@keyframes good {
to { opacity: 1; }
}
/* 元素自身有 transform 时,关键帧里最好把它写全 */
.tilted {
transform: rotate(8deg);
animation: grow 1s ease infinite alternate;
}
@keyframes grow {
from { transform: rotate(8deg) scale(1); }
to { transform: rotate(8deg) scale(1.1); }
}
/* 否则 rotate(8deg) 会在动画开始瞬间丢失 */
交互实验室:亲手画一条关键帧曲线
下面这个编辑器把 @keyframes 的时间轴变成了可点击的画布。在虚线区域里点击,就会在对应的百分比位置添加一个关键帧;纵轴决定该关键帧的 translateY 值。系统会实时生成对应的 CSS 并在下方播放。
观察要点:
- 关键帧之间是线性插值的,所以两个点之间的轨迹永远是一条直线段;
- 把
0%和100%的值设成一样,动画就会自然循环,不会“跳一下”; - 把某个关键帧的
translateY设得特别大,会看到动画在这一段明显“加速”; - 浏览器会自动对关键帧百分比排序,所以你可以先点 80% 再点 20%,结果是一样的。
animation 的八大子属性,一次讲透
animation 简写背后是八个独立属性。它们各自控制动画的一个维度,组合起来才是一条完整的时间轴。
@keyframes,默认 none0s(不播放)ease1,可设 infinitenormalnonerunning简写顺序与陷阱
animation 简写对顺序相当宽容,但有两个值必须靠“位置”区分:第一个时间值是 duration,第二个时间值是 delay。这个规则经常被记反。
.a { animation: float 2s ease 0.5s infinite alternate both; }
/* ❌ 常见错误:两个时间值写反了 */
.b { animation: float 0.5s ease 2s infinite; }
/* 实际变成:duration 0.5s,delay 2s —— 完全不是你想要的 */
/* ✅ 只写一个时间值时,它一定是 duration */
.c { animation: float 2s infinite; }
/* ✅ 多个动画用逗号分隔 */
.d {
animation:
float 2s ease-in-out infinite alternate,
glow 1.5s ease-in-out infinite;
}
/* ⚠️ 多动画时,每个动画的 delay 要分别写 */
.e {
animation:
float 2s ease 0s infinite,
glow 1.5s ease 0.3s infinite;
}
fill-mode:最容易被低估的一个值
animation-fill-mode 决定了动画在“开始之前”和“结束之后”这段时间里,元素应该呈现什么状态。它的四个值经常被混淆:
| 取值 | 延迟期间 | 结束之后 | 典型场景 |
|---|---|---|---|
none |
元素自身样式 | 元素自身样式 | 默认值,适合可逆的循环动画 |
forwards |
元素自身样式 | 保留最后一帧 | 入场动画结束后停在终点 |
backwards |
应用第一帧 | 元素自身样式 | 延迟期间就摆好起跑姿势 |
both |
应用第一帧 | 保留最后一帧 | 几乎万能的组合 |
fill-mode,结果元素在 animation-delay 期间保持可见,动画开始时“闪一下”才消失。
both:延迟期间就呈现第一帧(通常是 opacity: 0),结束后停在最后一帧。
插值规则:什么能动画,什么不能
关键帧动画的每一段都在做“插值”。但并非所有 CSS 属性都能插值,理解插值类型,能避免大量“为什么这个属性没有过渡效果”的困惑。
三类可动画属性
- 可插值(interpolable):数值、长度、颜色、变换函数等,能在两个值之间平滑过渡。
opacity、transform、color、background-color、width都属于这一类。 - 离散(discrete):没有中间状态,只能在两个值之间跳变。
display、visibility、position、font-family属于这一类。默认在 50% 处跳变。 - 不可动画(not animatable):完全不能出现在关键帧里产生效果,例如
animation-name自身。
离散属性的“跳变点”
离散属性并非完全不能用,而是会在整段动画的中间点突然切换。通过 animation-timing-function: steps(1, jump-start) 之类的技巧,还可以把跳变点挪到段落开头。
@keyframes appear {
from { visibility: hidden; }
to { visibility: visible; }
}
/* 用 steps(1, jump-start) 让它一开始就切换 */
.instant {
animation: appear 0.2s steps(1, jump-start) forwards;
}
/* 现代方案:允许离散属性过渡 */
.modern {
transition: display 0.25s allow-discrete;
}
/* 自定义属性(--x)默认也是离散的,除非用 @property 声明类型 */
@property --my-rot {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
性能代价:哪些关键帧最贵
把关键帧里出现的属性按代价分成三档。绿色放心用,橙色谨慎用,红色尽量避免。
| 属性 | 触发阶段 | 代价 | 建议 |
|---|---|---|---|
transform |
Composite | 低 | 位移、缩放、旋转的首选 |
opacity |
Composite | 低 | 淡入淡出的首选 |
filter |
Paint + Composite | 中 | 模糊、亮度变化可用,别大面积长时运行 |
background-color |
Paint + Composite | 中 | 小面积安全,大面积慎用 |
box-shadow |
Paint + Composite | 中 | 用伪元素 + opacity 替代更划算 |
clip-path |
Paint + Composite | 中 | 做遮罩动画很香,但别嵌套太多 |
width / height |
Layout + Paint + Composite | 高 | 改用 transform: scale() |
top / left |
Layout + Paint + Composite | 高 | 改用 transform: translate() |
margin / padding |
Layout + Paint + Composite | 高 | 几乎不该出现在关键帧里 |
font-size |
Layout + Paint + Composite | 高 | 改用 transform: scale() 或 SVG |
需要特别提醒的是:关键帧内使用的属性,决定了整条动画的代价下限。只要有一个关键帧写了 width,整条动画的每一帧都会走布局。因此,写关键帧时要像写接口一样克制——只写必须变化的属性。
steps() 阶跃函数:让动画“跳着走”
缓动函数不只有贝塞尔曲线,steps() 同样是 animation-timing-function 的一等公民。它把一段动画切成若干等份,每份之间不插值,而是直接跳到下一个值——这正是打字机、精灵图、机械感 UI 的基础。
animation-timing-function: steps(<整数>, <跳变点>);
/* 跳变点有四个取值 */
steps(4, jump-start) /* 段落一开始就跳 */
steps(4, jump-end) /* 段落结束时才跳(默认) */
steps(4, jump-none) /* 不跳,两端都保留 */
steps(4, jump-both) /* 两端都跳 */
/* start / end 是旧语法,分别等价于 jump-start / jump-end */
steps(4, start) /* = jump-start */
steps(4, end) /* = jump-end */
打字机效果:steps() 最经典的用法
打字机的本质是“宽度按字符数分步增长”。只要把 width 从 0 到目标宽度的过程切成与字符数相同的步数,就能得到逐字出现的效果。
.typewriter {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: 2px solid #2563eb;
animation:
typing 3.6s steps(18, end) infinite,
caret 0.7s step-end infinite;
}
@keyframes typing {
from { width: 0; }
to { width: 210px; }
}
@keyframes caret {
50% { border-color: transparent; }
}
/* 光标闪烁用 step-end,避免渐变导致“呼吸感” */
/* 步数应等于字符数,多一步少一步都会歪 */
上方为 steps(18) 逐字打字,下方为 steps(6) 的粗颗粒阶跃
精灵图动画:steps() 的另一个主战场
把多帧画面横向拼成一张图,然后用 background-position 配合 steps() 逐帧切换,这是游戏角色、加载动画的经典做法。
.sprite {
width: 64px;
height: 64px;
background-image: url(sprite.png);
background-repeat: no-repeat;
animation: play 0.8s steps(8, end) infinite;
}
@keyframes play {
from { background-position: 0 0; }
to { background-position: -512px 0; }
}
/* ⚠️ 精灵图动画必然触发重绘,元素面积务必小 */
/* ✅ 更好的做法:用
还有一类常见需求:用 steps() 做“数字滚动”“倒计时翻牌”“进度条分段推进”。它们的共同点是——不需要平滑,反而需要明确的“跳变”来强调节奏。
负延迟:让动画从半路开始的魔法
animation-delay 可以是负数。它的语义不是“提前开始”,而是“假装动画已经播放了这么久”。也就是说,动画会从时间轴的对应位置直接切入,而不是从头开始。
.dot {
animation: spin 3s linear infinite;
}
/* 但它们从时间轴的不同位置切入 */
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: -1s; }
.dot:nth-child(3) { animation-delay: -2s; }
/* 结果:三个点均匀分布在一个循环里,形成“流动”效果 */
/* 不需要写三段不同的 @keyframes */
负延迟的三个实用场景
- 相位错开:一组点 / 波纹 / 光斑做同一个循环动画,用负延迟错开相位,比手写多段关键帧优雅得多。
- 跳过入场动画:页面加载时,某些元素不想播放“从无到有”的过程,直接用负延迟让它从中间状态开始。
- 模拟“已经在运行”:比如一个持续旋转的加载图标,页面切换回来时希望它看起来一直在转,负延迟正好能做到。
.ripple {
animation: ripple 2.4s ease-out infinite;
}
.ripple:nth-child(1) { animation-delay: -0s; }
.ripple:nth-child(2) { animation-delay: -0.8s; }
.ripple:nth-child(3) { animation-delay: -1.6s; }
/* 用 CSS 变量统一生成,避免手写 */
.ripple {
--i: 0;
animation-delay: calc(var(--i) * -0.8s);
}
注意:负延迟的绝对值不应该超过 animation-duration。如果 delay: -5s 而 duration: 3s,浏览器会做取模运算,实际效果等同于 -2s。虽然结果正确,但写在代码里会让人困惑,建议始终保持在 [-duration, 0] 区间内。
关键帧里的 CSS 自定义属性
很多人以为 @keyframes 里不能使用 CSS 变量。事实是:变量可以用,但插值行为取决于它是否被 @property 声明了类型。
:root { --dist: 200px; }
@keyframes move {
to { transform: translateX(var(--dist)); }
}
/* 这里是可以的,因为 var() 在 transform 内部被解析成了长度 */
/* 情况二:直接动画变量本身 —— 需要 @property */
@property --progress {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@keyframes fill {
from { --progress: 0; }
to { --progress: 1; }
}
/* 情况三:用变量做“参数化关键帧” */
.chip {
--i: 0;
animation-delay: calc(var(--i) * 60ms);
animation-duration: calc(0.5s + var(--i) * 20ms);
}
/* 一个关键帧,服务无数个不同参数的实例 */
@keyframes pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(calc(1 + var(--overshoot, 0.08))); }
100% { transform: scale(1); opacity: 1; }
}
变量带来的一个陷阱:动画不可缓存
如果关键帧里用了 CSS 变量,浏览器就无法把关键帧“预编译”成固定值。这意味着每次变量变化,整条动画都需要重新计算。对性能敏感的动画,尽量使用固定值,把变量用在 animation-delay、animation-duration 这类元参数上,而不是关键帧内部。
@keyframes m { to { transform: translateX(var(--x)); } }变量一变,动画就需要重新解析与计算。
@keyframes m { to { transform: translateX(200px); } }固定值可被浏览器缓存,性能更稳定。
播放控制:play-state 与 WAAPI
CSS 关键帧动画一旦开始,似乎就“停不下来”了。其实并非如此——animation-play-state 就提供了最基础的暂停 / 恢复能力,而 Web Animations API 则把控制粒度提升到了毫秒级。
.paused {
animation-play-state: paused;
}
/* 注意:paused 只是“冻结”在当前帧,不会重置 */
/* 而且子元素的动画不会自动跟着暂停,需要单独处理 */
/* 方案二:WAAPI 精细控制 */
const anim = el.getAnimations()[0];
anim.pause();
anim.play();
anim.reverse();
anim.playbackRate = 0.5; /* 慢放 */
anim.currentTime = 800; /* 跳到 800ms */
anim.cancel(); /* 取消,回到初始 */
/* 读取状态 */
console.log(anim.playState); /* running / paused / finished */
console.log(anim.currentTime); /* 当前时间(ms) */
console.log(anim.effect.getTiming());
/* 事件 */
anim.onfinish = () => { /* 播放完成 */ };
anim.oncancel = () => { /* 被取消 */ };
anim.onremove = () => { /* 被移除 */ };
/* 一次性拿到元素上所有动画(含 CSS 动画) */
const all = el.getAnimations();
all.forEach((a) => a.updatePlaybackRate(1.5));
getAnimations() 是被低估的 API
element.getAnimations() 会返回该元素上所有处于活动状态的动画对象,包括由 CSS @keyframes 产生的动画。这意味着你可以用纯 CSS 写动画,却用 JS 精确控制它,两者并不冲突。
- 做「鼠标悬停时慢放」的效果:
hover时把所有动画的playbackRate降到 0.3。 - 做「页面不可见时暂停」:监听
visibilitychange,统一暂停 / 恢复所有动画,省电。 - 做「时间轴对齐」:多个元素的动画通过
currentTime对齐到同一时刻,实现完美的同步动效。
document.addEventListener('visibilitychange', () => {
const state = document.hidden ? 'paused' : 'running';
document.getAnimations().forEach((a) => {
if (state === 'paused') a.pause();
else a.play();
});
});
/* 鼠标悬停时慢放,提升可读性 */
card.addEventListener('pointerenter', () => {
card.getAnimations().forEach((a) => a.updatePlaybackRate(0.3));
});
card.addEventListener('pointerleave', () => {
card.getAnimations().forEach((a) => a.updatePlaybackRate(1));
});
关键帧的性能优化:从属性选择到图层控制
关键帧动画的性能优化,核心只有一句话:让动画尽量停留在合成阶段。围绕这个目标,有几条具体策略。
策略一:优先动画 transform 与 opacity
这两个属性可以完全在合成线程上处理,主线程只需要在动画开始时做一次计算。即使主线程被大量 JS 阻塞,动画依然可以保持流畅。
策略二:用 transform 模拟其他属性
很多看似必须动画布局属性的效果,其实都能用 transform 模拟:
| 想做的效果 | ❌ 昂贵写法 | ✅ 推荐写法 |
|---|---|---|
| 水平移动 | left |
transform: translateX() |
| 尺寸变化 | width / height |
transform: scale() |
| 旋转 | rotate 属性(兼容性差) |
transform: rotate() |
| 阴影扩散 | box-shadow |
伪元素 + opacity / transform: scale() |
| 遮罩展开 | width / clip |
transform: scaleX() + transform-origin |
| 高度展开 | height: 0 → auto |
transform: scaleY() 或 grid-template-rows |
策略三:谨慎使用 will-change
will-change 是给浏览器的“预告函”,告诉它某个属性即将变化,请提前准备。用得好能消除首帧抖动,用不好会吃掉大量显存。
.card {
transition: transform 0.3s ease;
}
.card:hover {
will-change: transform;
transform: translateY(-4px);
}
/* ✅ 用 JS 在动画开始前短暂添加 */
el.addEventListener('animationstart', () => {
el.style.willChange = 'transform';
});
el.addEventListener('animationend', () => {
el.style.willChange = 'auto';
});
/* ❌ 陷阱一:全局通配符 */
* { will-change: transform; }
/* ❌ 陷阱二:常驻不撤销 */
.always { will-change: transform, opacity, filter; }
/* ❌ 陷阱三:声明了根本不会被动画的属性 */
.useless { will-change: width, height, top, left; }
策略四:控制图层数量
每个合成层都会占用显存:一个 1920×1080 的层大约占 8.3MB。如果页面里有几十个元素同时被提升为合成层,低端设备很容易显存吃紧甚至崩溃。
.promote-1 { transform: translateZ(0); }
.promote-2 { backface-visibility: hidden; }
.promote-3 { will-change: transform; }
/* ✅ 更好:用 contain 隔离重绘范围,而不是强行分层 */
.widget {
contain: layout paint;
}
/* ✅ 长列表用 content-visibility 跳过屏幕外渲染 */
.feed-item {
content-visibility: auto;
contain-intrinsic-size: auto 240px;
}
策略五:限制同时运行的动画数量
即使是纯合成动画,也会消耗 GPU 带宽。当页面上同时有上百个元素在做循环动画时,低端设备的 GPU 会成为瓶颈。常见做法:
- 屏幕外的元素暂停动画(
IntersectionObserver+getAnimations())。 - 页面不可见时暂停全部动画(
visibilitychange)。 - 列表虚拟化,只渲染视口内的项。
- 把多个装饰性动画合并到一个容器上,而不是每个子元素各自动画。
关键帧的可维护性:让动画也能被“阅读”
一个项目里几十条 @keyframes 散落在各个文件,是维护的噩梦。关键帧动画同样需要命名规范与组织策略。
命名规范
- 用动词或动词短语:
fadeIn、slideUp、pulse、shake,一看就知道在做什么。 - 避免泛化命名:
anim1、motion、effect是灾难的开始。 - 加前缀区分来源:
ui-fadeIn、chart-grow、icon-spin。 - 同一类动画用同一词根:
slideInLeft、slideInRight、slideOutUp。
用 CSS 变量做参数化
与其为每个元素写一段 @keyframes,不如写一条通用的、可参数化的关键帧,然后用变量去驱动它。
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(var(--fade-dist, 20px));
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 使用时按需调整参数 */
.card { --fade-dist: 12px; }
.hero-copy{ --fade-dist: 40px; }
.toast { --fade-dist: 8px; }
/* 统一的动画基类 */
.anim-in {
animation:
fadeUp var(--anim-dur, 0.5s)
var(--anim-ease, cubic-bezier(0.2, 0, 0, 1))
var(--anim-delay, 0s)
both;
}
全局动画令牌
把时长与缓动函数抽成全局变量,是团队协作中最划算的投入。它让动画的“手感”在整个产品里保持一致。
:root {
/* 时长 */
--dur-instant: 80ms;
--dur-fast: 120ms;
--dur-base: 220ms;
--dur-slow: 360ms;
--dur-slower: 520ms;
/* 缓动 */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-emphasized: cubic-bezier(0.05, 0.7, 0.1, 1);
}
/* 使用 */
.modal {
animation: modalIn var(--dur-base) var(--ease-decelerate) both;
}
.toast {
animation: toastIn var(--dur-slow) var(--ease-spring) both;
}
错峰动画不要手写 nth-child
为列表项做错峰入场时,手写 nth-child 是常见的错误做法。更好的方式是用 CSS 变量传递索引,让一条规则服务所有元素。
.item:nth-child(1) { animation-delay: 0.05s; }
.item:nth-child(2) { animation-delay: 0.10s; }
.item:nth-child(3) { animation-delay: 0.15s; }
/* ……一直写到第 20 个 */
/* ✅ 巧办法:CSS 变量 + calc */
.item {
--i: 0;
opacity: 0;
transform: translateY(16px);
animation: fadeUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: calc(var(--i) * 70ms);
}
/* JS 只需给索引 */
items.forEach((el, i) => el.style.setProperty('--i', i));
无障碍:关键帧动画必须尊重用户偏好
前庭功能障碍、偏头痛、眩晕症用户会对大幅度动效产生生理不适。操作系统提供了“减少动态效果”开关,CSS 通过 prefers-reduced-motion 媒体查询读取这一偏好。忽略它,不仅是技术问题,更是伦理问题。
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 更优雅:保留淡入淡出,去掉位移与缩放 */
@media (prefers-reduced-motion: reduce) {
.anim-in {
animation: fadeOnly 0.2s ease both;
}
.parallax {
transform: none !important;
}
}
/* 单独控制某一条关键帧动画 */
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}
在 JS 中读取同一偏好
if (mq.matches) {
/* 跳过 JS 驱动的动画,直接呈现最终状态 */
el.getAnimations().forEach((a) => a.finish());
}
/* 监听偏好变化:用户可能在页面打开期间切换了系统开关 */
mq.addEventListener('change', (e) => {
if (e.matches) {
document.getAnimations().forEach((a) => a.finish());
}
});
还有一点常被忽略:不要让动画成为唯一的信息载体。如果某个状态只通过动画来表达(比如“保存成功”仅靠一个闪动),那么关闭动画的用户就会丢失信息。正确的做法是同时提供文字、图标或颜色变化。
关键帧动画的调试技巧
关键帧动画“没生效”的原因往往不是语法错误,而是细节被忽略。下面是一套排查流程与常见症状对照表。
/* 1. animation-name 是否与 @keyframes 完全一致(区分大小写)*/
@keyframes FadeIn { }
.a { animation-name: fadeIn; } /* ❌ 大小写不匹配 */
/* 2. animation-duration 是否为 0(默认值就是 0s)*/
.b { animation: fadeIn; } /* ❌ 没有时长,不会播放 */
/* 3. 简写中两个时间值是否写反 */
.c { animation: fadeIn 0.3s ease 2s; }
/* duration 0.3s,delay 2s —— 确认这是你想要的 */
/* 4. 动画是否被更具体的选择器覆盖 */
/* 用 DevTools 的 Computed 面板查看 animation-* 最终值 */
/* 5. 关键帧里的属性是否被元素自身的 !important 覆盖 */
.d { transform: none !important; }
/* 这会让关键帧里的 transform 完全失效 */
/* 6. 元素是否被 display: none 隐藏 */
/* 隐藏元素的动画不会运行,直到重新可见 */
fill-mode: both
fill-mode: forwards
DevTools 中的动画面板
- Animations 面板:可以查看当前页面上所有正在运行的动画,拖动时间轴滑块手动控制进度,甚至调整速度(10%、25%、100%)。
- 暂停 / 播放按钮:全局暂停所有动画,方便截图或检查中间状态。
- Element 面板的 animation 徽章:元素旁边会显示一个动画图标,悬停可快速查看。
- Rendering 面板的 Paint flashing:绿色闪烁区域就是正在重绘的部分,面积越大越慢。
- Performance 面板:录制一段动画,展开 Main 线程火焰图,看 Recalculate Style / Layout / Paint 的耗时。
一个实用的调试技巧:把速度调到 10%
在 Animations 面板里把播放速度调到 10%,很多平时看不清的问题会立刻暴露:缓动函数是否平滑、关键帧之间的过渡是否自然、有没有意外的跳变、元素的进出场是否符合预期。这比反复调整代码高效得多。
三个完整案例:把知识串起来
案例一:高性能的脉冲提示点
常见于地图标记、通知红点、实时状态指示。要求持续循环、性能友好、不干扰阅读。
<span class="pulse-dot"></span>
/* CSS */
.pulse-dot {
position: relative;
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: #10b981;
}
.pulse-dot::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: #10b981;
animation: pulse 2s ease-out infinite;
will-change: transform, opacity;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.7; }
70% { transform: scale(3); opacity: 0; }
100% { transform: scale(3); opacity: 0; }
}
/* 关键点:
1. 只用 transform + opacity,纯合成
2. 70% 到 100% 保持 opacity: 0,形成“停顿”节奏
3. 光环放在伪元素上,不占用额外 DOM */
案例二:滚动指示器
页面顶部的阅读进度条,或者某个容器内的滚动提示。关键是不要监听 scroll 事件,避免主线程压力。
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: #2563eb;
transform-origin: 0 50%;
animation: grow linear;
animation-timeline: scroll();
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* ⚠️ 注意:用 scaleX 而非 width,避免触发布局 */
/* ⚠️ 需要 animation-duration: auto 或省略 */
/* 退化方案:IntersectionObserver 或 rAF 节流 */
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const r = window.scrollY / (document.body.scrollHeight - innerHeight);
bar.style.transform = `scaleX(${r})`;
ticking = false;
});
}, { passive: true });
案例三:模态框的进入与退出
模态框需要两个方向的动画:打开时淡入 + 上移,关闭时淡出 + 下移。用两条关键帧配合状态类即可。
@keyframes modalIn {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
/* 状态类驱动 */
.modal {
animation: modalIn 0.28s cubic-bezier(0, 0, 0.2, 1) both;
}
.modal.closing {
animation: modalOut 0.2s cubic-bezier(0.4, 0, 1, 1) both;
}
/* 关闭时等动画结束再卸载 DOM */
function closeModal(modal) {
modal.classList.add('closing');
modal.addEventListener('animationend', () => {
modal.remove();
}, { once: true });
}
/* 注意:进入用 decelerate 曲线,退出用 accelerate 曲线 */
/* 退出动画时长应短于进入动画,符合用户的“离开”预期 */
兼容性与渐进增强
关键帧动画本身的支持度非常好,但一些现代控制能力仍有差异。下面是关键特性的支持现状与退化策略。
/* @keyframes / animation —— 全平台,IE10+ 都支持 */
/* animation-fill-mode —— 全平台 */
/* animation-play-state —— 全平台 */
/* steps() 四参数语法 —— Chrome 77+ / Safari 14+ */
/* @property —— Chrome 85+ / Safari 16.4+ */
/* getAnimations() —— Chrome 84+ / Safari 13.1+ */
/* animation-timeline —— Chrome 115+ */
/* transition-behavior —— Chrome 117+ */
/* 渐进增强:先保证基础可用 */
.card {
/* 基础状态:元素本来就在正确位置 */
opacity: 1;
transform: none;
}
/* 支持时才加动画 */
@supports (animation-timeline: view()) {
.card {
animation: fadeUp linear both;
animation-timeline: view();
}
}
/* 用特性检测决定是否走 JS 路径 */
if (CSS.supports('animation-timeline', 'view()')) {
/* 原生滚动驱动,什么都不用做 */
} else {
initIntersectionObserverFallback();
}
@keyframes / animation
getAnimations / WAAPI
@property 类型声明
animation-timeline,需退化
实践建议:把新特性当成“锦上添花”,而不是“雪中送炭”。基础的关键帧动画在所有浏览器上都要能正常工作,新特性只在支持的浏览器上提供额外的手感提升。
关键帧动画最佳实践清单
把全文的核心结论浓缩成一份可以贴在工位上的清单。
- 只动画 transform 与 opacity:这两个属性只走合成,是性能的底线。
- 关键帧里只写变化的属性:没写的属性会从元素自身继承,不要重复声明。
- 元素自身有 transform 时,关键帧里要写全:否则动画开始的瞬间会丢失原有变换。
- 关键帧里的 !important 无效:不要浪费时间写它,浏览器会直接丢弃。
- 两个时间值的顺序:第一个是 duration,第二个是 delay,别记反。
- 入场动画统一加 fill-mode: both:避免延迟期间的“闪一下”。
- steps() 的步数要和字符数 / 帧数对齐:多一步少一步都会歪。
- 用负延迟做相位错开:比手写多段关键帧优雅得多,且绝对值不要超过 duration。
- 变量用在元参数上:把变量给 delay / duration,而不是塞进关键帧内部。
- 用 getAnimations() 做精细控制:CSS 写动画,JS 控节奏,两者不冲突。
- 页面不可见时暂停动画:省电省 CPU,对移动端尤其重要。
- will-change 按需添加、及时移除:不要全局声明,不要常驻,不要写多个属性。
- 错峰动画用 CSS 变量 + calc:而不是手写 N 条 nth-child 规则。
- 长列表用 contain 或 content-visibility:把重绘范围锁在局部。
- 尊重 prefers-reduced-motion:减少位移与旋转,而不是粗暴地全部关闭。
- 不要只用动画传递信息:同时提供文字、图标或颜色变化。
- 用 DevTools 的 Animations 面板慢放:10% 速度下,问题无所遁形。
- 在低端真机上测试:模拟器无法反映真实的 GPU 与内存压力。
/* 1. 全局动画令牌 */
:root {
--dur-fast: 120ms;
--dur-base: 220ms;
--dur-slow: 360ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 2. 通用入场关键帧 */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(var(--fade-dist, 16px)); }
to { opacity: 1; transform: translateY(0); }
}
/* 3. 通用出场关键帧 */
@keyframes fadeDown {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(8px); }
}
/* 4. 统一动画类 */
.anim-in {
animation: fadeUp var(--dur-slow) var(--ease-decelerate) both;
animation-delay: calc(var(--i, 0) * 70ms);
}
.anim-out {
animation: fadeDown var(--dur-base) var(--ease-accelerate) both;
}
/* 5. 局部隔离 */
.card, .list-item, .widget {
contain: layout paint;
}
/* 6. 无障碍降级 */
@media (prefers-reduced-motion: reduce) {
.anim-in, .anim-out {
animation-duration: 0.01ms;
animation-iteration-count: 1;
}
}
关键帧动画的控制力,来自对“时间轴”的理解。当你能清楚地知道每一帧在做什么、每个百分比对应哪个状态、每个属性会触发哪一步渲染时,动画就不再是“碰运气调出来的效果”,而是可以被精确设计、被反复复用的工程资产。
希望这篇长文能帮你建立一套完整的关键帧动画心智模型。如果你在项目中遇到过更刁钻的动画控制问题,欢迎在实践中反复回到上面的实验台,亲手点一点、调一调、比一比——那比读十篇文章都管用。