我最早接触 CSS 动画的时候,想法很简单:不就是 hover 的时候加个过渡,或者写个 @keyframes 让它自己动起来吗?但真正做了几年的前端基础设施之后,才发现这套东西远没有想象中那么“玩具”。很多项目里的动画方案最后被推翻重来,不是因为设计稿复杂,而是因为一开始选错了实现方式、忽略了渲染原理、或者没有一套可持续维护的动效代码组织方式。如果你也遇到过“动画显示不全”“hover 延迟关闭”“伪元素状态控制不了”“动画一闪一闪”这类问题,那这篇文章就是写给你看的。这篇 CSS 动画指南不会只丢一堆代码,而是从选型、渲染底层、关键帧进阶、高频特效生产、异常排查到代码沉淀,先讲透原理,再给可以直接复用的实战方案。
1. 选型:transition 还是 animation,方向错了后面全是坑
1.1 触发机制差异:状态切换式与剧本式
CSS 动画经常被混着说,但 transition 和 animation 其实是两套完全不同的机制。我的判断标准很简单:transition 是“状态切换式”,它本身没有“动”的意愿,只是当元素的某个属性发生变化时,把这个变化过程拉长;而 animation 是“剧本式”,只要定义了 @keyframes 和 animation 属性,它不需要任何外部触发条件就会自动播放。
这个区别直接影响选型。一个按钮 hover 变亮、一个卡片展开、一个弹窗淡入淡出——这些“用户交互导致状态变化”的场景,优先用 transition。而 loading 旋转、背景流光、装饰性浮动、骨架屏扫光——这些“页面打开就需要自己循环播放”的场景,必须用 animation。用错了方向的典型症状就是:给一个需要自动播放的动效写了 transition,结果页面加载完它纹丝不动;或者给一个 hover 切换的动效用 animation + forwards,结果它自己跑起来之后状态回不去了。
1.2 transition 的四要素与延迟关闭技巧
transition 的完整语法由四个子属性组成:transition-property 指定哪些属性参与过渡,transition-duration 指定时长,transition-timing-function 指定加速度曲线,transition-delay 指定延时。很多人只写个 transition: all .3s 就完事,但 all 会让所有可动画属性都参与,性能和安全边界都不可控,我一般建议显式声明要过渡的属性。
延迟这个参数很容易被忽略,但它能做出非常多“高级感”的交互。比如热词里有个“css hover延迟关闭”,很多人实现不了,其实本质就是 进入时无延迟,离开时有延迟。做法是给默认状态加一个 delay,再在 hover 状态把 delay 清零:
css复制.card {
transition: opacity .3s ease .4s;
opacity: .8;
}
.card:hover {
transition-delay: 0s;
opacity: 1;
}
浏览器对 transition 的处理规则是:用“当前状态”下的 transition 参数去执行变化。鼠标移入时走的是 .card:hover 的规则,delay 为 0,所以立即亮起;鼠标移出时恢复 .card 的样式,delay 为 .4s,所以透明度要晚 .4 秒才开始回落。这个技巧在实现“确认框停留更久再隐藏”“下拉菜单离开后延迟收起”时都非常实用,关键是理解了“浏览器看的是目标状态的 transition 配置”。
1.3 animation 的完整参数面面观
animation 比 transition 多了一大截参数:animation-name、animation-duration、animation-timing-function、animation-delay、animation-iteration-count、animation-direction、animation-fill-mode、animation-play-state,可以缩写为 animation: name duration timing-function delay iteration-count direction fill-mode play-state。
其中有几个参数容易被轻描淡写地带过,但实际项目里特别关键。animation-delay 支持负值,比如 animation-delay: -1s 可以让动画从第一秒的状态开始播放,这种技巧在“多个元素随机时间进入但不想依次排队”时很有效。iteration-count 可以写成小数,比如 1.5 会让动画播放到第二次循环的一半时停下来。direction 的 alternate 会让动画正向播完再反向播回来,做呼吸灯、浮沉效果时比你写两个关键帧要简洁得多。
选型建议可以总结成一张表:
| 场景 | 选择 | 理由 |
|---|---|---|
| hover / focus / class 切换 | transition | 由状态变化驱动,心智负担小 |
| 页面加载即自动播放 | animation | 无需外部触发,自带循环能力 |
| 多关键帧复杂路径 | animation | @keyframes 可描述中间过程 |
| 需要 JS 监听结束/循环 | animation + 事件 | 提供 animationend 等事件 |
| 播放/暂停控制 | animation-play-state | 单属性即可控制 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 渲染底层:为什么说 transform 是动画的命根子
2.1 从浏览器渲染管线说起
如果你想让动画跑得流畅,必须先知道浏览器在动画过程中到底干了什么。渲染管线大致是四步:样式计算 → 布局(layout)→ 绘制(paint)→ 合成(composite)。
当你改变 left、top、width、height、margin 这类属性时,浏览器需要重新计算元素位置和尺寸,触发整棵布局树的重排,然后再重绘,最后才合成。这一整套链路非常昂贵,动画帧率达到 60fps 时,每一帧只有 16.7 毫秒,布局和重绘很容易就把预算吃光,表现出来的就是掉帧、卡顿。
而 transform 和 opacity 是例外。改变这两个属性时,浏览器可以把元素单独提升到一个合成层,之后每一帧只需要让合成器去移动、缩放、旋转、淡出这个层,不需要重新布局,也不需要重新绘制其他区域。这就是“为什么动 transform 比动 top/left 流畅得多”的根本原因。
2.2 合成器与 GPU、will-change 的正确用法
合成器的工作方式很像你在剪辑软件里操作一个视频片段:素材已经渲染好了,你只是在时间轴上拖动它、放大它、调整透明度,预览窗口用 GPU 来做这些变换,所以特别快。为了让浏览器主动给元素建层,可以使用 will-change:
css复制/* 告诉浏览器这个元素之后要频繁变化 transform */
.anim-target {
will-change: transform;
}
但这里有个很常见的滥用陷阱:will-change 会为元素创建合成层,层一旦变多,GPU 内存消耗就上去了。不要在大量元素上一律加 will-change: transform,那会适得其反。正确用法是:只在动画即将开始的短暂时间窗口内加上,动画结束后移除;或者使用 JS 在动画开始前加、在 animationend 里移除。另一个思路是,浏览器本身对正在执行 transform/opacity 动画的层会做一些自动优化,很多时候你不加 will-change,效果也不差,先测再决定要不要加。
2.3 transform 的组合顺序与 transform-origin
transform 的另一个坑是:多个函数写在一起时,顺序不同结果完全不同。原因在于变换是“依次作用在元素坐标系上”的。
css复制transform: translateX(100px) rotate(45deg);
transform: rotate(45deg) translateX(100px);
第一种写法是先把元素向右平移 100px,再围绕它自己的原点旋转 45°;第二种写法是先旋转 45°,再沿着旋转后的坐标轴方向平移 100px。两者最终位置不一样。这经常出现在“先放大再位移”还是“先位移再放大”的效果里。经验是:把 translate 放在最前面,再写 rotate / scale,符合大多数人对“移动到某个位置再旋转缩放”的直觉;如果写成反了,你会在调试器里看到元素飞到奇怪的位置。
transform-origin 决定旋转和缩放的中心点。默认是 50% 50%,也就是元素中心。做涟漪效果时我们通常以点击位置为圆心,做弹跳效果时可能需要以底部边线为圆心,这些都要通过调整 transform-origin 来实现。另外提示一句:transform-origin 的值是基于元素自身盒模型的百分比,如果元素尺寸在动画过程中变化,原点位置也会变,这可能就是某些动画“看起来偏了”的元凶。
3. 关键帧的进阶玩法:节奏、填充、事件
3.1 关键帧节点与 timing-function 的配合
@keyframes 里用百分比定义节点,0% 是起始状态,100% 是结束状态,中间可以加任意多个节点。很多人只把它当“补间”用,但其实 CSS 允许你在每个关键帧节点内部单独声明 animation-timing-function,也就是说,每个关键帧节点之间可以有不同的加速度曲线。
这个能力非常有价值。比如一个弹跳动画,下落阶段应该越来越快(ease-in),碰撞反弹阶段应该越来越慢(ease-out),落地瞬间有一个快速的压缩。如果你只设置全局的 timing-function,效果就会很“橡皮”。正确写法是把每个阶段的 timing-function 放在对应节点里:
css复制@keyframes bounce {
0% {
transform: translateY(0);
animation-timing-function: ease-out;
}
50% {
transform: translateY(-48px);
animation-timing-function: ease-in;
}
100% {
transform: translateY(0);
animation-timing-function: ease-out;
}
}
3.2 steps() 做瞬时跳变:打字机、频闪、逐帧动画
默认的 timing-function 都是平滑插值,但有些效果反而不希望平滑。steps(n, jumpterm) 可以把动画过程切成 n 个离散的台阶,每一级之间没有过渡。常用的两种:steps(4, end) 表示分 4 步,并且在每一步结束时跳变;step-start 等同于 steps(1, start),step-end 等同于 steps(1, end)。
一个经典应用是打字机效果:把文本宽度从 0 扩展到内容宽度,用 steps(文本字数, end) 让宽度一格一格增长,再配合 overflow: hidden 和 white-space: nowrap,视觉上就是逐字出现。另一个应用是背景精灵图动画:把雪碧图的 background-position 从 0 步进切到末尾,每一步显示一帧,实现角色跑步、人物动作等帧动画,比连续移动背景的表现力强得多。
3.3 fill-mode 到底怎么选
animation-fill-mode 控制的是动画运行之外的时间点,元素该应用哪一帧的样式。很多闪烁、状态错乱问题都是 fill-mode 没设对。
四种取值含义不同:none 是默认值,动画播放前后元素保持自身样式;forwards 保留动画结束时的最后一帧;backwards 在动画尚未开始(比如处于 delay 阶段)时就应用第一帧;both 则同时具备 forwards 和 backwards 的效果。
实操中最容易出问题的场景是:动画有 1 秒延迟,元素一开始显示的是原始样式,动画启动瞬间才跳到 0% 的关键帧样式,肉眼看起来就是“闪了一下”。解决办法就是给 animation 加上 backwards 或 both,让元素在延迟阶段就提前进入动画初始状态。另外,如果动画结束后需要保持结束画面不动,就用 forwards 或 both;如果动画结束要让元素回到原始状态,就保持 none。
3.4 动画事件与业务逻辑联动
如果只是“动起来”,CSS 自己就能搞定,但动画结束时往往需要通知 JS 做后续处理,比如移除一个遮罩层、跳转页面、打点上报。CSS 动画提供了三个事件:animationstart、animationiteration、animationend。
javascript复制element.addEventListener('animationend', () => {
element.remove();
});
用的时候有几个细节要记住。第一,animationstart 只在动画实际开始播放时触发,如果设置了正值的 delay,它会等 delay 结束后才触发;如果设置了负值 delay,动画会从中间状态开始,此时 animationstart 依然会触发,但这是符合预期的。第二,animationiteration 每次循环都会触发,但如果是最后一次循环结束时,它不会再触发——因为那是 animationend 的职责。第三,事件对象上有 animationName、elapsedTime、pseudoElement 等属性,可以精确知道是哪个动画、走了多久、是哪个伪元素上的动画,这对处理“多个动画绑在同一个元素上”的情况非常有用。
4. 高频特效生产现场:涟漪、加载、金光、悬停联动
4.1 涟漪光圈扩散的完整实现
涟漪效果是很多设计稿里又爱又恨的元素。爱它的人觉得它精致,恨它的人觉得难实现。其实核心就一句话:用伪元素或动态生成的节点,在点击坐标生成一个从 scale(0) 到 scale(1) 且透明度同步降到 0 的圆形。我推荐用 JS 动态生成节点,因为伪元素方案很难精确拿到“点击位置”作为圆心。
html复制<button class="ripple-btn">点我触发涟漪</button>
css复制.ripple-btn {
position: relative;
overflow: hidden;
border: none;
padding: 14px 32px;
background: #4a90d9;
color: #fff;
cursor: pointer;
}
.ripple-ink {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, .35);
transform: scale(0);
animation: ripple .6s ease-out forwards;
pointer-events: none;
}
@keyframes ripple {
to {
transform: scale(1);
opacity: 0;
}
}
javascript复制btn.addEventListener('click', (e) => {
const rect = btn.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const ink = document.createElement('span');
ink.className = 'ripple-ink';
ink.style.width = `${size}px`;
ink.style.height = `${size}px`;
ink.style.left = `${e.clientX - rect.left - size / 2}px`;
ink.style.top = `${e.clientY - rect.top - size / 2}px`;
ink.addEventListener('animationend', () => ink.remove());
btn.appendChild(ink);
});
几个必须注意的点:父级按钮要有 overflow: hidden,否则涟漪会溢出按钮边界;生成的节点要加 pointer-events: none,否则会挡住按钮的点击事件;动画结束后一定要在 animationend 里移除节点,不然连续点击几十次,DOM 里会堆一堆无用的 span,影响长页面性能。
4.2 加载动画的三条路线
loading 动画大概是实际项目里出现频率最高的动效需求。我常用三种方案,覆盖绝大多数场景。
第一种是旋转圆环,最经典、最稳妥。用一个带边框的圆,把其中一条边框颜色设为主题色或透明,然后旋转整个元素:
css复制.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(0, 0, 0, .1);
border-top-color: #4a90d9;
border-radius: 50%;
animation: spinner .8s linear infinite;
}
@keyframes spinner {
to { transform: rotate(360deg); }
}
第二种是三点跳动。三个小圆点,通过给不同元素设置 animation-delay 让它们错开节奏。需要注意的是,负 delay 在这里非常有用:如果你想让三个点一开始就处于“正在跳”的中间状态,而不是页面加载后各自从静止等到自己那拍,就用 animation-delay: -0.3s、-0.15s、0s 这样的负值。
第三种是骨架屏扫光。在灰色占位块上叠一层半透明白色渐变条,从左边平移扫到右边,循环往复。扫光条推荐用 transform: translateX() 来实现,不要动画它的 left 属性,理由在第 2 部分已经说过,性能差距明显。
4.3 金光闪闪的文字效果
“css如何做出来金光闪闪的效果”经常被搜到,我猜大多数人是想要那种文字上光芒流淌的质感。实现思路有两个方向。
方向一是背景渐变 + 背景裁剪到文字。给文字一个金色渐变背景,用 background-clip: text 裁剪成文字形状,然后让背景位置动起来:
css复制.gold-text {
background: linear-gradient(135deg, #bf953f, #fcf6ba, #b38728, #fbf5b7, #aa771c);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: goldFlow 4s ease-in-out infinite;
}
@keyframes goldFlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
方向二是斜向扫光。在文字上层叠一个半透明的高光条,以一定角度从左侧扫到右侧。需要注意,高光条不能遮挡文字的可点击性,所以同样要加 pointer-events: none,同时父级需要 overflow: hidden 来控制扫光的可见范围。
4.4 hover 延迟关闭与兄弟元素联动
第 1 部分已经讲了 hover 延迟关闭的核心手法,这里再补充一个完整案例。如果你有一个卡片需要 hover 时展示操作按钮,移出后按钮延迟消失,那么不需要写 JS,只要把退出时的 transition-delay 拉长即可。
兄弟元素的联动则经常出现在“鼠标悬停导航项,激活对应的内容面板”这类场景。CSS 里 A + B 能选中 A 的相邻兄弟 B,A ~ B 能选中 A 之后的所有兄弟 B。但“上一个兄弟”选不了。现代 CSS 里可以用 :has() 绕过去:
css复制.group:has(.trigger:hover) .target {
animation: fadeIn .3s ease both;
}
:has() 的浏览器支持度目前已经足够好,用它实现“父级根据某个子元素的悬停状态,去控制另一个子元素的动画”,省掉一串 JS 监听。
5. 真实项目中的异常排查:显示不全、闪烁与伪元素变量
5.1 “动画显示不全”的几类真实成因
“动画显示不全”这个热搜词的背后,通常是几类很具体的问题。
第一类是元素被父级裁剪。动画中的元素如果从自身位置向外扩散,父级只要有 overflow: hidden 或者 border-radius 组合,溢出部分就会被直接切掉。这种情况不是动画写错了,是容器的边界约束问题。排查时先在浏览器 DevTools 里看看元素的实际位置和尺寸,确认是不是被 clip、overflow、contain 影响。
第二类是变换基准点设置导致偏移。transform-origin 不在预期位置时,scale 或 rotate 会让元素跑出可视区域,视觉上像“只显示了一半”。尤其是当元素尺寸是动态的,不要写死一个 transform-origin,必要时用 CSS 变量或 JS 联动设置。
第三类是元素高度为 0 或宽度不受控。比如从 height: 0 动画到 height: auto,transition 无法直接补间,因为浏览器不知道 auto 的具体数值,结果就是动画不生效或者到某个百分比时才“跳”出来。更稳妥的方案是用 max-height 技巧,或者改为用 transform: scaleY() 来模拟展开。
5.2 闪烁和动画不触发的经典场景
闪一下的问题大多是 fill-mode 没设对,前文已经说过,用 both 或 backwards 就能解决。但还有一种闪烁原因在于:元素默认样式和关键帧 0% 的样式不一致,动画开始瞬间浏览器做了“从默认到第一帧”的硬切换,肉眼能看到一个闪白或闪位。处理思路是把元素默认样式直接设置成关键帧 0% 的样式。
动画不触发也有几个高概率原因。第一,行内元素(inline)不能应用 transform 动画,需要把元素改成 inline-block 或 block。第二,伪元素动画需要伪元素本身有内容 content: '',而且 display 不能是 none。第三,如果一个动画已经执行完并被 fill-mode 固定在结束帧,你想再次触发它,直接改 class 往往无效,因为浏览器认为动画状态没有变化。这时候需要用技巧“重启”动画:先把动画名去掉,强制 reflow,再加回来;或者直接克隆/替换节点。
5.3 CSS 变量驱动伪元素状态
CSS 变量和伪元素之间有个非常实用的能力:变量可以继承到伪元素,也可以直接在伪元素上用。这意味着你不用为了给 ::before 换一个动画参数,单独去写新的样式类。
css复制.btn {
--ripple-color: rgba(255, 255, 255, .4);
--wave-distance: 8px;
}
.btn::after {
background: var(--ripple-color);
transform: translateY(var(--wave-distance));
}
JS 侧也可以通过 style.setProperty 动态修改变量,从而实时影响伪元素的动画表现:
javascript复制el.style.setProperty('--wave-distance', '20px');
这个模式非常适合做主题化、定制化的动效配置。比如一个通用的 toast 组件,错误类型、成功类型只是颜色和位移不同,你可以只修改 CSS 变量,而不需要为每种类型写一套动画关键帧。另外注意,关键帧内部也能使用 CSS 变量,但如果你在 @keyframes 里写 transform: translateX(var(--x)),要确认 var 的取值在动画执行时是可计算的,否则整个声明会被当成无效而忽略。
5.4 一定要处理的无障碍偏好
最后必须提一条几乎每个项目都容易漏掉的东西:无障碍动画偏好。很多用户会在系统里开启“减少动态效果”。对这部分用户,如果页面里的 loading 动画、粒子动画、飘动装饰仍然全速播放,轻则影响阅读,重则引发眩晕。通过 prefers-reduced-motion 媒体查询,我们可以一键降低动画强度:
css复制@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
这个方案不是把动画彻底删掉,而是把它压缩到几乎瞬间完成。对于纯装饰性动画,这基本等于关闭;对于有功能语义的动画(比如状态切换提示),它仍然保留最终状态,只是没有中间过程。这是我认为最稳妥的降级策略。
6. 把动效沉淀成项目资产:变量、原子类与参考库
6.1 用 CSS 变量搭一套动效设计令牌
如果每个开发者都按自己的手感写动画时长和缓动曲线,项目很快就会失控。我强烈建议在项目里搭一套“动效令牌”,把常用的时长和缓动统一成 CSS 变量:
css复制:root {
--dur-fast: .16s;
--dur-base: .32s;
--dur-slow: .6s;
--ease-out: cubic-bezier(.22, .61, .36, 1);
--ease-in: cubic-bezier(.55, .06, .68, .19);
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
之后写动画尽量引用这些变量,而不是直接写数字。这样做最大的好处是:当设计团队调整全局动效节奏时,你只需要改几个变量,所有组件同步生效。另一个隐藏收益是强制团队收敛动效风格,避免一个人喜欢 0.1s 的极速、另一个人喜欢 0.8s 的拖沓。
6.2 原子化 CSS 与组件化动效类
动效代码的复用有两种主流姿势。一种是组件风格,一个动画对应一个类:
css复制.anim-fade-in {
animation: fadeIn var(--dur-base) var(--ease-out) both;
}
.anim-slide-up {
animation: slideUp var(--dur-slow) var(--ease-out) both;
}
另一种是原子化风格,把属性拆成更细的单元,比如 .duration-fast、.delay-200、.ease-out,使用时组合。原子化更适合高度统一的团队,因为组合自由度高;组件风格更适合业务方直接“抄作业”,上手零门槛。我自己的习惯是两者结合:底层维护一组基础关键帧和原子类,业务层面再封装成接近语义化的类名。不管选哪种,都要注意不要让动画类太宽泛,比如一个类里同时写死了 duration 和 delay,换场景就难办了。
6.3 值得参考的开源库与自查清单
关于“css动画效果网站”,我推荐你去研究 Animate.css、Hover.css 这些老牌动画库,但不要直接整包引入。它们真正的价值在于:一是有大量现成的关键帧可以参考学习,二是它们的命名和分类方式能帮你建立动效体系的雏形;但整包引入的体量大、样式冗余,而且难以跟业务设计变量对齐,生产环境用起来很痛苦。
最后分享一下我每个动效上线前都会过的自查清单:
- 优先使用了 transform 和 opacity,而不是 top/left/width/height。
- 动画的初始状态和结束状态都通过 fill-mode 控制好了,不会出现首帧闪烁。
- 有循环动画时,确认
prefers-reduced-motion降级方案已经接上。 - 动画元素是否会造成不可控溢出,父级裁剪是否符合预期。
- 需要 JS 在动画结束后做清理或跳转时,事件监听是否绑定、是否在动画结束后解绑。
- 动画参数是否来自全局动效令牌,而不是散落的魔法数字。
把这套清单沉淀下来之后,团队里新接入动效的人就不会凭感觉发挥,我也少了很多“为什么这个动画在这里看起来这么怪”的排查活。CSS 动画做到这一步,才算是从“会用”到了“能管”。
