☰
纯CSS动画实战:用@keyframes替代transition实现循环与多阶段效果
2026/10/21 14:04:47 网站建设 项目流程

1. 为什么我劝你别再堆 transition,试试 @keyframes

刚入行那会儿,我做过一个按钮的悬停效果,用transition写了三行,hover 上去颜色渐变、阴影浮起,自我感觉良好。后来产品提了个需求:按钮要有一个“呼吸感”,就是那种不依赖鼠标操作、自己在那儿一明一暗循环跳动的效果。我当时第一反应还是加transition,结果发现根本做不到——transition只能描述“从 A 状态到 B 状态”的一次性过渡,它需要一个触发条件,比如 hover、focus、或者类名切换。而“呼吸感”是一个无限循环、自动播放的动画,没有起点也没有终点。

这就是@keyframes登场的时刻。它解决的核心问题只有一个:让元素在没有任何外部交互的情况下,按照你定义的时间线自动播放动画,并且可以循环、可以往返、可以在任意时间点插入任意多的状态。你不需要写 JavaScript 去定时切换类名,也不需要依赖用户的鼠标或键盘操作,纯 CSS 就能让一个元素“活”起来。

这篇文章适合谁看?如果你已经会写基本的 CSS,知道transition怎么用,但每次遇到“循环动画”“多阶段动画”“自动播放动画”就下意识想打开 JS 文件,那这篇内容就是为你准备的。我会从@keyframes的语法本质讲起,拆解它和transition的根本区别,然后给出几个我在实际项目里反复用到的动画模式,包括加载指示器、骨架屏微光、入场序列、以及一个容易被忽略的性能优化点。全程不依赖任何框架,纯 CSS,复制就能跑。

先说一个我踩过的坑:很多人以为@keyframes只是transition的“加强版”,其实两者的心智模型完全不同。transition是“状态之间的插值”,@keyframes是“时间轴上的关键帧编排”。理解这一点,后面所有的参数和技巧都会变得顺理成章。

2. @keyframes 的语法骨架与时间轴思维

2.1 从“状态”到“时间轴”的认知转换

用transition的时候,你脑子里想的是两个状态:初始状态和最终状态。浏览器帮你计算中间过程。比如opacity: 0到opacity: 1,浏览器在 0.3 秒内线性地把透明度从 0 插值到 1。整个过程只有两个“锚点”。

@keyframes不一样。你定义的不是两个状态,而是一条时间轴上的若干个“关键帧”。每个关键帧用百分比标记它在动画周期中的位置,0%是起点,100%是终点。你可以在0%、25%、50%、75%、100%各放一组样式,浏览器会负责在相邻关键帧之间做插值。这意味着你可以描述一个元素“先向右移动,再向上跳起,然后旋转着落下”这样复杂的运动轨迹,而这一切都发生在同一个动画周期内。

举个最直观的例子。下面这段代码定义了一个名为breathe的动画:

@keyframes breathe { 0% { opacity: 0.4; transform: scale(0.95); } 50% { opacity: 1; transform: scale(1.05); } 100% { opacity: 0.4; transform: scale(0.95); } }

注意0%和100%的样式是完全一样的。这不是冗余,而是为了让动画循环播放时首尾无缝衔接。如果你只写0%和50%,那么从50%回到0%的过程浏览器会自动补全,但补全的方向是“从 50% 的样式回到 0% 的样式”,效果上就是缩放从 1.05 直接回到 0.95,中间没有过渡帧,看起来会很突兀。所以循环动画的黄金法则第一条:首尾关键帧的样式必须一致,否则循环点会出现跳变。

2.2 关键帧的百分比写法与 from/to 的取舍

@keyframes里的关键帧选择器有两种写法:百分比和from/to。from等价于0%,to等价于100%。我个人的习惯是:如果动画只有起点和终点两个关键帧,用from/to更简洁;如果有三个或以上的关键帧,统一用百分比,保持视觉上的一致性。

/* 两帧动画,用 from/to 更干净 */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } /* 多帧动画,统一用百分比 */ @keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-6px); } 40% { transform: translateX(6px); } 60% { transform: translateX(-4px); } 80% { transform: translateX(4px); } }

