如果你觉得 CSS 动画一眼就能看出来“假”,那问题绝大多数不在位移距离,也不在动画时长,而在 transition-timing-function 和 animation-timing-function 这两个被很多人忽略的属性上。CSS 动画的真实感不靠引入物理引擎,靠的是把速度随时间变化的曲线调对,让元素看起来“有重量、有惯性、有阻力”。
这篇内容会围绕缓动函数展开:从内置关键字到 cubic-bezier() 的调参逻辑,再到 steps() 这种离散节奏的用法,最后给你一套可以直接抄走的实际项目模板,并附上我在调试过程中踩过的一些坑。适合写后台组件、官网动画、落地页动效的前端工程师和 UI 动效设计师阅读,也适合刚接触前端动画、想搞清楚“为什么别人写的动画那么顺滑”的同学。
1. 动画的真实感不来自“像”,而来自“节奏对”
1.1 为什么默认的 ease 和 linear 总让人觉得生硬
早年间我做弹窗的时候,习惯性写了 transition: all .3s ease,当时觉得 ease 已经是所有缓动函数里最常用的了,应该没毛病。后来设计师看了一眼说“这个弹层像假的”,我第一反应是阴影不够深,结果加了一堆模糊度还是不太对,最后逐帧录制比对才发现问题出在节奏上:元素从头到尾的“速度变化”太呆板,入场感觉像是被程序强制从 A 点搬到 B 点,缺少现实世界里物体该有的启动和刹停过程。
这里先说一个基础认知:所谓动画帧,本质是让元素属性值在一个时间段内从旧值变成新值。如果时间函数是线性的,那么每一帧的位移量完全一致。但真实世界的物体运动几乎不存在这种匀速状态,汽车起步有加速过程,飞机降落后还要滑行减速,连橡皮从桌上滚落都会经历一个先慢后快、触地后又反弹的过程。如果动画里的物体从头到尾一个速度跑完,大脑就会第一时间判断“这个物件没有质量,不符合常理”。
CSS 默认的 ease 确实带一点加速减速的意思,但它更像是一个“通用中庸值”,不针对任何具体动作。你想让弹窗有“被弹簧推出来”的感觉,或者让卡片有“被手指按下去再弹回来”的反馈,需要的是能表达过冲、回弹、延时响应的自定义曲线。而这正是 cubic-bezier() 的强项。
1.2 时间曲线的本质:重新分配每一毫秒
说直白一点,缓动函数改变的并不是动画的距离,而是在总时长不变的情况下,重新分配“每一帧完成多少进度”。用一个生活例子来理解:同样一条 100 米跑道,普通走路是匀速前进,适合形容线性动画;骑自行车起步时用力蹬,速度逐渐拉起来,到了终点前再捏刹车,这段速度分布就是标准的“先加速后减速”曲线;而如果你从高处往下跳,落地前速度越来越快,落地后还要缓冲一下,那就是另一套完全不同的节奏。
放进 CSS 里,所谓 ease-in 就是“慢启动、快结束”,适合表达自由落体这种不断加速的动作;ease-out 是“快启动、慢结束”,适合表达物体被释放、逐渐停稳的滑动效果;ease-in-out 则两端都比较温和,适合不希望给人忽然启动感的 UI 过渡。但真正的细节藏在自由度更高的 cubic-bezier() 和 steps() 里,前者能调出连续光滑的物理质感,后者能表达齿轮跳动、打字机逐字显示这类离散节奏。
还有一个很多人不知道的点:CSS 里内置的 ease、ease-in、ease-out、ease-in-out,本质上就是几个预设的 cubic-bezier 曲线。你完全可以不背这些关键字,直接记住它们背后的数值,这样写自定义动效时会更清楚自己在调什么。下表是这些默认曲线的坐标值,方便对照理解:
| 关键字 | 等效 cubic-bezier | 曲线特点 |
|---|---|---|
ease |
cubic-bezier(0.25, 0.1, 0.25, 1.0) |
中段加速快,两端收得慢,通用 |
ease-in |
cubic-bezier(0.42, 0, 1.0, 1.0) |
慢启动,后面越来越快,像坠落 |
ease-out |
cubic-bezier(0, 0, 0.58, 1.0) |
启动快,越到后面越慢,像刹车停稳 |
ease-in-out |
cubic-bezier(0.42, 0, 0.58, 1.0) |
两端都缓慢,中间加速,最常用 |
linear |
cubic-bezier(0, 0, 1, 1) |
匀速直线运动 |
1.3 这篇内容能帮你在哪些场景落地
如果你只是写一个鼠标 hover 改变颜色,那随便用个 ease 也就够了。但一旦你的工作里出现以下这些场景,缓动函数就不是可有可无的修饰品了:
- 组件库里的弹窗、抽屉、菜单展开收起,用户每次点击都要看到,假一点都会被放大感知;
- 官网 Banner 里的滚动入场、图片位移、文字逐行显现,节奏直接影响品牌质感;
- 数据可视化大屏里数字滚动、表格行展开、进度条增长,曲线调对了,“高级感”立刻上一个台阶;
- 游戏化 H5 或营销页面里的按钮反馈、卡片翻转、礼物弹跳,这些动作本身就来自现实世界的物理感,要求更高。
接下来的内容会从参数拆解和实操两个方向展开。你可以理解原理后直接跳到对应的项目模板抄作业,也可以从头到尾顺一遍,下次拿到动效需求时不再只会问“时长用 300ms 还是 500ms”,而是能说出“这里应该用一个带 1.2 倍过冲的 easeOutBack”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. transition 与 animation 里的缓动函数到底在管哪一段
2.1 CSS 里两个 timing-function 的生效位置不一样
很多人写了 transition: left 1s cubic-bezier(...) 只是机械地认为“过程用这个曲线”,其实 transition 和 animation 的 timing function 作用粒度完全不同。
transition-timing-function 作用于“从一个状态到另一个状态”的单次补间过程。鼠标悬停时元素变大是一次 transition,移出时从大到小又是一次独立的 transition。两次过程可以分别设置不同的曲线,这在做按钮按下回弹效果时特别好用。
animation-timing-function 则作用于“关键帧与关键帧之间”。你可以把 @keyframes 里的 0%、50%、100% 看成一个个站点,animation-timing-function 控制的是“从上一个关键帧到下一个关键帧之间”的变速情况。因为一个动画里可能有多段路程,你完全可以让 0% 到 50% 用加速曲线,50% 到 100% 用减速或回弹曲线,这样可以模拟出“下落——触底——回弹——停稳”的连续动作。
有意思的是,百分比关键帧本身也可以带自己的 timing-function。写法是在对应百分比的花括号内加上 animation-timing-function: ...;,表示从这一帧到下一帧之间的过渡使用这个曲线。这个特性在做复合动作时非常好用,因为单个全局 animation-timing-function 只会让整段动画统一用一种节奏,做不到“一段下落正好落地,中间有细微挤压感”的仿真效果。
2.2 属性写法中的常用参数和注意事项
transition 的标准缩写是:
css复制.elem {
transition-property: transform, opacity;
transition-duration: 400ms, 200ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: 0ms, 100ms;
}
当然平时写得多的是单行缩写:
css复制.elem {
transition: transform 400ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 200ms ease-out 100ms;
}
animation 的完整写法也类似,只是多了循环次数、方向、填充模式等概念:
css复制.elem {
animation: cardIn 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes cardIn {
from {
transform: translateY(20px) scale(0.9);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
需要注意,animation 缩写里的 cubic-bezier(...) 写在时长后面,如果是多段关键帧动画但你又想在每段用不同曲线,更好别只靠缩写里的全局 timing-function,而是去每个关键帧内部单独写。还有一个小细节:transition 没有“帧”的概念,它只会从当前计算样式平滑过渡到目标样式,所以你要给同一个属性做多段变速就必须借助 @keyframes 或者在状态变化点打断 transition。
2.3 把时长、距离、曲线三者的关系先理清
缓动函数不是独立起作用的,它和动画时长、位移距离是一个“三角关系”。视觉上同样的效果,300ms 配 ease-in-out 和 500ms 配 ease-out 给人的体感完全不同。下面给几个我常用的起手经验:
- 小面积 UI 反馈,比如按钮按下、图标切换、hover 浮起,建议保持在 150ms 到 250ms,曲线用
ease-out,因为反馈必须快,不能拖泥带水; - 中大面积面板,比如弹窗、抽屉、菜单展开,建议 250ms 到 400ms,曲线用
ease-out或者带轻微过冲的cubic-bezier(0.34, 1.56, 0.64, 1),营造先快后慢的“重量感”; - 元素跨屏移动、页面切换、首屏 loading 强调动效,建议 400ms 到 800ms,可以设计多段关键帧,让元素经历“启动—滑行—停稳”几个阶段。
这里有一个判断标准:动画太快,用户看不到过程,真实感无从谈起;动画太慢,用户会觉得系统卡,甚至开始等待。真实感强的动画,往往是在“可感知但不拖沓”的区间内,用曲线让观者的大脑自动补完“力”和“质量”的存在。
3. cubic-bezier() 调参实战:让元素带重量、带惯性
3.1 理解 bezier 曲线里 x 和 y 的作用与边界
cubic-bezier(x1, y1, x2, y2) 是 CSS 中最强大的缓动函数形式,很多人一看到四个参数就发怵。翻译成大白话:x 轴代表动画已经消耗的时间比例,y 轴代表属性值产生的变化比例。曲线里有两个控制点 P1 和 P2,就决定了斜率随时间的变化方式。
CSS 规范规定,x1 和 x2 必须在 0 到 1 之间,因为它代表时间进度,时间不允许倒退;而 y1 和 y2 可以超出 0 到 1 的范围。这一点非常关键,因为正是 y 值大于 1 或者小于 0,才让动画产生“冲过头再回来”的效果。比如你想做一个按钮被点击后轻微压扁、然后回弹到原形甚至稍微鼓一下再恢复的动画,就可以让控制点 y 值超过 1,产生过冲。
单纯说坐标比较抽象,直接套一个具体场景。抽屉从屏幕右侧滑入,如果你不想让它在进入终点时戛然而止,而是希望它到达位置后稍微越过一点点再退回,形成一种“撞到边界有弹性”的感觉,就可以写成:
css复制.drawer {
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.drawer.open {
transform: translateX(0);
}
初始样式里给个 translateX(100%),打开时回到 0。cubic-bezier(0.34, 1.56, 0.64, 1) 这条曲线就是我们常用的 easeOutBack,因为 y2 = 1.56,transfrom 值会在过程中冲到超过 0 的位置再回落到 0,视觉上就是抽屉滑过头再弹回来。实际演示时,我看到的效果是抽屉会像被弹簧推出来的一样,在右侧边缘有一个弹性缓冲,但又不会像橡皮筋那样反复乱跳。
3.2 从真实动作反推曲线:自由落体、弹跳、惯性滑行
曲线不是凭空拍的,需要从动作的受力分析反推。自由落体是速度越来越快,所以用 ease-in,也就是 cubic-bezier(0.42, 0, 1, 1);物体落地后撞击地面反弹,除了要有 wait a moment 的阶段,还要在反弹过程中越过原目标值,这就要用到带过冲的曲线。
举一个弹球落地的例子。假设一个球从顶部下落到底部,我们希望它落下后底部产生一次形变,然后弹起一小段再停稳。如果用纯 CSS 模拟,可以先规划三段关键帧:下落阶段使用 ease-in;触地瞬间把 scaleY 压扁一点;恢复和弹起阶段带一点过冲。代码结构可以这样写:
css复制@keyframes bounceDrop {
0% {
transform: translateY(0) scaleY(1);
animation-timing-function: cubic-bezier(0.55, 0.06, 0.68, 0.19);
}
70% {
transform: translateY(180px) scaleY(1);
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
85% {
transform: translateY(160px) scaleY(1.08);
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
100% {
transform: translateY(170px) scaleY(0.92);
}
}
这里的视觉逻辑是:0% 到 70% 是一个不断加速的下坠;70% 到 85% 是接触地面后的缓冲,但位置会略低于最终停留点,同时通过 scaleY 压扁表现地面阻力;85% 到 100% 回弹到略高于停留点之后再落下停稳。每一段的 timing-function 都不同,前面的 cubic-bezier(0.55, 0.06, 0.68, 0.19) 是 easeInQuart 的近似值,加速感明显;后半段的 cubic-bezier(0.22, 1, 0.36, 1) 是 easeOutCubic,表现减速停稳的过程。
这类动画我通常不会一次性把曲线调到位,而是先在浏览器 DevTools 里把动画慢放一倍,再逐步把阶段位置和 y 轴过冲量一点点调,原则是:过冲不是越猛越真实,而是“刚刚好让人觉得有力但不夸张”才最接近肉眼对物理世界的判断。
3.3 函数库里那些现成曲线的工程化价值
在实际项目里,我不建议每次都从零用控制点蒙一条曲线。社区里已经积累了很多成熟的缓动函数命名体系,比如常见的 easeInSine、easeOutCubic、easeInOutBack、easeOutElastic。它们本质上是把所有贝塞尔控制点的经验值沉淀下来,方便设计师与前端沟通时快速表达。
有共识了以后,团队协作会顺畅很多。设计师在 Figma 里说“这里用一个 easeOutQuint”,开发不必再问“那是不是就选 ease-out”,而是直接翻译成 cubic-bezier(0.22, 1, 0.36, 1)。如果团队里没有统一参数,我建议建立一个 CSS 变量文件,把常用曲线集中管理:
css复制:root {
--ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-in-out-quad: cubic-bezier(0.45, 0, 0.55, 1);
}
然后在组件里直接写 transition: transform 300ms var(--ease-out-cubic)。这样以后全局调风格,比如设计师从稳重路线切换成活泼路线,只需要改变量,不需要一个个组件去找参数。
4. steps():真实感不只是顺滑,有时需要“一格一格跳”
4.1 为什么有些动画越“卡顿”反而越真实
很多人有个误区,觉得缓动函数作用就是让动画变得更丝滑,遇到 steps() 就默认它是“降级版”或“不流畅”。实际上很多真实感的来源恰恰是离散感,典型场景就是打字机效果。文字出现在屏幕上不是一次性滑入,而是一个字一个字蹦出来;机械仪表盘的指针不是匀速扫过,而是每 0.1 秒跳一下;超级玛丽里的角色走路不是连续滑行,而是按帧切换的精灵图动画。
steps(number, direction) 把整个变化过程切成 N 段,每一段保持当前值不动,然后瞬间跳到下一段。第一参数是步数,第二参数控制每一段是在开始时就跳跃(jump-start / start)还是在结束瞬间跳跃(jump-end / end)。默认是 end,大多数情况下够用,但做精灵图动画时,如果总帧数和关键帧的对应关系没理清,很容易出现开头多一帧或者结尾少一帧的问题。
打字机场景最经典的实现,是配合 width 变化让文字容器宽度逐字展开,同时设置 overflow: hidden 和 white-space: nowrap,曲线用 steps(字符数, end),时长给一个接近人眼阅读速率的数字。比如一句话 20 个字符,希望 2 秒打完,就写 steps(20, end) 2s。
css复制.typing {
width: 0;
white-space: nowrap;
overflow: hidden;
animation: typing 2s steps(20, end) forwards;
}
@keyframes typing {
from {
width: 0;
}
to {
width: 20ch;
}
}
这样文字不是匀速连续滑出,而是像打字机一样,一个字一个字蹦出来,每个“蹦”的动作之间还有一个稳定停留的时间。如果不加 steps(),用 linear 去展开宽度,实际看到的就是一片文字被幕布拉开,根本没有打字感。
4.2 用 steps 做加载动画的逐帧切换和状态跳表
除了打字机,很常见的场景是加载图标。很多加载动画其实是精灵图,一张图片里横排放了 8 帧、12 帧甚至更多,用一个元素背景定位做切换即可。传统做法是每隔几十毫秒用 JS 改一次 background-position,但用 CSS steps() 可以完全避免 JS 参与,性能更好。
假设有一张精灵图,总宽度是 120px,每一帧宽度是 30px,一共 4 帧,那么让动画在 1 秒内循环播放可以这样写:
css复制.loader {
width: 30px;
height: 30px;
background-image: url("sprite.png");
animation: framePlay 1s steps(4) infinite;
}
@keyframes framePlay {
from {
background-position: 0 0;
}
to {
background-position: -120px 0;
}
}
这里为什么 background-position 要从 0 走到 -120px 而不是 -90px?因为精灵图里最后一帧显示的是背景图最后一部分,而完成整个循环后需要回到 0。由于 steps(4) 会把“从 0 到 -120px”这个过程切分成 4 格,第一格结束时大概是 -30px,第二格 -60px,第三格 -90px,第四格结束后才是 -120px,也就是又回到了初始帧对应的显示状态附近。实际处理时需要结合图片裁切方向和无缝循环要求微调结束值,这里给的是一个基础理解框架。
如果你之前习惯用 JS 时间片控制帧切换,换成 CSS 实现以后会发现两个优势:一是动画跑在合成器上,不容易被主线程任务阻塞;二是帧切换的时机完全由时间线控制,不会因为页面卡顿而出现掉帧和切换间隔不均匀。
4.3 steps 和 cubic-bezier 结合使用的高级操作
steps 和 cubic-bezier 不是互斥的关系,它们可以用来表达更复杂的状态。比如进度条增长时,如果用户需要“看到进度一格一格跳上去”的确认感,又希望每一格之间的节奏有加速效果,可以让外层元素用 steps() 控制分格数量,内层元素用 cubic-bezier 控制每一格里的速度变化。
一种常见实现思路是:最外层容器设 animation-timing-function: steps(5, end),把总进度切成 5 块;每一块内部再用伪元素做一次小范围的位移和透明度过渡,让每个格子里还有明显的“动脉冲”效果。真实项目里这种需求不一定天天有,但理解了两者可以叠加,做起创意动效时思路就会宽很多。
5. 真实项目实操:三个可复用的动效完整案例
5.1 弹窗入场:一整套入场、停稳、离场应该用哪种曲线
弹窗是最能体现团队动效审美的地方。做得好的弹窗,打开和关闭会在视觉上形成一套“呼吸感”;做不好的弹窗,要么是直进直出的白板感,要么是顶部出现一条横向拉开的“大幕感”。
我常用的弹窗入场配方是:背景遮罩用 200ms 的 ease-out 做透明度变化;弹窗主体本身用 300ms 到 380ms 的 easeOutBack 做缩放和位移动画,从 scale(0.92) translateY(8px) 过渡到正常状态。因为 easeOutBack 带有轻微过冲,弹窗到达目标位置时会像一个较轻的卡片被推到桌面上弹了一下,而不是死板地停住。
关闭时则反过来,用更短的时间和更干脆的曲线。关闭动画如果也带过冲,会让人觉得这个窗“不想走”,很拖沓。所以离场我会用 180ms 的 ease-in,让整体快速缩小并淡出:
css复制.modal {
transition: opacity 200ms ease-out, transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal.close {
transition: opacity 180ms ease-in, transform 180ms ease-in;
opacity: 0;
transform: scale(0.95) translateY(4px);
}
一个小提醒:入场和离场如果写在同一条 transition 里,会出现关闭时还在用入场的回弹曲线的问题,所以要离开场和进场的过渡定义。我一般用 .modal.close 单独覆盖 transition 属性,让两个方向的节奏分开。
5.2 卡片翻转:绕 Y 轴旋转时如何让翻转回头更有“纸片感”
卡片翻转在活动页和考试类产品里很常见,核心是把 transform 做成绕 Y 轴 180 度旋转。如果只是前后两面切换,时间函数用 ease-in-out 就能完成;但如果希望翻转即将结束时带一点明显的“回摆”,可以分两段关键帧来做。
先设置容器透视和 preserve-3d:
css复制.card-box {
perspective: 800px;
}
.card {
transform-style: preserve-3d;
transition: transform 600ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
.card.flipped {
transform: rotateY(180deg);
}
由于 cubic-bezier(0.34, 1.4, 0.64, 1) 的 y 值超过了 1,卡片在旋转接近 180 度时会往 190 度左右冲一下,再回摆到 180 度,视觉上就像一张硬卡纸在翻转到位后有一点点惯性回弹。这个效果比纯 ease-in-out 多了一点点“纸片感”,但又不会像弹簧一样来回闪。实际测试时,如果发现回摆过度显得廉价,可以把 y2 从 1.4 降到 1.2,回弹幅度会明显收敛。
还要注意两点:一是在三维旋转中想让卡片背面的内容镜像正确,要给背面的容器设置 backface-visibility: hidden;二是如果卡片内部有文字或图片,最好把内容放在一个父级平面内,避免子元素因为浏览器渲染差异出现闪烁。比起微调曲线,这两个点才是翻转效果真正不“假”的基础。
5.3 进度条的增长:不是越快越好,而是有“发力”和“收力”
进度条看起来简单,但它也是缓动函数一个非常好的应用场景。如果进度条数据从 10% 涨到 90%,直接用 linear 匀速走完,会给人很机械的印象,像进度是人为编出来的一样。真实系统里的任务加载往往先快后慢:一开始资源准备充分,进度飞速前冲;越靠近 100%,要等待的东西越复杂,速度自然放缓。
要让进度条符合这种心理预期,可以给宽度变化设置 ease-out 曲线,并在接近终点时安排一次轻微过冲,让条在 100% 附近弹一下再收回来:
css复制.progress-bar {
width: 0;
transition: width 800ms cubic-bezier(0.22, 1.36, 0.36, 1);
}
cubic-bezier(0.22, 1.36, 0.36, 1) 的特点是前半段推进非常快,中后段迅速收敛,并且在 1.2 倍过冲附近反弹。实测下来,视觉上就是进度条在前 300ms 里从 10% 冲到 70%,接下来速度放慢,最后在接近满值时有轻微的“超出再退回”效果,很像真实加载完成前的那一下小缓冲。
如果你做的是极简管理后台,担心任何回弹都会显得不严肃,可以直接去掉过冲,换成 cubic-bezier(0.33, 1, 0.68, 1),只保留“先快后慢”的节奏即可。关键是理解进度条动画的本质是“让用户信任系统在推进”,而不是让用户看着一个匀速条形图发呆。
6. 常见问题与排查技巧实录
6.1 为什么用了 cubic-bezier 却感觉没有任何变化
遇到这种情况,先检查是不是没有把函数写到正确的属性上。transition-timing-function 负责的是过渡动画,animation-timing-function 负责的是关键帧动画,如果把一个 cubic-bezier 写在了 transition 里,但元素实际是用 animation 播放,那这条曲线当然不会生效。
第二个高频原因是:用 transition 的时候,初始状态和目标状态之间隔了不止一种属性,比如同时变化 opacity、transform、filter,而你在单行缩写里只给了一个 timing function。虽然浏览器会把它应用到所有过渡属性上,但有些属性因为浏览器优化,不会按典型曲线感知,比如部分视觉属性对变化不敏感,容易被误判为“没生效”。
第三个原因很隐蔽:如果你在 @keyframes 里写了 0% 到 50% 再到 100%,但中间段也写了 animation-timing-function,那全局的曲线只会作用于第一段,后面的段会按关键帧内部曲线来跑。一旦你以为全局曲线管全部,看到的现象就会和预期不符。排查办法是在 DevTools 的 Animations 面板里逐段查看速度曲线,确认每一段到底应用的是哪条函数。
6.2 动画回到初始状态时出现跳变,如何借力 fill-mode 和动画事件
使用 CSS animation 时,如果只写了 from 和 to 两个关键帧,动画结束后元素通常会回到动画开始前计算样式。比如你做“从透明到不透明”的淡入效果,动画播放完,如果没设置 forwards 或 both,元素就会瞬间回到透明状态。很多新人误以为是缓动函数的问题,其实只是 fill-mode 没设置。
对于需要保留结束状态的场景,我会写上 animation: xxx 500ms cubic-bezier(...) both,both 表示动画开始前应用第一帧,动画结束后保留最后一帧。还有一种场景是:动画播放完后希望执行一段 JS 逻辑,比如跳转页面或开启表单校验,这时可以监听 animationend 事件,事件对象里能拿到 animationName、elapsedTime,用于精确判断是哪一段动画结束。需要注意,在 animation 循环播放时,animationend 只会在整个动画结束后触发一次,不是每一轮都触发。
6.3 hover 过渡方向不同却共用同一条曲线,视觉上倒置了怎么办
鼠标移入和移出在很多场景下需要不同节奏。hover 放大时我们希望元素快速响应用户操作,所以用短时长的 ease-out;鼠标移出时,元素从放大状态恢复原状,如果也用 ease-out,就会产生“慢慢缩回去”的拖沓感。更好的处理是把 transition 写在默认状态和 hover 状态两处:
css复制.card {
transition: transform 300ms ease-out;
}
.card:hover {
transform: scale(1.04);
transition: transform 180ms ease-in;
}
这样鼠标移入时,应用的是 hover 里的 180ms ease-in,快速反馈;鼠标移出时,元素回到默认状态,应用的是默认规则里的 300ms ease-out,温和还原。两种方向使用不同曲线,在手感上差异巨大。很多人直接写一条 transition: transform 300ms ease 就不管了,所以总感觉按钮或卡片反馈不够跟手。
另一个常见问题是父元素触发 hover 时,子元素使用延迟过渡造成“回头动画生硬”。如果子元素在 hover 状态下有 transform 和 opacity 两段不同延迟,移出时延迟会导致反向动画等到时间才执行,中间就会出现一段静止的“卡住感”。解决办法是:把延迟尽量放在入场方向,离场方向的延迟设成 0,确保状态取消时用户能立刻得到反馈。
6.4 性能与可访问性:缓动函数不是越大胆越好
真实感再重要,也不能让用户产生眩晕或不适。某些回弹曲线如果幅度过大、次数过多,在视觉上会显得刻意,甚至造成观感疲劳。大量使用 easeOutElastic 这类弹簧曲线会让界面显得过度活泼,影响专业感。我的建议是:功能型界面里回弹幅度不要超过目标值的 10% 到 15%,营销页可以适当放开,但仍需保持克制。
性能方面,尽量让缓动函数去作用在 transform 和 opacity 这类合成器属性上,避免对 width、height、left、top、margin 等布局属性做高频动画。宽度变化这类属性虽然可以通过 steps() 做打字机效果,但它会持续触发布局和绘制,如果页面本身很复杂,容易出现掉帧。打字机效果可以改用 clip-path 或切分字符容器,尽可能减少对布局的冲击。
可访问性层面,CSS 还有一套内置方案,就是 prefers-reduced-motion 媒体查询。对于系统中开启“减少动态效果”的用户,应该主动把非必要的位移动画和弹跳效果降级为纯透明度动画,甚至直接关掉:
css复制@media (prefers-reduced-motion: reduce) {
.card,
.modal,
.progress-bar {
transition-duration: 0.01ms;
animation-duration: 0.01ms;
animation-iteration-count: 1;
}
}
这套处理不只是为了合规,也是让更多普通人能舒适地使用你的界面。一个用户如果对大幅回弹敏感,再真实的动画对他也是负担。
根据我个人经验,缓动函数的调试始终是“动效设计”里性价比最高的环节。不必去背所有曲线的数学公式,重要的是理解几种典型受力场景:自由落体用快收尾,释放滑动用慢收尾,碰撞反弹用过冲,机械状态切换用离散跳变。把这几个模型记住了,再配上一个能实时预览曲线的工具,你写出来的动画质感会立刻和那些默认 ease 走天下的人拉开差距。
最后再分享一个小技巧:调回弹类动画的时候,别盯着代码里的数值盲改。先用浏览器 DevTools 的动画录制功能把当前效果录下来放慢看,再一点一点调控制点的 y 轴超限量。你会发现真正让人舒服的回弹,往往是肉眼看不太明显、但你会觉得画面“很有弹性”的那个临界点。超过这个点,动画就会从“真实”滑向“浮夸”,这个度值得每一次都认真试出来。
