1. 为什么我劝你别再只会写 transition 了做前端这些年我见过太多项目里动画的实现方式停留在“鼠标移上去变个色、点一下缩个放”的阶段。问起来就是transition一把梭能用就行。但真到了要做加载动画、骨架屏呼吸效果、按钮点击涟漪、页面入场序列这些场景transition就彻底不够用了——它只能描述“从 A 状态到 B 状态”的线性过渡没法做多阶段、循环、带中间关键帧的复杂动画。这时候keyframes才是真正的主角。它是 CSS 里唯一能让你用纯声明式语法描述“一个动画从 0% 到 100% 之间每一帧长什么样”的机制。配合animation系列属性你可以做出无限循环的加载转圈、心跳脉冲、文字逐字浮现、粒子飘落、进度条回弹等等效果而且全程不碰 JavaScript不占主线程性能还比 JS 逐帧操作 DOM 好得多。这篇文章适合谁看如果你已经会写基本的 CSS但对keyframes的理解还停留在“抄一段代码改改数字”的阶段那这篇就是给你准备的。我会从关键帧的底层逻辑讲起把animation那一堆看起来眼花缭乱的属性逐个拆开再带你手写几个真实项目里高频出现的动态特效最后把我踩过的坑和排查技巧一并倒出来。看完你至少能做到看到一个动画需求脑子里能立刻拆出关键帧结构而不是打开搜索引擎找现成的抄。2. keyframes 到底在干什么把时间轴切成关键帧2.1 关键帧的本质是“时间轴上的采样点”很多人第一次接触keyframes会觉得它和transition差不多都是描述变化。但两者的思维模型完全不同。transition是“你告诉我起点和终点中间我自己补”而keyframes是“你告诉我时间轴上若干个采样点每个点上的样式我来插值”。打个生活化的比方transition像是你告诉出租车司机“去某地”他怎么开你不管keyframes像是你给司机画了一张路线图上面标了“第 0 分钟在起点、第 3 分钟到路口 A、第 7 分钟到路口 B、第 10 分钟到终点”司机必须按这个节奏走。写出来就是这样keyframes slide-in { 0% { transform: translateX(-100%); opacity: 0; } 60% { transform: translateX(10%); opacity: 1; } 100% { transform: translateX(0); } }这里0%、60%、100%就是三个关键帧。浏览器会在它们之间自动做插值计算。注意60%这一帧我故意让它冲过头到10%再回落到0这就是所谓的“回弹”效果——transition做不到这种中途折返因为它只有起点和终点。关键帧的百分比是相对于animation-duration的。如果duration是 2s那60%就对应第 1.2 秒。这一点必须刻在脑子里后面调时序全靠它。2.2 from / to 和百分比混用的坑keyframes允许你用from代替0%用to代替100%。这两个关键字和百分比可以混用但有个细节很多人不知道如果你只写了from和to那中间就是线性插值但如果你写了0%、50%、100%浏览器同样会在相邻关键帧之间做线性插值并不会自动给你加缓动。真正控制缓动的是animation-timing-function而且它可以写在单个关键帧内部覆盖全局设置。比如keyframes bounce { 0% { top: 0; animation-timing-function: ease-out; } 50% { top: 200px; animation-timing-function: ease-in; } 100% { top: 0; } }这段代码模拟的是小球下落再弹起下落阶段用ease-in越来越快触底后上升用ease-out越来越慢。这种“分段缓动”是做出物理真实感的关键transition完全无能为力。注意关键帧内部的animation-timing-function只影响“从当前帧到下一帧”这一段不会影响整条动画。很多人调不出想要的手感就是因为没意识到这一点。2.3 关键帧里哪些属性可以动哪些动了会出事理论上所有可插值的 CSS 属性都能放进关键帧但实际项目里要克制。我总结了一个经验表属性类别推荐程度原因transform强烈推荐走合成层不触发重排重绘60fps 稳opacity强烈推荐同样走合成层性能极佳filter推荐多数情况走合成但 blur 开销较大width / height谨慎触发重排动画元素多时明显掉帧top / left谨慎触发重排能用 transform 就别用它background-color可用触发重绘但不重排少量元素没问题box-shadow谨慎重绘开销大长动画容易卡clip-path推荐现代浏览器合成优化不错做揭示效果神器一句话原则能用 transform 和 opacity 解决的绝不用布局属性。这不是教条是我在低端安卓机上实测出来的血泪教训——一个用left做位移的列表动画在千元机上直接掉到 20fps换成translateX后立刻回到 55fps 以上。3. animation 那一串属性到底该怎么配3.1 八个子属性逐个拆解animation是个简写属性展开后有这么几个animation-name: slide-in; animation-duration: 2s; animation-timing-function: ease-in-out; animation-delay: 0.5s; animation-iteration-count: infinite; animation-direction: alternate; animation-fill-mode: both; animation-play-state: running;我逐个说人话解释顺便讲清楚什么时候该用哪个。animation-name就是你的keyframes名字没什么好说的。animation-duration是单次动画时长注意是“单次”如果iteration-count是 3那总时长是 3 倍。animation-timing-function是缓动曲线除了ease、linear、ease-in、ease-out、ease-in-out这几个预设还可以用cubic-bezier()自定义。做弹性效果时cubic-bezier(0.68, -0.55, 0.27, 1.55)这种带负值的曲线特别好用因为它会让动画“冲过头再回来”。animation-delay是延迟可以是负值。负值意味着动画“从中间开始播”这个技巧在做错峰动画时非常有用后面会细讲。animation-iteration-count是播放次数可以是数字也可以是infinite。做加载动画基本都用infinite。animation-direction控制方向normal是正向reverse是反向alternate是正反交替alternate-reverse是反正交替。做“呼吸灯”效果时alternate是标配这样你只需要写一个方向的关键帧。animation-fill-mode是最容易被忽略但最重要的属性。它决定动画在延迟阶段和结束之后元素保持什么样式。none是回到原始样式forwards是保持最后一帧backwards是延迟阶段就应用第一帧both是两者都保持。做入场动画时如果不用forwards动画播完元素会“闪回”到初始状态这个坑我见过太多人踩。animation-play-state就两个值running和paused配合 JS 做暂停/继续。3.2 简写顺序的陷阱animation简写很方便但有个坑第一个时间值永远是 duration第二个才是 delay。很多人写反了导致动画行为诡异。/* 正确2s 是 duration0.5s 是 delay */ animation: slide-in 2s ease-in-out 0.5s infinite alternate both; /* 错误这样写 0.5s 变成了 duration */ animation: slide-in 0.5s 2s ease-in-out infinite;我的建议是调试阶段永远用展开写法等确认无误了再合并成简写。因为简写一旦顺序错了浏览器不会报错只会默默按错误的方式执行排查起来非常痛苦。3.3 多个动画同时跑怎么写一个元素可以同时应用多个动画用逗号分隔.element { animation: fade-in 0.5s ease-out forwards, float 3s ease-in-out infinite 0.5s; }这里fade-in负责入场淡入float负责入场后持续上下浮动。注意两个动画的delay要错开否则浮动会在淡入还没结束时就叠加视觉上很乱。多个动画同时操作同一个属性时后声明的会覆盖先声明的。所以如果你两个动画都改transform就要小心了——它们不会叠加而是后者赢。解决办法是把多个变换合并到一个transform里或者用嵌套元素分别承担不同动画。4. 手把手实现五个高频动态特效4.1 无限循环加载转圈这是最经典的keyframes应用场景。核心思路是让一个圆环持续旋转。.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; } keyframes spin { to { transform: rotate(360deg); } }这里我只写了to因为from默认就是元素的当前状态rotate(0)。用linear是因为旋转动画如果加缓动会一顿一顿的匀速才自然。0.8s是我试出来的手感——太快显得焦虑太慢显得卡顿。实操心得border-top-color只染一条边配合圆角就形成了“缺口圆环”。如果你想要更精致的双色环可以用conic-gradient配合mask但兼容性会差一些移动端老机型要测。4.2 骨架屏呼吸效果骨架屏的呼吸感本质是透明度在循环变化但直接0.5 → 1 → 0.5会很生硬加个缓动就自然了。.skeleton { background: #e5e7eb; animation: breathe 1.5s ease-in-out infinite; } keyframes breathe { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }注意这里用了0%, 100%合并写法表示这两个时间点样式相同。ease-in-out让透明度变化在两端慢、中间快视觉上就像人在呼吸。如果你想让骨架屏有“扫光”效果那就得用background-position或者transform: translateX配合渐变遮罩那个复杂度高一些但原理一样——都是关键帧在驱动。4.3 按钮点击涟漪扩散涟漪效果的关键是一个绝对定位的圆形从点击位置向外扩散并淡出。纯 CSS 版本通常用伪元素实现。.btn { position: relative; overflow: hidden; } .btn::after { content: ; position: absolute; top: 50%; left: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.6); transform: translate(-50%, -50%); opacity: 0; } .btn:active::after { animation: ripple 0.6s ease-out; } keyframes ripple { 0% { width: 0; height: 0; opacity: 0.6; } 100% { width: 300px; height: 300px; opacity: 0; } }这里有个性能问题动画width和height会触发重排。如果按钮很多或者动画频繁建议改成transform: scale()keyframes ripple { 0% { transform: translate(-50%, -50%) scale(0); opacity: 0.6; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 0; } }配合一个固定尺寸的伪元素scale从 0 到 1效果一样但性能好得多。这是我优化过的一个真实案例改完之后低端机上的点击响应明显跟手了。4.4 文字逐字浮现入场这个效果在落地页标题上特别常见。纯 CSS 实现需要把每个字包一层然后用animation-delay错开。h1 classtitle span动/spanspan态/spanspan特/spanspan效/span /h1.title span { display: inline-block; opacity: 0; transform: translateY(20px); animation: rise 0.5s ease-out forwards; } .title span:nth-child(1) { animation-delay: 0s; } .title span:nth-child(2) { animation-delay: 0.1s; } .title span:nth-child(3) { animation-delay: 0.2s; } .title span:nth-child(4) { animation-delay: 0.3s; } keyframes rise { to { opacity: 1; transform: translateY(0); } }forwards在这里是必须的否则动画结束后文字会闪回透明。nth-child手动写延迟虽然笨但最可控。如果字数多可以用 SCSS 循环生成或者用 CSS 变量配合calc()。注意display: inline-block不能省因为transform对行内元素无效。这个坑我踩过当时调了半天以为是关键帧写错了其实是 span 默认display: inline导致translateY完全不生效。4.5 心跳脉冲提示点消息提示的小红点做脉冲效果能有效吸引注意力但又不至于太吵。.dot { width: 12px; height: 12px; background: #ef4444; border-radius: 50%; position: relative; } .dot::before { content: ; position: absolute; inset: 0; border-radius: 50%; background: #ef4444; animation: pulse 1.5s ease-out infinite; } keyframes pulse { 0% { transform: scale(1); opacity: 0.8; } 100% { transform: scale(2.5); opacity: 0; } }这里用伪元素做扩散圈本体保持不动。scale(2.5)和opacity: 0同时进行形成“扩散并消失”的效果。ease-out让扩散先快后慢更符合物理直觉。5. 性能、兼容与调试那些文档不会告诉你的坑5.1 合成层与 will-change 的正确用法浏览器渲染一帧要经过样式计算 → 布局 → 绘制 → 合成。transform和opacity之所以快是因为它们可以跳过布局和绘制直接在合成阶段处理。但“合成层”不是免费的。每个提升为合成层的元素都会占用显存元素多了反而会拖慢。所以will-change: transform不能乱加它相当于提前告诉浏览器“这个元素要动给它开个独立层”。用对了提升性能用错了内存暴涨。我的经验是只在动画即将开始前加will-change动画结束后移除。纯 CSS 做不到动态增删所以对于持续运行的动画比如加载转圈加will-change是合理的对于一次性入场动画加了反而浪费。.spinner { will-change: transform; }5.2 动画闪烁与首帧跳动有时候动画第一帧会闪一下尤其是配合animation-delay使用时。原因是延迟阶段元素还是原始样式动画一开始突然跳到第一帧。解决办法是用animation-fill-mode: backwards或both让元素在延迟阶段就应用第一帧样式。.element { opacity: 0; animation: fade-in 0.5s ease-out 1s both; }both同时具备backwards和forwards的效果延迟时保持第一帧结束后保持最后一帧。这是入场动画的万能配置。5.3 常见问题速查表现象可能原因解决方向动画不执行关键帧名字拼错 / 元素 display 为 inline检查 name 拼写改 display动画播完闪回缺少 fill-mode: forwards补上 forwards 或 both动画一顿一顿timing-function 用了 ease 做旋转旋转类改 linear移动端卡顿动画了 width/height/top/left改用 transform/opacity多个动画冲突同时操作同一属性合并 transform 或拆嵌套元素延迟阶段样式不对没设 fill-mode: backwards补 backwards 或 both动画结束后无法交互forwards 保持了 pointer-events检查是否被覆盖层挡住5.4 用开发者工具调动画的正确姿势Chrome DevTools 的 Animations 面板是调keyframes的神器但很多人不知道怎么用。打开方式F12 → 更多工具 → 动画。然后触发动画面板里会出现一条时间轴你可以拖动进度条逐帧查看还能实时调整 duration 和 delay。更实用的是“慢放”功能——把动画速度降到 10%那些平时一闪而过的细节问题比如关键帧之间的跳变就全暴露出来了。我调复杂动画时基本都开着这个面板比反复改代码刷新页面效率高十倍。实操心得在 Animations 面板里可以给动画打“暂停点”精确停在某个百分比上检查样式。做多元素错峰动画时这个功能能帮你确认每个元素的延迟是否真的错开了。6. 进阶玩法让关键帧动画更可控6.1 用 CSS 变量动态改关键帧参数keyframes里是可以使用 CSS 变量的这意味着你可以在不重写关键帧的情况下通过改变量来调整动画行为。:root { --float-distance: 20px; } keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(var(--float-distance) * -1)); } } .card { animation: float 3s ease-in-out infinite; } .card.large { --float-distance: 40px; }这个技巧在做主题化或响应式动画时特别有用。比如移动端想让浮动幅度小一点只需要在媒体查询里改变量值关键帧本身不用动。6.2 负延迟实现“动画已经在跑”的错觉animation-delay设成负值动画会从中间开始播。这个特性在做“随机相位”的循环动画时是神器。.particle:nth-child(1) { animation-delay: -0.5s; } .particle:nth-child(2) { animation-delay: -1.2s; } .particle:nth-child(3) { animation-delay: -2.1s; }这样三个粒子虽然动画相同但因为起始相位不同看起来就像各自独立在飘。如果不用负延迟它们会整齐划一地同步运动非常假。6.3 动画与 prefers-reduced-motion有部分用户因为身体原因对动画敏感操作系统层面开启了“减少动态效果”。作为开发者我们应该尊重这个设置。media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }这段代码把所有动画压缩到几乎瞬间完成既保留了状态变化又去掉了动态过程。这不是可选项是我认为每个项目都该加的底线。6.4 关键帧动画的调试清单每次写完一个keyframes我都会按这个清单过一遍关键帧名字和animation-name是否完全一致大小写敏感duration和delay在简写里顺序对不对需要保持终态的地方有没有加forwards延迟阶段会不会闪要不要backwards动画属性是否触发了重排能不能换transform循环动画的timing-function是否合适旋转用linear移动端低端机上跑一遍看帧率加了prefers-reduced-motion降级没有这套流程走下来基本能拦住 90% 的动画问题。剩下的 10% 往往是浏览器差异那就得靠真机测试了。7. 我在实际项目里踩过的三个印象最深的坑第一个坑是animation-fill-mode的默认值。早期做入场动画时我写完关键帧发现动画播完元素就消失了查了半天以为是opacity的问题最后才发现是没加forwards。这个属性默认是none意味着动画结束后元素回到未应用动画时的样式。从那以后我养成了习惯只要是入场动画both直接写上不给自己留隐患。第二个坑是多个动画操作同一属性时的覆盖问题。当时做一个卡片既要入场淡入又要持续浮动我写了两个动画都改transform结果浮动完全失效。原因是后声明的动画覆盖了先声明的transform。解决办法是把淡入的translateY和浮动的translateY合并到一个关键帧里或者用外层元素做浮动、内层元素做淡入。这个教训让我明白CSS 动画不是叠加的是覆盖的。第三个坑是移动端的性能。一个用box-shadow做脉冲的提示点在桌面浏览器上丝滑流畅到了千元安卓机上直接卡成幻灯片。后来把box-shadow换成transform: scale配合opacity问题立刻解决。这件事让我彻底记住了那条铁律动画只碰 transform 和 opacity其他属性都要三思。这三个坑说到底都是同一个道理keyframes的语法很简单难的是对渲染机制的理解和对细节的敬畏。语法查文档十分钟就能会但知道什么时候该用什么、什么会出问题只能靠一个个项目磨出来。最后分享一个我个人的小习惯每次做完一个动画效果我都会把它单独抽成一个 HTML 文件存起来标注好关键参数和适用场景。时间长了就攒成了一个自己的动画库下次遇到类似需求直接翻出来改比重新写快得多。这个习惯坚持了两年现在手头有几十个经过真机验证的动画片段基本覆盖了日常开发的大部分场景。