上面这个shake动画里,0%, 100%写在一起,表示这两个关键帧共享同一组样式。这是@keyframes里非常实用的一个语法糖,能省掉大量重复代码。抖动效果的关键在于振幅要递减,从 6px 到 4px,模拟能量衰减的感觉,如果每次都是固定振幅,看起来就像机械振动,不够自然。

2.3 动画属性族:谁在控制时间轴

定义好@keyframes只是第一步,真正让动画跑起来的是元素上的animation属性族。这里面有几个参数直接决定了动画的“性格”:

属性作用常用值踩坑提示
animation-name指定关键帧名称自定义名称名称区分大小写
animation-duration一个周期的时长0.3s/2s不写默认 0s,动画不执行
animation-timing-function插值曲线ease/linear/cubic-bezier()对每个关键帧区间独立生效
animation-delay延迟启动0.5s/-1s负值可让动画从中间开始
animation-iteration-count循环次数1/infinite小数表示播放部分周期
animation-direction播放方向normal/alternatealternate可省掉首尾一致的要求
animation-fill-mode动画外的状态forwards/backwards/both不设置的话动画结束后元素会“弹回”原始状态
animation-play-state播放/暂停running/paused可用于 hover 暂停动画

这里重点说两个最容易出问题的参数。第一个是animation-fill-mode。默认值是none,意思是动画结束后,元素立刻回到没有动画时的样式。如果你做了一个“淡入并停留在最终状态”的入场动画,不设置forwards,动画播完元素就会闪一下变回透明,非常难看。第二个是animation-timing-function的作用范围。它并不是作用于整条时间轴,而是作用于每一对相邻关键帧之间的区间。也就是说,如果你在0%到50%之间用了ease-in,在50%到100%之间用了ease-out,那么前半段会慢起快落,后半段会快起慢落,整体节奏是分段的。

3. 把 @keyframes 用在刀刃上:四个高频场景拆解

3.1 加载指示器:用三个点做出“正在输入”的节奏感

聊天类界面里常见的“正在输入”指示器,通常是三个小圆点依次跳动。这个效果用@keyframes做非常合适,核心思路是给三个点设置相同的动画,但用animation-delay错开启动时间。

