很多人一提到页面动效,第一反应就是去翻 JavaScript 动画库,或者琢磨怎么用 Canvas 画个粒子系统。但在我实际做项目的这几年里,CSS动画能解决的需求,可能比大部分人想象的多得多。像悬浮按钮的反馈、卡片加载的骨架屏、页面切换的过渡,甚至是一些复杂的交互动画,纯 CSS 都能搞定,而且性能往往更好,代码量还少一大截。
这篇文章不打算把 CSS 动画的每一个属性都罗列一遍,那样跟翻文档没区别。我想从实际做项目的角度,把真正高频用到、且容易踩坑的几个核心点拆开讲讲:Transform 为什么是动画性能的关键、Transition 和 Animation 各自适合什么场景、缓动函数怎么调出高级感,以及最后用一个完整案例把所有知识点串起来。如果你正在做网页,或者想优化现有页面的交互质感,这篇应该能帮上忙。
1. Transform:所有动效的地基,先搞懂坐标系
1.1 位移、缩放、旋转并不是三个独立技能
我见过不少同学把 transform 当作三个互不相关的属性来记:translate 是移动,scale 是缩放,rotate 是旋转。这么理解不能算错,但会错过一个很关键的底层逻辑:transform 的本质是对元素坐标系的整体变换。也就是说,当你对一个元素同时写多个变换函数时,它们是按顺序叠加到一个坐标系上的,后面的函数是在前面函数变换完的坐标系里再执行。
这个顺序的差异会带来非常明显的结果差异。举个例子:
css复制/* 写法一 */
.element {
transform: translateX(50px) rotate(45deg);
}
/* 写法二 */
.element {
transform: rotate(45deg) translateX(50px);
}
第一种写法,元素先向右移动 50 像素,然后绕着自己的中心(默认是元素的中心点)旋转 45 度。第二种写法,元素先旋转 45 度,然后沿着旋转后的“右方”移动 50 像素——这时候它的移动方向已经跟着坐标系转过去了,所以最终位置完全不同。
这个知识点在实际开发里有什么用?最典型的场景是做围绕某个点旋转的转盘或者仪表盘。假设你有一个圆形菜单,希望每个菜单项围绕圆心排列,通常做法是:
css复制.menu-item {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
}
.menu-item:nth-child(1) {
transform: rotate(0deg) translateX(120px);
}
.menu-item:nth-child(2) {
transform: rotate(45deg) translateX(120px);
}
这里的关键就是先 rotate 再 translateX。如果不理解“先旋转坐标系再位移”这个原理,你会很难想明白为什么元素会自动跑到圆周上。理解了之后,这类布局基本就是数学题了。
1.2 transform-origin:动画的支点决定手感
transform-origin是另一个高频但容易被忽略的属性。它决定了变换的基准点,默认是元素的几何中心(50% 50%)。但实际做项目时,很多动效的“支点”并不在中心。
打个比方,做一张卡片的翻转效果时,如果希望卡片像翻书一样从左往右翻,那支点应该是元素的左边框,也就是 transform-origin: left center;如果希望像相册一样从中间摊开,那才是默认的 center center。
我做悬浮菜单的“弹出”动效时,通常会把支点设置在展开方向的对角线位置。比如菜单从右下角弹出,transform-origin 就设为 right bottom,这样缩放动画会有一种“从角落里长出来”的质感,比单纯从中心放大自然得多。
css复制.popup-menu {
transform-origin: right bottom;
transform: scale(0);
transition: transform 0.25s ease-out;
}
.popup-menu.active {
transform: scale(1);
}
提示:
transform-origin也影响rotate的旋转中心,别只在处理位移时才想起来它。
1.3 3D 变换:让动效多一个维度的秘密
CSS 动画不只属于二维平面。perspective、rotateX()、rotateY()、translateZ() 这几个属性组合起来,能把平面页面做出空间感。一个非常经典的案例就是卡片翻转:
css复制.card-container {
perspective: 800px;
}
.card-inner {
transform-style: preserve-3d;
transition: transform 0.6s;
}
.card-container:hover .card-inner {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
这段代码里,perspective 是在父元素上设置视距,数值越小,3D 效果越强烈;transform-style: preserve-3d 让子元素的 3D 变换不会被压平;backface-visibility: hidden 是让背面的内容在翻转后不“透”出来。三个属性缺一不可,少了任何一个,效果都会塌掉。
实际做项目时,我对 perspective 的取值有一些心得:800px 到 1000px 是比较舒服的范围,视觉上很像人眼在看真实物体的距离。太小会畸变严重,太大则几乎看不出 3D 效果。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Transition:从“跳变”到“过渡”的质感提升
2.1 过渡动画的触发条件不只是 hover
多数人接触 transition 都是从 hover 开始,觉得自己已经会了。但实际上,transition 的触发条件是“属性值发生变化”,这个变化可以来自很多地方:JavaScript 修改样式、切换 class、表单元素获得焦点(:focus),甚至是在媒体查询里改变样式。
我在实际项目中经常这样用:给表单输入框添加边框颜色的过渡动画,焦点进入时边框从灰色变为主题色,并伴随着轻微的盒子阴影扩散。用户感知到的不是突兀的跳变,而是顺滑的“聚焦”反馈。
css复制.input-field {
border: 1px solid #ddd;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.input-field:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.2);
}
这种细节不会有人专门拿来说,但用户的整体体验就是从这些几十毫秒的过渡里积累起来的。
2.2 不同属性的过渡耗时应该不一样
设置 transition 时最容易犯的错是“一个时间走天下”。我见过不少代码,移动、变色、放大全部用 0.3 秒,结果做出来的动画手感很“钝”。
这里有个基本规律:位移和缩放这类空间变换,适合用较短的时长(0.2s~0.3s),因为它们是用户最直观感受到的动作,响应慢了会觉得卡;而颜色变化、透明度、模糊这类属性,可以稍微长一点(0.4s~0.6s),这样过渡更细腻。
另外,transition-delay 也可以做文章。做列表的子项逐个出现时,我给每个子项加一个递增的 delay,整体效果就像多米诺骨牌一样逐个弹出,代码量不大,但质感提升非常明显。
2.3 过渡的“双向”差异:进入和退出不能一个样
transition 的一个常见认知误区是,它只控制“变化过程”。实际上,进入动画和退出动画的时长和缓动是可以不同的,实现方式就是给不同状态分别设置 transition。
比如一个提示框,我希望它出现时快而有力(0.2s 弹出),消失时慢而柔和(0.4s 淡出),那么代码可以这样写:
css复制.toast {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.4s ease, transform 0.4s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}
注意看,默认状态的 transition 负责“退出”,.show 状态里的 transition 负责“进入”。很多人在做这个需求时只会设一份 transition,导致两个方向的动画手感相同,体验感差了不少。
3. Keyframes:真正让动画“活”起来
3.1 关键帧百分比:控制节奏的粒度
transition 只适合“从一个状态到另一个状态”的简单变化。一旦遇到需要中间过程的动画,比如“放大 → 回弹 → 恢复”这种多段变化,就必须用 @keyframes。
我对关键帧百分比的理解,可以类比成导演手里的分镜脚本。你在 0% 和 100% 之间设置多少个分镜,动画就会按这些分镜逐个走。分镜越密集,可控性越强,但也不意味着越多越好——过多的关键帧会让代码难以维护。
一个典型例子:做弹跳动画,如果只设置 0% 和 100%,效果是直上直下的机械运动;如果加上 30%、50%、70% 这些中间点,让元素在落地时先压缩一下、再反弹一下,整个动画就“活”了。
css复制@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-60px);
}
50% {
transform: translateY(0);
}
70% {
transform: translateY(-20px);
}
}
注意,我在关键帧里写了 0%, 100% 的合并写法,这在实际工作中很常用,能少写不少重复代码。
3.2 animation 的属性和简写顺序
animation 是个简写属性,完整的写法包含 animation-name、animation-duration、animation-timing-function、animation-delay、animation-iteration-count、animation-direction、animation-fill-mode、animation-play-state。
这里最容易被忽略又最坑的就是 animation-fill-mode。它的作用是设置动画开始前和结束后,元素应用哪些关键帧的样式。
none:默认值,动画结束后元素恢复原始样式forwards:动画结束后,保持最后一帧(100%)的样式backwards:在动画延迟期间,先应用第一帧(0%)的样式both:同时应用 forwards 和 backwards
我做“页面加载后元素从透明到清晰浮现”的动画时,通常需要让元素保持在最终状态,这时候如果漏掉 forwards,元素会在动画结束后突然消失回透明状态,非常尴尬。
css复制.hero-title {
opacity: 0;
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(30px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
3.3 无限循环动画的节奏控制和暂停技巧
无限循环动画最常出现在 loading 效果、呼吸灯、跑马灯这类场景。实现本身很简单,animation-iteration-count: infinite 就行。但节奏控制是个学问。
我实际做呼吸灯时发现,如果动画时长设置成 1s,视觉上会显得很急促;设置成 3s~4s 会更接近“呼吸”的节奏。再配合 ease-in-out 的缓动函数,效果会非常自然。
另一个实用技巧是 animation-play-state: paused 配合 JavaScript 控制动画暂停。比如卡片悬浮时暂停动画、移开后恢复,或者是“暂停时定格在某个细节上”这种需求,都靠它实现。
css复制.spinner {
animation: spin 1.2s linear infinite;
}
.spinner.paused {
animation-play-state: paused;
}
4. 缓动函数:动画质感的分水岭
4.1 默认的 ease 不是不能用,但别只会用它
ease 是 transition 和 animation 的默认缓动函数,它的特点是“两头慢、中间快”,看起来比较柔和。问题在于,如果用得太多,所有动画的手感会千篇一律,甚至显得“黏”。
我自己的经验是,进入类的动画(元素出现、弹入)适合用 ease-out,因为希望它快速出现,而不是拖拖拉拉;退出类的动画适合用 ease-in,因为东西消失时略微加速会显得利落。至于 ease-in-out,更适合那些往返循环的动画,比如呼吸灯,或者透明度循环变化。
4.2 cubic-bezier:用一条曲线控制“手感”
cubic-bezier 是自定义缓动函数的标准方式,格式是 cubic-bezier(x1, y1, x2, y2)。大多数人看到四个数字就头疼,其实可以把它理解为“一条贝塞尔曲线的两个控制点”,它决定了整个动画的速度变化趋势。
这里有几个我常用的经验值:
| 效果 | 曲线 | 适用场景 |
|---|---|---|
| 快入慢出 | cubic-bezier(0.25, 0.1, 0.25, 1) |
元素从屏幕外滑入 |
| 明显的回弹 | cubic-bezier(0.68, -0.55, 0.27, 1.55) |
弹窗、卡片弹出 |
| 轻快利落 | cubic-bezier(0.2, 0, 0, 1) |
抽屉、侧边栏 |
| 线性匀速 | linear |
轮播图、旋转图标 |
重点说下回弹这条曲线,它的 y 坐标出现了负值(-0.55),这意味着动画过程中元素会“越过”终点然后再拉回来,产生了类似弹簧的效果。这种曲线在 Chome 的 DevTools 里可以直接拖拽调试,不需要死记参数。
4.3 什么时候用 steps:逐帧动画的正确打开方式
steps() 是缓动函数里的一个异类。它不是平滑过渡,而是把动画分成若干“帧”逐格跳变。最典型的应用是做角色行走动画:把雪碧图按帧切好,用 steps() 逐帧切换背景位置。
我做过一个简单的加载动画,用到了这个思路:
css复制.loader {
width: 30px;
height: 30px;
background-image: url(sprite.png);
animation: playFrames 1s steps(8) infinite;
}
@keyframes playFrames {
0% {
background-position: 0 0;
}
100% {
background-position: -240px 0;
}
}
这里雪碧图一共 8 帧,每帧 30px 宽,总宽度 240px。steps(8) 让背景位置每 1/8 秒跳变一帧,循环播放就是一个小动画。
注意:用
steps()时,不要写ease-out之类的平滑缓动,否则帧与帧之间会出现奇怪的渐变过渡,整个“逐帧”效果就被破坏了。
5. 性能与避坑:动效卡顿的常见根源
5.1 只动 transform 和 opacity
CSS 动画性能的黄金法则,我已经跟身边的人强调过很多遍:尽量只对 transform 和 opacity 做动画。原因是这两个属性不会触发布局重排(Layout)和重绘(Paint),而是直接在合成器(Compositor)层面处理,能够利用 GPU 加速。
反过来,如果你动画 width、height、left、top、margin 这些属性,浏览器每一帧都要重新计算几何位置,性能消耗大,帧率上不去。我实测过一个列表展开动画的对比:用 height 从 0 到 200px 做动画,在低端机器上明显掉帧;改用 transform: scaleY(1),配合 transform-origin: top,流畅度天差地别。
5.2 will-change 的滥用问题
will-change 是一个“提前预告”属性,告诉浏览器某个元素将会发生什么变化,以便提前分配资源。但很多人会犯一个错:给所有元素都加上 will-change: transform,结果浏览器创建了大量合成层,内存暴涨,反而拖慢性能。
正确的做法是,只在动画即将开始前加上,动画结束后移除。用 JavaScript 控制会比较容易实现:
javascript复制element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
5.3 默认隐藏的元素做动画前先处理好“首帧闪烁”
还有一个很容易踩的坑:一个默认 display: none 的元素,你给它加 CSS 动画希望它显示出来时平滑进入。结果在动画刚开始的瞬间,元素会先“闪”一下,再进入动画状态。
这个问题的根源是,当元素从 display: none 切换到 display: block 时,浏览器会立刻重新渲染元素,第一帧可能来不及应用动画的初始状态。
解决的思路一般是:不要用 display 切换,而用 visibility 和 opacity 组合:
css复制.modal {
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0.3s;
}
.modal.show {
visibility: visible;
opacity: 1;
}
visibility 虽然不可见,但它占据布局空间且可以参与过渡,这样就不会有闪烁问题。
5.4 页面在后台运行时,动画会“追赶进度”
如果你在页面标签页切换到后台,回来之后发现了动画的怪异表现(比如瞬间跳过了很多状态),是因为浏览器会暂停后台页面的动画渲染,等回到前台时尝试“追赶”进度。这个问题在无限循环动画上比较少见,但对那些依赖状态精确性的动画(比如陀螺仪、跑马灯计时器)会有影响。
遇到这类需求,建议用 JavaScript 的 requestAnimationFrame 来处理时间轴,或者监听 visibilitychange 事件重新同步状态。纯 CSS 解决这个问题的空间不大,但至少做到心中有数。
6. 用一个真实案例把知识串起来
理论说了不少,来看一个我在实际项目里做过的组件——一个带数字滚动的“计数器卡片”。这个案例把 transform、transition、keyframes、缓动函数和性能优化全用上了。
先看需求:页面上有个卡片,卡片上有一条数字统计,当页面加载时,数字从 0 滚动到目标值,同时卡片有一个轻微的浮入效果。
6.1 卡片的浮入效果
卡片本身用 @keyframes 做浮入,从下方 20px 的位置移动到位,同时透明度从 0 到 1。
css复制.stats-card {
opacity: 0;
animation: cardIn 0.6s ease-out forwards;
}
@keyframes cardIn {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
6.2 数字滚动效果
数字滚动用 CSS 很难直接做到“数字本身从 0 滚动到 99”,因为文字内容无法用 transform 驱动。这里的解决思路是用两层数字叠在一起,外层 overflow: hidden,内层通过 translateY 移动来模拟滚动。
css复制.number-window {
overflow: hidden;
height: 1.2em;
line-height: 1.2em;
}
.number-list {
transition: transform 1s cubic-bezier(0.2, 0, 0, 1);
}
/* 切换到 target 时,通过 JS 设置 transform 来实现滚动 */
.number-list.scrolled {
transform: translateY(-120px);
}
在这个基础上,JavaScript 负责计算目标数字对应的 translateY 偏移量,并动态设置 transition 时长和 cubic-bezier 参数,让滚动速度随数字位数变化。这是纯 CSS 加几行 JS 的典型配合:CSS 管“怎么动”,JS 管“动多少”。
6.3 性能细节
这个案例里,我把卡片浮入做成 transform 和 opacity 动画,数字滚动也用 translateY 而不是改变 top 或 margin-top,目的就是让所有动画都走合成器,避免重绘。即便卡片里有背景渐变、边框阴影这些比较“重”的样式,实际跑起来在普通笔记本上也是 60 帧满帧运行。
6.4 为了顺手把悬浮交互也加了
做完加载动画之后,我又给卡片加了一个 hover 悬浮效果:卡片轻微上浮,阴影加深。这个直接复用 transform 和 box-shadow 的过渡即可。
css复制.stats-card {
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}
.stats-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
这里我把 transition 写在默认状态而不是 hover 状态,这样鼠标移出时卡片也能平滑落回原位。如果写在 hover 状态,移出时就会瞬间弹回,这个细节很多人都踩过。
7. 那些文档里不会写的“手感”心得
最后聊一点偏“玄学”但实际很重要的东西:动画的手感。CSS 动画学到最后,拼的不是你会多少个属性,而是你感知节奏的能力。
我从自己做过的大量页面上总结了几条经验:
- 所有动画的时长加起来,不要超过用户等待的耐心线。 页面加载进场动画最好控制在 300ms 到 500ms 之间,太久了用户会开始烦躁。功能性的交互动画(比如按钮反馈)甚至 150ms 就够了。
- 同类动画用一致的时长。 如果页面的浮入动画有的 0.3s、有的 0.5s,会显得很乱。我会在项目里定义一个时间变量的集合,用 CSS 自定义属性统一管理。
css复制:root {
--duration-fast: 0.15s;
--duration-normal: 0.3s;
--duration-slow: 0.6s;
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--ease-spring: cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
- 尊重用户偏好。 现在很多系统自带“减少动态效果”的无障碍设置,可以用
prefers-reduced-motion媒体查询来适配。当用户开启这个选项时,把非必要的动画关闭,只保留透明度变化。
css复制@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
这短短几行代码,对老用户和有视觉敏感问题的用户来说,体验差距是天壤之别。
CSS 动画的门槛其实很低,低到写几行 transition 就能让页面动起来;但天花板又很高,高性能、好手感、对用户友好的动画,需要从坐标系、缓动、性能优化一路深刻理解。希望这篇整理出来的东西,能让你在实际项目里少走一些弯路。
