Day 21 | animation 与 keyframes:把动画从 A 到 B 变成多点编排
本文是《30天精通CSS》专栏第 21 篇,订阅专栏不迷路。
摘要:本文基于 CSS Animations Level 1 现行规范,用一个加载指示器把 @keyframes 的完整心智讲清。transition 只能从一个状态补到另一个状态,而 keyframes 能在任意多个时间点之间编排——加载指示器、入场序列、循环呼吸全靠它。今天按四版递进:最简淡入 → 多帧位移动画 → 六个动画属性一次到位 → 和 transition 划清分工。新手最容易踩的是忘写 animation-fill-mode,动画一结束元素立刻弹回原位,前面白演。读完你会带走一个能循环的加载指示器 GIF、一条关键帧定格胶片,以及一份"该用 animation 还是 transition"的判断表。这个模型从 Animations Level 1 定稿起长期有效。
本文目录
-
先摆现象:一个会自己转的加载圈
-
两版递进:从最简淡入到多帧入场
-
六属性一次到位:从会动到听话
-
一段 GIF 加一条胶片:看动画真正跑起来
-
animation 和 transition,到底谁管哪一段
-
最容易忘的坑:动画一停,元素弹回原位
-
动手改一改:duration、forwards、backwards 三处必查
-
明天见(Day 22):一个元素,同时演好几出

先摆现象:一个会自己转的加载圈
先给结果:一个不停旋转、还能呼吸变色的加载圈。这是 animation 的主场——不用你点、不用你悬停,页面一开,它自己按时间轴往下走,叮,开机就演,演完还能循环。
但今天真正的痛点,是评论区出现频率最高的一类三连问:“我动画明明写了”“怎么一结束就弹回原位”"前面不就白演了?"三连问,一个病根:animation-fill-mode 没写。这篇就把这条时间轴讲清楚——关键帧怎么写、七个旋钮各管什么、它和 transition 到底怎么分工。
上一篇 Day 20 的翻卡,靠的还是 transition:“你动我一下,我才补一段”;今天要的是"我给一段剧本,你自己演完还能循环"。再提醒一句,Day 17 那条"transition 缺 duration 等于不动"的老坑,animation 这边有个同款双胞胎:animation-duration 不写,照样一动不动,嘛。
先记住类比:transition 像电梯,只在两个楼层之间走一趟;@keyframes 像一段 GIF 剧本,写好了从第 0 帧到第 100 帧每一步长什么样,浏览器照着放。