.dot { width: 8px; height: 8px; border-radius: 50%; background: #888; animation: dotBounce 1.2s infinite ease-in-out; } .dot:nth-child(2) { animation-delay: 0.15s; } .dot:nth-child(3) { animation-delay: 0.3s; } @keyframes dotBounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-8px); opacity: 1; } }

这里的关键帧设计有个讲究:0%, 60%, 100%三个点共享“落回原位”的状态,而30%是“弹到最高点”。为什么是 60% 而不是 50%?因为如果落回原位的时间点设在 50%,那么从 50% 到 100% 这半个周期里,点会一直停在原位不动,看起来会有明显的“卡顿感”。把落回点设在 60%,意味着弹起占 30% 的时间,落下占 30% 的时间,剩下 40% 的时间在原地“休息”,节奏上更接近真实的弹跳衰减。三个点各延迟 0.15 秒,形成波浪式的视觉流动,比三个点同步跳动要生动得多。

注意:animation-delay只影响第一次播放的启动时间,循环播放时每个周期之间的间隔是固定的。如果你希望延迟效果在每个周期都生效,需要把延迟写进关键帧的百分比里,而不是用animation-delay。

3.2 骨架屏微光:用背景位移做出“加载中”的质感

骨架屏的微光扫过效果,本质上是一个半透明的白色渐变条从左到右扫过灰色占位块。很多人第一反应是用background-position配合transition,但那样只能扫一次。用@keyframes可以让它无限循环。

.skeleton { background: #e0e0e0; background-image: linear-gradient( 90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.6) 50%, rgba(255, 255, 255, 0) 100% ); background-size: 200% 100%; background-repeat: no-repeat; animation: shimmer 1.6s infinite linear; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

这段代码里有两个参数需要解释。background-size: 200% 100%把渐变条拉伸到元素宽度的两倍,这样渐变条在移动时不会露出边缘。background-position从200%移动到-200%,意味着渐变条从元素右侧完全移出,再从左侧完全移入,整个扫过过程覆盖了元素宽度的四倍距离。为什么不用100%到-100%?因为那样渐变条在起点和终点时会有一部分露在元素外面,扫过效果不完整。用200%到-200%能保证渐变条完整地穿过整个元素区域,视觉上更干净。

animation-timing-function用linear而不是ease,是因为微光扫过应该保持匀速,任何加速减速都会让“扫描”的感觉变得奇怪。这个动画的duration我一般设在 1.4s 到 1.8s 之间,太快了像闪烁,太慢了像卡住。

3.3 入场序列:用 animation-delay 编排元素出场顺序

页面加载时,卡片、列表项、导航菜单依次淡入上浮,这种“入场序列”效果用@keyframes配合animation-delay可以做得非常优雅,完全不需要 JS 去逐个添加类名。

.card { opacity: 0; transform: translateY(16px); animation: cardEnter 0.5s ease-out forwards; } .card:nth-child(1) { animation-delay: 0.05s; } .card:nth-child(2) { animation-delay: 0.1s; } .card:nth-child(3) { animation-delay: 0.15s; } .card:nth-child(4) { animation-delay: 0.2s; } @keyframes cardEnter { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }

这里有一个细节值得展开:元素的初始样式里写了opacity: 0和transform: translateY(16px),然后动画的from又写了一遍同样的值。这不是重复,而是为了处理animation-delay期间的状态。在延迟的那 0.05 秒到 0.2 秒里,动画还没开始,元素会显示初始样式,也就是透明且下移的状态。如果没有在元素上写初始样式,元素会在延迟期间以“正常状态”显示,然后突然跳到from的状态开始动画,产生闪烁。所以凡是带延迟的入场动画,元素本身必须预设为动画起始状态。

另外,animation-fill-mode: forwards在这里是必须的。动画结束后,元素需要停留在to的状态,也就是完全可见、位置归零。如果不写forwards,动画一结束元素就会弹回opacity: 0,整个入场效果前功尽弃。

3.4 循环呼吸与脉冲:让静态图标“活”过来

通知铃铛的摇摆、在线状态点的脉冲扩散、语音输入按钮的呼吸缩放,这些“微动效”是提升界面质感性价比最高的手段。它们的共同特点是:幅度小、周期短、无限循环。

.pulse-ring { position: relative; width: 12px; height: 12px; border-radius: 50%; background: #4caf50; } .pulse-ring::after { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid #4caf50; animation: ringPulse 1.8s infinite ease-out; } @keyframes ringPulse { 0% { transform: scale(1); opacity: 0.8; } 100% { transform: scale(2.4); opacity: 0; } }

这个脉冲环效果用了一个伪元素来画扩散的圆环。关键帧从scale(1)放大到scale(2.4),同时透明度从 0.8 降到 0。ease-out让扩散速度先快后慢,模拟能量向外释放后逐渐消散的感觉。如果换成linear,圆环会像机械膨胀一样匀速变大,缺少“脉冲”的爆发感。

这里有个性能上的小技巧:transform和opacity是仅有的两个可以由 GPU 合成器直接处理的属性,不会触发浏览器的重排和重绘。所以做循环动画时,尽量只动这两个属性。如果你用width、height、top、left来做动画,每一帧浏览器都要重新计算布局,在低端设备上很容易掉帧。我见过有人用@keyframes去改box-shadow的扩散半径做脉冲,效果是好看,但帧率惨不忍睹。用transform: scale()配合伪元素,性能要好得多。

4. 那些年我踩过的 @keyframes 坑

4.1 动画不生效?先检查这三个地方

第一个坑:animation-name拼写错误或者大小写不一致。CSS 里@keyframes的名称是区分大小写的,@keyframes FadeIn和animation-name: fadeIn匹配不上,动画直接不执行,而且控制台不会报任何错。我当年排查了半小时才发现是首字母大小写的问题。

第二个坑:animation-duration没写或者写成0。默认值是0s,意味着动画在零秒内完成,视觉效果上就是瞬间跳到终态,看起来像没生效。很多人从网上复制代码时只复制了@keyframes部分,忘了元素上的animation简写里要带时长。

第三个坑:元素被display: none隐藏了。动画不会在隐藏元素上运行,即使后来通过 JS 把display改回block,动画也不会自动从头播放。如果你需要“显示时播放动画”,正确做法是用visibility或opacity控制显隐,或者用 JS 在显示后重新添加动画类名。

4.2 多个动画叠加时的优先级与冲突

一个元素可以同时应用多个动画,用逗号分隔:

.element { animation: fadeIn 0.5s ease-out forwards, float 3s infinite ease-in-out 0.5s; }

这里fadeIn负责入场淡入,float负责入场后的持续浮动。两个动画同时运行,但作用在不同的属性上,所以不会冲突。但如果两个动画都试图控制同一个属性,比如都改transform,那么后声明的动画会覆盖先声明的。更麻烦的是,如果两个动画都改transform但改的是不同的函数,比如一个用translateY,一个用scale,它们不会合并,而是后者的transform完全覆盖前者。

解决这个问题的办法是把多个变换合并到一个动画里,或者用 CSS 变量把不同动画的变换值组合起来。我通常的做法是:如果一个元素需要同时做位移和缩放,就写在一个@keyframes里,用不同的百分比阶段来编排,而不是拆成两个动画。

4.3 动画闪烁与首尾跳变的根因排查

循环动画最常见的视觉问题就是“循环点跳变”。表现是动画播完一轮回到起点时,元素会闪一下或者位置突变。根因通常有三个:

第一,首尾关键帧样式不一致。比如0%是translateX(0),100%是translateX(100px),循环时元素会从 100px 瞬间跳回 0。解决办法是让100%的样式和0%一致,或者用animation-direction: alternate让动画往返播放。

第二,animation-timing-function在首尾区间不对称。比如0%到50%用ease-in,50%到100%用ease-out,如果首尾样式一致但速度曲线不对称,循环点仍然会有速度突变。解决办法是让首尾区间的速度曲线也对称,或者统一用linear。

第三,animation-fill-mode设置不当。如果动画结束后元素回到初始状态,而初始状态和动画的0%不一致,循环时就会跳变。对于无限循环动画,fill-mode通常不需要设置,因为动画永远不会结束。但对于有限次数的动画,forwards和backwards的选择会影响动画前后的状态衔接。

4.4 性能优化:为什么你的动画在低端机上掉帧

前面提过,只动transform和opacity是性能最优解。但即使只动这两个属性,如果动画元素数量太多,仍然可能掉帧。我做过一个测试:在一个列表里放 50 个同时播放脉冲动画的圆点,在低端安卓机上帧率直接掉到 20fps 以下。

优化手段有几个。第一,用will-change: transform, opacity提前告诉浏览器这个元素要动,让浏览器提前把它提升为合成层。但will-change不能滥用,每个合成层都会消耗内存,元素多了反而更卡。第二,对于不在视口内的动画元素,用animation-play-state: paused暂停动画,等滚动到视口内再恢复。第三,如果动画只是装饰性的,考虑用prefers-reduced-motion媒体查询在用户开启“减少动态效果”时关掉动画:

@media (prefers-reduced-motion: reduce) { .pulse-ring::after { animation: none; } }

这不仅是性能优化,也是无障碍设计的基本要求。有些用户对闪烁和运动敏感,强制播放动画可能引起不适。

5. 让 @keyframes 和 transition 各司其职

回到开头那个问题:什么时候用transition,什么时候用@keyframes?我的判断标准很简单:如果动画需要“自动播放”或者“循环”,用@keyframes;如果动画是“状态切换的附属效果”,用transition。

按钮 hover 变色、输入框 focus 边框高亮、下拉菜单展开收起,这些都是一次性的状态过渡,transition写起来更简洁,代码量更少。而加载动画、入场序列、脉冲呼吸、骨架屏微光,这些需要自动运行或循环播放的效果,@keyframes是唯一的选择。

两者也可以配合使用。比如一个卡片,hover 时用transition做阴影和位移的过渡,同时卡片内部有一个用@keyframes做的微光循环动画。两者互不干扰,各管各的属性。

最后分享一个我常用的调试技巧:在 Chrome DevTools 的 Animations 面板里,可以可视化地查看@keyframes的时间轴,拖动进度条逐帧检查每个关键帧的样式,还能实时调整duration和delay看效果。这个面板比在代码里反复改数值刷新页面要高效得多。打开方式是:DevTools 右上角三个点菜单 → More tools → Animations。一旦用习惯了,调动画的速度会快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询