CSS 动画看起来是在让元素移动,写到真实界面里却要多考虑几步:属性在什么时候变化,变化的节奏怎样,动画执行几次,开始前和结束后显示哪种状态,以及怎样照顾不适合观看运动的用户。我们就沿着这些问题往下看,把 @keyframes、动画属性、脚本接口、性能和无障碍策略放进同一条时间线里理解。

过渡由状态变化触发,通常连接前后两个状态。关键帧动画需要用名称绑定,可以加入多个中间状态,也能单独设置重复、方向、暂停、填充和延迟。两者都依赖浏览器对属性值的插值,所以不能简单地说动画一定比过渡性能好。
在讨论动画参数之前,我们先确认它会不会让人不舒服。快速闪烁、大面积缩放、持续旋转和强烈视差都可能引发癫痫风险,也可能让前庭敏感用户感到眩晕、恶心或失去方向感。这类问题需要在设计动画时处理,不能等动画做完再补救。

你可以先把界面中的运动分成两类:
写样式时,先给出静态且可用的默认状态,再为允许运动的用户加上动画:
.notice {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: no-preference) {
.notice[data-entering='true'] {
animation: notice-enter 240ms ease-out both;
}
}
@keyframes notice-enter {
from { opacity: 0; transform: translateY(0.5rem); }
to { opacity: 1; transform: translateY(0); }
}如果项目里已经积累了很多动画,可以先在 @media (prefers-reduced-motion: reduce) 中统一关闭,再逐项恢复必要反馈。全局使用 animation: none !important 也会删掉帮助用户理解界面的动画,所以它更适合用来过渡,不适合作为最终方案。
@keyframes:定义一轮动画的状态地图@keyframes 只描述一轮动画里的状态,不决定这一轮要播放多久。名称后面可以写多个关键帧选择器,每个选择器对应总进度中的一个位置:
@keyframes card-arrive {
from {
opacity: 0;
transform: translateY(1rem) scale(0.98);
}
60% {
opacity: 1;
transform: translateY(-0.125rem) scale(1.01);
}
to {
opacity
from 等价于 0%,to 等价于 100%。选择器只能写这两个关键字或带 % 的百分比。像 1s、50、不带百分号的 0 都无效。33.333% 这样的小数百分比可以使用,负数以及超过 100% 的百分比不可以。
动画名可以是标识符,也可以是字符串。在项目里,用 card-arrive 这种含义清楚的标识符会更容易维护。不要用 none、running、paused、infinite、forwards 等动画关键字命名,它们在解析简写时容易产生歧义。未转义的标识符也不能直接以数字开头,特殊符号需要转义。
如果多个进度点采用相同状态,可以用逗号把它们写进同一个声明块。关键帧不要求按百分比升序排列,不过按时间顺序书写更方便阅读和修改:
@keyframes pulse {
0%, 100% { transform: scale(1); }
40%, 60% { transform: scale(1.08); }
}省略 0% 或 100% 时,浏览器会读取元素在不受该动画影响时的计算值,用它补出隐式首帧或尾帧。这样,同一个动画可以从不同元素各自的基础样式出发,结果也会随上下文变化。组件需要稳定复用时,可以显式写出每个动画属性的首尾值,减少这种依赖。
同一个百分比可以出现多次。浏览器会像处理普通 CSS 一样合并这些声明;同一属性发生冲突时,后写的值覆盖先写的值:
@keyframes route {
from, to { translate: 0 0; opacity: 1; }
50% { translate: 10rem 0; }
to { opacity: 0.4; }
}这里的 to 最终同时得到 translate: 0 0 和 opacity: 0.4。后一个关键帧块并不会整体替换前一个块,覆盖发生在具体声明这一层。
一个属性能不能平滑变化,要看它的动画类型,也要看起点和终点之间是否能计算出中间值。数值、颜色和变换通常可以插值。height: auto 到 height: 300px 在很多场景中没有稳定的数值中点,结果可能直接跳变,也可能不符合预期。如果组件必须改变高度,可以考虑已知尺寸、网格轨道、裁剪或变换,并在目标浏览器里实际验证。
visibility 是典型的离散值,它不会逐渐变成“半可见”,只会在规定的位置切换。animation-timing-function 本身也不会在两个关键帧之间插值,但可以写在关键帧块中,控制从当前帧到下一个相关帧的推进方式。
如果确实要用脚本修改样式表里的关键帧,可以从 document.styleSheets 中找到 CSSKeyframesRule,再调用对应的方法:
const rule = [...document.styleSheets]
.flatMap(sheet => {
try { return [...sheet.cssRules]; }
catch { return []; } // 跨源样式表可能不可读
})
.find(item => item instanceof CSSKeyframesRule && item.name === 'pulse');
rule?.findRule('50%');
findRule() 和 deleteRule() 接收的是完整关键帧选择器。一个块如果写成 25%, 75%,查找时也要使用这段完整文本。遇到同一百分比重复出现的情况,实现只会定位匹配规则,不会替你合并其中的逻辑。直接修改全局样式表会影响较大范围,普通组件通常更适合通过 CSS 自定义属性传入变化值。
animation-name 与多动画冲突定义好关键帧后,动画不会自己播放。你还要用 animation-name 把它绑定到元素,或绑定到 ::before、::after 伪元素。该属性的初始值是 none。如果只写名称而没有设置时长,动画仍会运行,但默认的 animation-duration 是 0s,通常看不到变化过程。
.badge {
animation-name: rise, glow;
animation-duration: 320ms, 900ms;
}用逗号分隔多个动画名称,相当于建立了多个动画槽位。其他长属性会按相同索引与名称配对:值不够时循环复用,值多出来时,多余部分不参与匹配。某个名称暂时找不到对应的 @keyframes,只会让那个槽位没有效果;规则之后出现,它仍可以生效。
并发动画修改不同属性时可以共同工作,例如一个修改 transform,另一个修改 opacity。如果它们都修改同一属性,冲突时以后列出的动画为准。两个 transform 动画不会自动相加;需要同时处理位移和旋转时,可以把它们合并进同一个 transform,也可以分别放到父子元素上。

animation-duration 表示一轮迭代持续多久,单位可以是 s 或 ms,值不能为负。单独写 0 缺少时间单位,在动画时长列表里是无效值,需要写成 0s。默认时长就是 0s,不过开始和结束事件仍可能依次发生。
animation-iteration-count 的默认值是 1,也可以写非负数字或 infinite。小数次数会按比例执行:1.25 是完整播放一轮,再播放下一轮的四分之一。0 表示执行零次,但仍不能据此判断事件一定不会触发。
animation-direction 有四个值:
animation-delay 可以是正值,也可以是负值。正值表示绑定后先等待一段时间;负值会让动画立即开始,并把时间线视为已经推进了一段。比如时长是 10s、延迟是 -4s,使用线性时间函数时,会从大约 40% 的进度开始。负延迟如果跨过了若干完整迭代,被跳过的轮次不会补发迭代事件。
CSS 动画提供了四个常用事件:animationstart、animationiteration、animationend 和 animationcancel。处理事件时,我们通常会读取 animationName、elapsedTime 和 pseudoElement 这几个只读属性。
animationstart 在延迟结束后触发。使用负延迟时,它会立即触发,elapsedTime 通常会反映已经跳过的时间。animationiteration 发生在两轮之间。如果它和最终的 animationend 落在同一时刻,只会触发结束事件。animationend 只在动画自然完成时触发。无限动画不会自然结束。animationcancel 表示动画尚未到达终点就被移除、中断,或不再符合适用条件。const toast = document.querySelector('.toast');
toast.addEventListener('animationend', event => {
if (event.animationName === 'toast-enter') {
toast.dataset.ready = 'true';
}
});同一个类已经存在时,再添加一次不会产生样式变化,动画自然也不会重新播放。可以先删除类,强制刷新一次样式,再把类加回来;也可以通过 Web Animations API 获取并控制动画。如果后一步必须等前一步完成,用事件衔接脚本会更稳妥,因为它依据实际完成时刻推进,不需要猜测延迟时间。
animation-timing-function 控制相邻关键帧之间的推进方式。它处理的是每个关键帧区间,并非只给整段动画套上一条统一曲线。常用关键字有 ease、linear、ease-in、ease-out 和 ease-in-out。
用 cubic-bezier(x1, y1, x2, y2) 可以自定义曲线。两个 x 控制点必须落在 0 到 1 之间,y 则可以越界。y 越界时,属性值可能短暂超过首尾值,形成回弹或过冲。动画反向播放时,时间函数的效果也会随方向反转。
steps(n, position) 会把一个区间切成离散台阶,可以用在精灵图、机械计数器或帧动画中。steps(5, end) 在每段末尾跳变,steps(5, start) 在每段开头跳变。step-start 等价于 steps(1, start),step-end 等价于 steps(1, end)。
时间函数也可以直接写进关键帧:
@keyframes settling-ball {
0% {
transform: translateY(0);
animation-timing-function: ease-in;
}
55% {
transform: translateY(12rem);
animation-timing-function: ease-out;
}
78% {
transform: translateY(7rem);
animation-timing-function
关键帧里的时间函数不会逐渐过渡成另一个函数。它决定的是从当前帧到下一次出现相关属性的关键帧之间采用什么节奏。只把它写在 100% 中没有效果,因为后面已经没有区间可供控制。只有时间函数、没有其他属性声明的空关键帧,同样无法影响属性区间。
animation 简写animation-play-state 只有 running 和 paused 两个值。暂停会冻结当前进度,尚未结束的正延迟也会停住。恢复播放后,动画从冻结的位置继续,不会回到开头。
animation-fill-mode 决定动画执行区间以外显示哪一帧:
none:动画开始前和结束后都不保留关键帧样式。backwards:正延迟期间应用第一轮的实际起始帧。动画反向播放时,起始帧可能是 100%。forwards:结束后保留动画实际到达的最终状态。迭代次数如果是 0.6,保留的是 60% 处的计算状态,不一定是 100%。both:同时应用 backwards 和 forwards 的效果。animation 简写可以一次设置八个长属性:
.panel {
animation: 320ms ease-out 80ms 1 normal both running panel-enter;
}第一个时间值总会解析成 duration,第二个才是 delay。把名称放在最后更容易读,也能减少它和关键字产生歧义的机会。多组简写用逗号分隔后可以并行动画。要留意的是,简写会把没有写出的长属性重置为初始值;如果前面已经设置过长属性,再补写简写可能会把原值覆盖掉。
CSS 没有 animation-iteration-delay 属性。想让每轮动画之间停一会儿,需要换一种实现方式。下面三种方法解决的问题略有不同。
把停顿写进关键帧:假设动作持续 1 秒、停顿持续 4 秒,可以把一轮总时长设为 5 秒,让前 80% 保持静止,后 20% 执行动作。这种写法只用 CSS 就能无限重复,不过改变停顿比例时也要修改关键帧。
@keyframes breathe-with-rest {
0%, 80% { transform: scale(1); }
100% { transform: scale(1.08); }
}
.status {
animation: breathe-with-rest 5s ease-out 3;
}重复绑定同一名称并设置不同延迟:当次数有限,而且每次开始的时刻已经确定时,可以用这种方式排程。如果动画发生重叠,同一属性仍然以后列出的动画为准。
监听 animationend 后定时重绑:这种做法可以让每轮使用不同间隔,也能按实际完成时刻继续。组件卸载时记得清理计时器,并根据用户的减少动态偏好调整行为。
多个不同动画也可以用累计延迟排成一条 CSS 动画链:后一段的延迟等于前面各段延迟与时长的总和。但主线程繁忙时,延迟虽然已经到期,画面却可能晚一些才开始更新。业务顺序要求严格时,应使用事件或状态机推进,不要把累计秒数当成精确时钟。
display: none、主线程与打印状态动画运行时产生的值位于普通声明之上,通常会覆盖选择器权重更高的普通规则。外部的 !important 声明仍然可以压过动画值。不要在关键帧声明中写 !important,浏览器会把它当作无效声明忽略。
把元素设为 display: none,会终止它和后代元素上的动画。恢复为可显示值后,动画相当于重新绑定,正延迟会重新计时,迭代也会从头开始。如果你的目的只是暂停,用 animation-play-state: paused。如果要让元素淡出并阻止交互,可以组合 opacity、visibility 和 pointer-events,等淡出完成后再考虑是否切换 display。
涉及布局或绘制的动画会受到主线程阻塞的影响。比如页面加载时安排了很多逐渐增加的延迟,但脚本占用主线程十几秒,那么多个已经到期的动画可能在主线程释放后一起出现。CSS 延迟只是时间线参数,不能保证任务一定在那个时刻完成绘制。
打印内容无法呈现时间变化。浏览器可能打印动画结束状态,也可能选择其他静态状态。发票、报告等内容需要明确可见时,可以单独提供打印样式:
@media print {
* { animation: none !important; }
.reveal { opacity: 1; transform: none; }
}will-change
浏览器更新画面时,可能要经过样式计算、布局、绘制和合成。让 width、height、top、left、margin 等几何属性持续变化,通常会增加布局或绘制工作。transform 和 opacity 在条件合适时更容易只走合成阶段。因此,实现位移时可以先考虑 transform: translate(),缩放用 scale(),淡入淡出用 opacity。
不过,这不等于用了 GPU 就一定更快。图层会占用显存,上传纹理和合成本身也需要资源。给所有元素都写 translateZ(0) 可能制造大量图层,反而拖慢页面。颜色、滤镜和阴影也可能带来昂贵的绘制工作,判断瓶颈时要用浏览器性能工具在真实设备上测量。
will-change 只是给浏览器的优化提示,不是每个动画都要写的配置。可以先简化动画,改用更适合合成的属性并测量结果。问题仍然存在时,再短期提示确实会变化的长属性,并在动画结束后移除提示。
const card = document.querySelector('.card');
card.addEventListener('pointerenter', () => {
card.style.willChange = 'transform';
});
card.addEventListener('animationend', () => {
card.style.willChange = 'auto';
});不要在 will-change 中写 all 或范围宽泛的简写。写 background 可能暗示一组长属性都要变化,浏览器需要为此准备更多资源。只声明经过测量、确实存在性能问题的具体属性即可。
遇到动画没有播放或状态不对时,不用一上来就改关键帧。按下面的顺序检查,通常更容易找到问题落在哪一层:
先确认 animation-name 能找到同名的 @keyframes,再排除名称与 paused、none 等简写关键字冲突的情况。
检查 animation-duration 是否仍是默认的 0s,所有时间值是否带单位,逗号列表是否按照名称索引正确配对。
接着看首尾帧是否依赖了意外的计算值,属性能否插值,以及多个动画是否同时覆盖同一个 transform。
animation-fill-mode: forwards 只会在动画效果层保留最终计算值,不会把这个值写回元素的普通样式。删除动画名称或改变匹配规则后,元素仍会回到级联决定的基础状态。业务状态需要由类名、属性或应用数据表达,不能只依赖视觉上的填充模式。
现在可以把 CSS 动画理解成一台小型时间状态机。@keyframes 画出一轮中的状态;名称、时长、延迟、次数和方向组成时间线;时间函数、播放状态和填充模式决定每个区间怎样推进;事件、媒体查询和性能策略则负责它与真实界面的衔接。
真正落到组件里时,可以逐个回答这几个问题:动画传递了什么信息?用户选择减少动态后,功能是否仍然完整?当前属性带来的渲染成本是否测量过?这些答案说清楚了,动画的实现和取舍也就有了依据。
确认元素或祖先有没有被设为 display: none,动画是否暂停,减少动态媒体查询是否按预期生效。
最后查看事件日志和性能录制,判断是动画时间线没有建立、动画被取消,还是主线程繁忙导致画面没有及时更新。