两版递进:从最简淡入到多帧入场
第一版:让方块淡进来的最简 keyframes
最小可用的一段动画:定义一个 @keyframes,再让元素 animation 引用它。先跑通"会动",再谈"演得好"。
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第一版:最简淡入</title>
<style>
* { box-sizing: border-box; }
body { max-width: 640px; margin: 60px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; text-align: center; }
/* 剧本:只写起点和终点两帧,中间由浏览器插值 */
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
/* animation 至少要有名字+时长两个值才会动 */
.box { width: 160px; height: 160px; margin: 0 auto; border-radius: 16px;
background: #2563eb; color: #fff; display: flex; align-items: center; justify-content: center;
animation: fade 1.6s ease-out; }
.note { font-size: 13px; color: #8a8a86; margin-top: 18px; }
</style>
</head>
<body>
<div class="box">我淡进来了</div>
<p class="note">刷新页面看:方块从透明走到不透明,一次跑完就停住。</p>
</body>
</html>


现象很清楚:刷新时方块从隐形滑到可见,跑到最后一帧就停在终点。这里有个坑的伏笔——它之所以停在终点,是因为终点值恰好等于元素本来的样子(opacity 默认就是 1)。等下你会看到"停在终点"并不总是理所当然。
第二版:多帧加位移,把入场演出来
真正让 @keyframes 显出价值的,是关键帧不止两帧。加上 transform 做位移,就是一个标准入场动画。
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第二版:多帧入场</title>
<style>
* { box-sizing: border-box; }
body { max-width: 640px; margin: 50px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
/* 四个关键帧:进场略带回弹,中间还放大了一下 */
@keyframes rise {
0% { opacity: 0; transform: translateY(40px) scale(.9); }
60% { opacity: 1; transform: translateY(-8px) scale(1.02); }
80% { transform: translateY(3px) scale(.99); }
100% { transform: translateY(0) scale(1); }
}
/* 三个卡片错峰入场:靠 animation-delay 拉开时间差 */
.card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 14px;
font-size: 14px; box-shadow: 0 2px 8px rgba(31,45,61,.08);
opacity: 0; animation: rise .7s ease-out forwards; }
.c2 { animation-delay: .15s; }
.c3 { animation-delay: .3s; }
.note { font-size: 13px; color: #8a8a86; }
</style>
</head>
<body>
<div class="card">第一条:上浮进场</div>
<div class="card c2">第二条:晚 0.15 秒出发</div>
<div class="card c3">第三条:再晚 0.3 秒</div>
<p class="note">关键差异:60% / 80% 两帧制造"冲过头再落回"的回弹感,这是两帧的 transition 给不了的。</p>
</body>
</html>


注意我在 .card 上写了 animation-fill-mode: forwards——这是刻意的。元素初始 opacity: 0,如果动画结束后不 forwards 锁住终点,它会瞬间闪回全透明,你会看到卡片"演完就消失"。这个坑,后面"动画一停,元素弹回原位"那节专门放大给你看。
六属性一次到位:从会动到听话
animation 其实是六件事的简写。把它们拆开摆好,你就完全掌握了节奏。
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>六属性拆解</title>
<style>
* { box-sizing: border-box; }
body { max-width: 720px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
@keyframes pulse { 0%,100% { transform: scale(1); background:#2563eb; }
50% { transform: scale(1.25); background:#7c3aed; } }
/* 展开写全六个属性,看清每个管什么 */
.dot { width: 88px; height: 88px; border-radius: 50%; color: #fff; margin: 0 auto;
display: flex; align-items: center; justify-content: center; font-size: 13px;
animation-name: pulse;
animation-duration: 1.4s; /* 时长 */
animation-timing-function: ease-in-out; /* 节奏 */
animation-delay: .3s; /* 起跑延迟 */
animation-iteration-count: infinite; /* 循环次数 */
animation-direction: alternate; /* 奇偶趟反向 */
animation-fill-mode: both; /* 起止两端的填充 */
}
.note { font-size: 13px; color: #8a8a86; margin-top: 16px; text-align: center; }
</style>
</head>
<body>
<div class="dot">呼吸中</div>
<p class="note">infinite 让它永不停;alternate 让它"放大→缩回→放大"来回摆,不会每趟硬跳起点。</p>
</body>
</html>


六个属性分别管什么
一句话一个:name 选剧本、duration 定多长、timing-function 定快慢节奏、delay 定延迟起跑、iteration-count 定放几遍、direction 定要不要倒放、fill-mode 定头尾怎么填充(严格说是第七个,常和前六个一起记)。简写顺序不敏感,但 duration 缺了等于 0、动画不动,是最常见的"我写了怎么不跑"。这里还藏着一个新手容易撞的组合坑:delay 加了、却没配 fill-mode: backwards,那么在等待的那零点几秒里,元素会先露出它"本该长什么样"的最终态,动画一开始才突然闪回起点,肉眼看就是"闪一下再动"。我自己在做错峰列表入场时踩过一次——三条卡片各延迟 .15 秒,结果它们全部先出现在终点位置、再弹回起点往下滑,说实话,第一次见到这个"闪一下再动",我还以为是浏览器在逗我,愣是刷新了好几遍才看清是延迟期在裸奔。后来给入场动画统一补上 both 才干净。所以记住:只要动画有 delay,几乎都要顺手写 backwards 或 both,把起始帧在等待期就占住。

一段 GIF 加一条胶片:看动画真正跑起来
文字说不清"转得顺不顺",看图吧——下面这段循环 GIF 是经典加载指示器:一圈不闭合的弧在匀速自转。
# @frames 20
# @size 360×280
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>加载指示器 GIF</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; height: 280px; display: flex; flex-direction: column; gap: 14px;
align-items: center; justify-content: center;
background: radial-gradient(circle at 50% 40%, #ffffff, #eceadf); font-family: "Microsoft YaHei", sans-serif; }
/* 主圈:一段不闭合的弧,靠 rotate 匀速自转 */
.ring { width: 84px; height: 84px; border-radius: 50%;
border: 8px solid #dbe4ef; border-top-color: #2563eb; animation: spin 1s linear infinite; }
/* 副圈:反向 + 呼吸,展示同页多个 animation 不打架 */
.ring2 { width: 40px; height: 40px; border-radius: 50%;
border: 5px solid #dbe4ef; border-bottom-color: #2f8f4e; animation: spin 1.6s linear infinite reverse; }
@keyframes spin { to { transform: rotate(360deg); } }
.lab { font-size: 13px; color: #6b6b66; }
</style>
</head>
<body>
<div class="ring"></div>
<div class="ring2"></div>
<div class="lab">加载中…</div>
</body>
</html>

再看同一段自转被定格成四个进度点,这就是胶片条的价值——每一格带百分比和时间戳,方便你把转速、相位抄下来核对。
# @frames 4
# @target .ring
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>加载指示器定格</title>
<style>
* { box-sizing: border-box; }
body { max-width: 360px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; text-align: center; }
/* 与 GIF 同款:一段匀速自转的弧,这里跑一次方便定格到固定相位 */
.ring { width: 96px; height: 96px; border-radius: 50%; margin: 0 auto;
border: 9px solid #dbe4ef; border-top-color: #2563eb; animation: spin 1.6s linear 1; }
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
.note { font-size: 13px; color: #8a8a86; margin-top: 16px; }
</style>
</head>
<body>
<div class="ring"></div>
<p class="note">定格点约 0% / 33% / 66% / 满格 对应 0° / 120° / 240° / 360°,顶部缺口一格一格转回来</p>
</body>
</html>

四格对比数据能验证"是不是匀速":缺口依次从顶部转到右下、转到左下、回到顶部,四段角度均分,没有哪一格明显跳一大步。若某两格之间缺口位置突变,多半是 timing-function 不是 linear 或关键帧分布不均。
这里补一条边界与体验常识:一个 infinite 循环的加载圈虽然好看,对前庭功能敏感或者在系统里勾选了"减少动态效果"的用户呢,却真不太友好。规范做法是用 prefers-reduced-motion 媒体查询兜底——检测到这类偏好时,把 animation 换成 none 或一次性的淡入,让加载圈停在某个静态角度,而不是永远转下去。另外时长也别贪长:一个 1.2s 到 1.6s 自转的圈看着最舒服,太慢像卡住了、太快显得躁;真到了十几秒以上的加载,该上进度条或者骨架屏呀,别让一个圈干转到天荒地老。
animation 和 transition,到底谁管哪一段
分工的一句话版
transition 由状态变化触发(hover、加 class、改属性),没有变化它就不动,而且只会从 A 补到 B。animation 由加载即自动播放(或 animation-play-state 控制),自带时间轴,能一次经过多个关键帧。还有个常被问到的差别:transition 你只要把状态来回切,它就自动来回补,天然可逆;而 animation 想重播一次,得先把它归零(比如删了 class 再加回、或靠 animation-play-state 配合),否则它会以为"已经跑完了"不再动。这也是为什么"按钮点一下抖一下"这种一次性反馈更适合 animation,而"悬停抬起、离开落回"更适合 transition。
什么时候用哪个
下面把两种写法放同一页,左卡用 transition 做悬停抬升,右卡用 animation 做一次性入场,对照着记最牢。
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>transition vs animation</title>
<style>
* { box-sizing: border-box; }
body { max-width: 760px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
.row { display: flex; gap: 20px; }
.box { flex: 1; border-radius: 14px; padding: 22px; font-size: 14px; color: #fff; }
/* 左:transition —— 只在悬停的瞬间从 A 补到 B,来回可逆 */
.tr { background: #2563eb; transition: transform .3s ease, box-shadow .3s ease; }
.tr:hover { transform: translateY(-10px); box-shadow: 0 14px 30px rgba(37,99,235,.35); }
/* 右:animation —— 加载就自动演一遍入场,可多帧、可循环 */
@keyframes pop { 0% { opacity: 0; transform: scale(.7); } 70% { transform: scale(1.05); }
100% { opacity: 1; transform: scale(1); } }
.an { background: #2f8f4e; opacity: 0; animation: pop .8s ease-out forwards; }
.cap { font-size: 13px; color: #8a8a86; margin-top: 12px; text-align: center; }
</style>
</head>
<body>
<div class="row">
<div class="box tr">悬停我:transition<br>可逆、只走 A→B</div>
<div class="box an">加载即入场:animation<br>自动播放、多帧</div>
</div>
<p class="cap">同一页并存不冲突:交互态交给 transition,自顾自的时间轴交给 animation。</p>
</body>
</html>

判断规则很简单:要跟着用户操作来回走的,用 transition;要自己按剧本走一遍或循环的,用 animation。 两者可以同时挂在一个元素上——悬停缩放用 transition,持续呼吸用 animation,互不干扰。这个分工是长期有效的通用心智。
| 触发方式 | 状态变化(hover / 加 class)被动触发 | 加载即自动播放,或被 class 触发 |
| 关键帧 | 只有起点和终点两段 | 任意多段,可命名多帧 |
| 循环 | 不能循环 | iteration-count 可 infinite |
| 倒放 | 手动去掉 class 才回退 | direction: alternate 原生支持 |
| 适合什么 | 交互反馈、悬停态 | 入场、加载指示、常驻呼吸 |
再补一条时效背景:早年给 SVG 做动画常用 SMIL 的 <animate> 标签,它和 CSS 各写一套、耦合进结构里,如今已被 CSS Animations 取代,新项目直接写 @keyframes 就够了,不必再学 SMIL。理解了这个边界,你就不会把入场动画误写成 transition(它不会循环),也不会拿 animation 去做本该可逆的悬停(它不跟手)。

最容易忘的坑:动画一停,元素弹回原位
这是新手问得最多的一类"我动画明明写了,怎么结束就跳回去"——哈,这条基本是评论区出现率最高的"演完失忆症"。根因是缺了 animation-fill-mode。下面把两条时长相同、唯一差别就是 fill-mode 的动画并排放,截图是在动画跑完之后拍的。
<!– 《30天精通CSS》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>fill-mode 缺失:弹回原位</title>
<style>
* { box-sizing: border-box; }
body { max-width: 760px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
.bar { background: #fff3e0; border: 1px solid #e3e0d8; border-radius: 6px;
padding: 8px 14px; font-size: 14px; color: #8a3a28; margin-bottom: 22px; }
.row { display: flex; gap: 24px; justify-content: center; align-items: flex-start; height: 260px; }
/* 基准态就停在下方 translateY(70px),动画把它抬到 0;两条只差 fill-mode */
@keyframes up { from { transform: translateY(70px); } to { transform: translateY(0); } }
.box { width: 150px; height: 150px; border-radius: 14px; transform: translateY(70px);
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; text-align: center; padding: 0 10px; }
.good { background: #2f8f4e; animation: up .3s ease-out forwards; } /* 停在抬起的终点 */
.bad { background: #c0392b; animation: up .3s ease-out; } /* 缺 fill-mode:跑完掉回基准位 */
.note { font-size: 13px; color: #8a3a28; margin-top: 18px; text-align: center; }
</style>
</head>
<body>
<div class="bar">0.3 秒动画跑完之后再截图:左边抬在终点,右边已掉回 70px 基准位</div>
<div class="row">
<div class="box good">forwards:停在终点,抬着</div>
<div class="box bad">无 fill-mode:掉回基准</div>
</div>
<p class="note">现象解释:两条动画路径完全相同,差别只在 fill-mode。元素基准态是 translateY(70px),动画只在"生效期间"覆盖它,结束后 none 交回基准、forwards 留住终点,于是左边高右边低。</p>
</body>
</html>

前置条件是理解"动画只在 active 阶段拥有元素"。animation-fill-mode 就是决定动画开始前 / 结束后要不要把关键帧的值留住:forwards 留住结束帧(入场动画必备),backwards 在延迟期就占住起始帧(配 delay 时防闪),both 两头都占。注意风险:如果你的元素初始态本来就等于动画终态(比如 opacity 默认 1、且终点也是 1),漏写 fill-mode 你根本看不出问题;可一旦初始态和终点态不同(常见于 transform、opacity: 0 起步),不 forwards 就会露馅。排查动作是打开 DevTools 的 Animations 面板,拖时间轴到动画结束后,直接看元素是不是跳了。

动手改一改:duration、forwards、backwards 三处必查
小结
复盘今天这条线:@keyframes 给了多帧剧本,animation 七个属性是旋钮,fill-mode 是"演完了人站哪儿"的那件事。三个心智带走——其一,duration 缺省是 0,写了名字却不动先查这个;其二,入场动画几乎都要 forwards,别嫌它啰嗦;其三,交互来回交给 transition、自动脚本交给 animation,一个元素可以同时挂着两者。之所以这三条最值钱,是因为新手十次"动画没反应",倒有九次卡在第一或第二条,而"演完跳回去"几乎全出在 fill-mode。把 @keyframes 想成一张时间轴、把 animation 想成播放器的旋钮,以后遇到任何循环、呼吸、序列动画,都是在这张轴上加关键帧、在旋钮上调参数,不会有新东西要背。哎,就一行 forwards 的事,少写一行,一晚上的白演。
我给你留了三处必查:① 把第二版入场卡片的 forwards 删掉,刷新看"演完就消失";② 把六属性那节的 animation-duration 注释掉,确认它真的一动不动;③ 给 .c2、.c3 的 delay 配上 both,再对比"闪一下再动"消失前后的差别。改完回头看我给你画的那张"电梯 vs 剧本"图,分工就刻进去了。把加载指示器和入场序列这两个模板存进 D:\\我的网页\\。
两道思考题
其一,给第二版入场动画去掉 forwards、只保留 opacity: 0 的初始值,刷新后卡片会是什么现象?用"弹回原位"那节的因果解释一下。其二,让加载圈"转半圈停一下再转半圈",该动的是 timing-function 还是关键帧百分比?试着用 steps(2) 复现一下,明天会正式讲它。
明天见(Day 22):一个元素,同时演好几出
今天的加载圈只跑了一段动画。明天开始玩编排:逗号并列,一个元素同时挂旋转、呼吸、变色好几段;再拿 steps() 把平滑曲线切成一格一格,吉祥物就能咔哒咔哒往前走——剧透一个坑:steps() 的帧数和 sprite 步长对不齐,画面当场跳抽搐,明天专门排它,顺带把 will-change 什么时候该发、什么时候别乱发也讲掉。今晚先把 fill-mode 三个值玩明白。
参考资料
-
MDN: @keyframes
-
MDN: animation-fill-mode
-
W3C: CSS Animations Level 1
网硕互联帮助中心




评论前必须登录!
注册