做前端的人大概都有一段这样的经历:写页面布局、调组件样式都挺顺,一到要加动效就卡壳,翻文档把animation的八个属性抄一遍,粘上去发现要么不动、要么动完弹回去、要么一动就掉帧,最后干脆用 JS 写个定时器改样式糊过去。这个提高篇的第七个练习,练的就是把这层窗户纸捅破——CSS animation 动画从参数手感、关键帧设计,到状态控制、性能收口的一条完整链路。它不像入门篇那样只讲“语法长什么样”,而是要求你手里有几个能直接落地的动画片段:骨架屏微光、打字机光标、卡片入场序列、可暂停的加载指示器。适合已经能写基础 CSS、做过一两个页面、但动效全靠抄代码的人。我把这一轮练习里的思路、参数取舍和踩过的坑整理出来,能照着改就能跑。
1. 这个练习到底练什么:先把 animation 的骨架立起来
1.1 从 transition 到 animation 的思维切换
很多人第一次接触动效是从transition起步的,因为它的心智模型特别简单:给元素一个过渡属性,状态变了,浏览器帮你补中间帧。但练到动画这一层就会发现transition的天花板很低——它只能定义“起点”和“终点”,中间怎么走、走几趟、走完停不停,全都不归你管。更关键的是,transition必须由状态变化触发,也就是说你得先有个:hover、:focus或者 class 切换,元素自己不会主动动起来。
animation的思维完全是另一套。它是“预先写好一段剧本,然后交给浏览器去演”。剧本就是@keyframes,里面可以写 0%、30%、60%、100% 任意多个节点,节点之间浏览器自动插值;演几遍、正着演还是倒着演、演完停在最后一帧还是复位,都是独立参数。这个差别直接决定了选型:鼠标悬停按钮变个色、菜单展开收起,用transition足够;加载指示器无限旋转、骨架屏微光来回扫、页面进场的错位弹入,必须上animation。
我在练习里给自己定了一条判断标准,后来一直在用:这个动效需不需要“自己开始”?需不需要“循环”?需不需要“多段路径”?三个里中一个就写animation,三个都不占就老老实实用transition。这条标准能挡掉大量过度设计。见过不少项目里,一个 hover 变色也写成@keyframes加infinite,代码长了三倍不说,还没法做过渡中断时的平滑衔接,属于纯纯的负收益。
1.2 @keyframes 的两种写法和关键帧的百分比语义
@keyframes有两种写法,from / to和0% / 100%。两种完全等价,但混用会很难看。我的习惯是:只有起止两帧时用from / to,一旦要写中间帧,全部换成百分比。原因很实际——两帧的时候from/to读起来像自然语言,三帧以上再写from, 50%, to就会让人愣了一下,脑子要翻译一次。
百分比的含义比想象中重要。它指的是动画时长的百分比,不是属性值的百分比。比如duration: 2s,那么25%就是第 0.5 秒。这一点新手最容易搞混,写出50% { left: 50%; }以为是从中间开始,实际上是“在第 1 秒的时候元素左偏移到父容器一半的位置”。
还有一个细节:没写出来的属性会沿用元素本身的计算值,而不是沿用上一帧。举个例子:
@keyframes fadeMove { 0% { opacity: 0; } 60% { opacity: 1; transform: translateX(40px); } 100% { opacity: 1; } }这里的100%只声明了opacity,transform会回到元素自身 CSS 里的值(大概率是none),于是你会看到元素在 60% 之后“唰”地弹回原点。我第一次踩这个坑时调了半小时,还以为是浏览器 bug。正确写法是把transform: translateX(40px)在 100% 里再写一遍,或者干脆只在 0% 和 100% 写transform,让 60% 只负责opacity。
关键帧内部其实还能写animation-timing-function,用来给某一段单独指定缓动曲线:
@keyframes bounce { 0% { transform: translateY(0); animation-timing-function: ease-out; } 70% { transform: translateY(-40px); animation-timing-function: ease-in; } 100% { transform: translateY(0); } }这段的意思是:上升阶段减速(自然),下落阶段加速(重力感)。这个技巧写“小球弹跳”“弹簧回弹”特别顺手,比调cubic-bezier试半天直观得多。很多人不知道关键帧里能塞缓动函数,白白在外面用一个全局曲线把上下两段都拉成了同一种手感。
1.3 八个子属性拆开看,别再一次写全
animation简写能一次写八个值,看起来很爽,实际上是排查问题的噩梦。我几乎不在项目里用简写,全部拆开:
| 属性 | 作用 | 常见取值 | 容易踩的点 |
|---|---|---|---|
animation-name | 引用@keyframes名 | 自定义标识 | 拼错不报错,静默失效 |
animation-duration | 单次时长 | 0.3s/300ms | 写0等于没动画 |
animation-timing-function | 缓动曲线 | ease/linear/cubic-bezier()/steps() | 默认是ease,不是linear |
animation-delay | 延迟开始 | 0s/-0.5s/200ms | 负值可直接从中间开始 |
animation-iteration-count | 播放次数 | 1/infinite/2.5 | 可写小数,会停在半路 |
animation-direction | 方向 | normal/alternate/reverse | 配合奇数次时终点状态易错 |
animation-fill-mode | 前后填充 | none/forwards/backwards/both | 决定“动完回不回弹” |
animation-play-state | 播放/暂停 | running/paused | 只能用 JS 或状态类切换 |
简写里的顺序坑在于两个时间值:第一个是duration,第二个是delay。animation: spin 1s 2s linear infinite;表示转一圈 1 秒、延迟 2 秒开始。这个顺序很多人写反,反了之后动画直接晚两秒才动、每秒转两秒的圈,看起来“怪怪的”但不容易立刻定位。
animation-delay支持负值,这是个被严重低估的特性。animation-delay: -0.8s的含义是“假装动画已经跑了 0.8 秒”,元素会直接从 0.8 秒那一帧开始播。做多个元素错峰旋转时,比给每个元素单独写@keyframes起点要省事得多——一个random出来的负延迟撒下去,一圈元素的相位立刻散开了。
2. 三个热身练习:把参数手感练出来
2.1 呼吸灯:duration 与 timing-function 的配合
第一个练习我选了呼吸灯,因为它足够简单,但把“时长感”和“曲线感”这两件事逼到台面上。目标是一团光晕缓慢起伏,像人在呼吸,不能像心跳那样急促。
.breath { width: 96px; height: 96px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #7fd1ff, #2b7fff); animation: breath 2.4s ease-in-out infinite; } @keyframes breath { 0%, 100% { transform: scale(1); opacity: 0.86; } 50% { transform: scale(1.12); opacity: 1; } }参数是这么定的:人的静息呼吸周期大约 3 到 4 秒一次完整循环,屏幕上太慢会显得卡顿、太快会显得紧张,2.4 秒是实测下来“不催人也不呆滞”的甜点值。曲线必须用ease-in-out,因为真实呼吸在吸气末和呼气末都会自然减速,用linear会得到一个机械的匀速缩放,看着像故障。缩放幅度 1.12 是试出来的——1.05 几乎看不出变化,1.25 又太夸张像心跳,1.1 到 1.15 之间最舒服。
注意0%, 100%这一行可以合并写,等价于分开写两条。合并写的好处是保证首尾两帧绝对一致,避免循环时出现“跳帧”。这是循环动画的铁律:只要iteration-count是infinite,首尾两帧的每个属性值都必须完全相等,否则每一圈结束都会有一次肉眼可见的突跳。我见过一个旋转 loading 因为首帧写rotate(0deg)、尾帧写rotate(360deg)但同时中间又插了一帧改border-color,导致颜色在循环点闪一下,查了很久才发现是插值路径的问题。
2.2 骨架屏微光扫过:linear + 位移 + 溢出裁剪
骨架屏那个从左侧扫到右侧的高光,是这几年出现频率最高的 CSS 动画之一。它的实现只有三行关键代码,但每一行都有讲究。
<div class="skeleton"> <span class="shimmer"></span> </div>.skeleton { position: relative; overflow: hidden; background: #eef1f5; border-radius: 8px; height: 16px; } .skeleton .shimmer { position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient( 100deg, transparent 20%, rgba(255, 255, 255, 0.75) 50%, transparent 80% ); animation: sweep 1.6s linear infinite; } @keyframes sweep { to { transform: translateX(100%); } }overflow: hidden是必须的,否则高光会扫出容器边界,在页面空白区域留下一道白线。inset: 0把伪元素尺寸撑满容器,比写width: 100%; height: 100%少两行。位移用translateX而不是left,这一点在第 4 章会细讲,简单说就是left会触发布局重算,translateX走合成线程,几十个骨架屏同时扫的时候差别很明显。
曲线的选择上这里反而要用linear。骨架屏是“循环的等待感”,匀速扫过最接近“数据还没来”的心理预期;换成ease-in-out会让人感觉“快来了、慢下来、又快了”,反而制造焦虑。1.6s一个来回,比常见的 1.2s 稍慢一点,实测在列表页里不会晃眼睛。
还有一个我现在固定会加的细节:高光宽度别做太窄。gradient里transparent 20%到transparent 80%意味着高光实际宽度是容器的 60%,看起来是“一片柔光扫过”,而不是“一根细线划过”。细线适合赛博风,柔光适合大多数业务界面。
2.3 打字机光标:steps() 与 blink 的节奏对齐
打字机效果是steps()的最佳教学案例。它的核心诉求是“逐字出现,不能平滑过渡”,而steps(n)的作用正好是把一段连续插值切成 n 个台阶。
.typing { width: 0; overflow: hidden; white-space: nowrap; border-right: 2px solid #333; font-family: ui-monospace, Consolas, monospace; animation: type 2.4s steps(12, end) forwards, caret 0.8s steps(1, end) infinite; } @keyframes type { to { width: 12ch; } } @keyframes caret { 50% { border-color: transparent; } }几个关键决策。第一,width从0动画到12ch,ch是字符0的宽度,配合等宽字体,12 个字符正好对应 12 个台阶,台阶数和字符数对齐,每个台阶结束就跳出一个字符,看起来就是“打出来的”。如果字体不是等宽,ch和实际字符宽度不一致,会出现某些字提前露出、某些字滞后,所以这里必须锁monospace。
第二,steps(12, end)里的end(也写作jump-end)表示每个台阶在“段末跳跃”,也就是第一个字符要等到第一段时间走完才出现。如果换成start(jump-start),第一个字符瞬间就出现,整体会早半拍。这两个值在视觉上的差异,在 12 个字符时刚好是“一个字”的差距,肉眼可辨。我的经验是打字机效果一律用end,因为人打字时是“先按键、后出字”,出字发生在时间点之后。
第三,光标闪烁单独写一条动画,用steps(1, end)做硬切。steps(1)的含义是整段只有一帧,到 50% 时直接跳到transparent,再到 100% 跳回来,中间没有任何渐变。如果用默认的缓动,border-color会平滑过渡,得到的是一个“呼吸型光标”,不是“闪烁型光标”。这条技巧在写指示灯、红点提示时都能复用。
两条动画用逗号分隔写在同一个animation里,各自有独立的时长和循环次数,互不干扰。这也是为什么我前面说别老用简写——两条动画的简写一拼,可读性直接崩掉。
注意:
width参与动画会触发布局计算。单个打字机没问题,但如果页面上有几十个同时打字,性能会掉。真遇到这种场景,换个思路:把所有文字放在一个容器里,用clip-path或一个遮罩层做位移裁切,让动画只作用于transform。
3. 进阶练习:组合动画与状态管理
3.1 多关键帧串联:卡片入场序列
单个元素动起来不难,难的是“一组元素依次动起来还不乱”。练习里我用了列表卡片入场的场景:六张卡片从下往上弹入,每张错开 60 毫秒。
@keyframes cardIn { 0% { opacity: 0; transform: translateY(24px) scale(0.96); } 60% { opacity: 1; transform: translateY(-4px) scale(1.01); } 100% { opacity: 1; transform: translateY(0) scale(1); } } .card { opacity: 0; animation: cardIn 0.46s cubic-bezier(0.22, 0.68, 0.35, 1.06) both; } .card:nth-child(1) { animation-delay: 0ms; } .card:nth-child(2) { animation-delay: 60ms; } .card:nth-child(3) { animation-delay: 120ms; } .card:nth-child(4) { animation-delay: 180ms; } .card:nth-child(5) { animation-delay: 240ms; } .card:nth-child(6) { animation-delay: 300ms; }这里有三处值得展开。第一,60%那个“过冲”帧是整个动效的灵魂。如果没有它,元素就是匀速上移然后停下,很平;加了 60% 的-4px,元素会先稍微越过目标位置,然后回落到 0,产生轻微弹性。这个“过冲幅度 4px”不是随便写的,它和translateY(24px)的位移量有关——大约是总位移的 1/6,超过 1/4 就会显得像弹簧在乱弹。
第二,缓动曲线用了自定义cubic-bezier(0.22, 0.68, 0.35, 1.06),注意最后一个值1.06大于 1,这是故意的,它让曲线尾部略微超出 1 再回来,配合关键帧里的过冲帧形成双重弹性。这种曲线适合“活泼型”界面,正式的后台系统我会换成更克制的cubic-bezier(0.25, 0.8, 0.35, 1)。
第三,.card上先写了opacity: 0。这行的作用是“防闪烁”——在animation-delay期间,元素还没开始播动画,如果没有这行,元素会以完全不透明的状态先显示 60 到 300 毫秒,然后突然消失再淡入,出现明显的闪一下。
但更优雅的解法是用animation-fill-mode: backwards或both,让元素在延迟期间就保持第一帧(opacity: 0)的状态,就不用手写opacity: 0了。这个属性的细节值得单独开一节讲。
3.2 animation-fill-mode 的四种取值实测对比
这个属性是animation里最容易搞不清的,我用一个延迟 1 秒、从opacity: 0到opacity: 1的淡入动画做了四组实测:
| 取值 | 延迟期间(0~1s) | 动画结束后 | 适用场景 |
|---|---|---|---|
none(默认) | 元素原本样式 | 回到原本样式 | 临时提示、一次性抖动 |
forwards | 元素原本样式 | 停在最后一帧 | 淡入后常驻、进度条走完停住 |
backwards | 停在第一帧 | 回到原本样式 | 延迟较长的错峰入场 |
both | 停在第一帧 | 停在最后一帧 | 既延迟又要保持终态 |
实测下来最常被误用的是forwards。很多人写完淡入发现“动完就消失了”,就是因为没加forwards,动画结束后opacity回到了元素本身的1——等等,这里其实不会消失。真正会“消失”的场景是反向的:动画是淡出,不加forwards,结束时元素弹回不透明。所以判断口诀是:动画的终态如果和你想要的最终状态不一样,就必须加forwards或both。
both是我在项目里用得最多的。它同时解决两个问题:延迟期间不闪(backwards的效果)和结束后不回弹(forwards的效果)。前面那个卡片入场的例子,正确的写法就是把.card上的opacity: 0去掉,改成both:
.card { animation: cardIn 0.46s cubic-bezier(0.22, 0.68, 0.35, 1.06) both; }一条both顶三行代码,还避免了“元素本身样式”和“动画首帧”不一致导致的撕裂。
有一个坑得提前说:forwards保持的是“最后一帧的计算值”,它不会被元素的其他样式覆盖。也就是说,如果你之后用 JS 给元素加了style.opacity = 1,但动画的最后一帧是opacity: 0.5,视觉上还是 0.5,因为动画的填充值优先级高于内联样式之外的声明。要覆盖它,只能移除动画(animation: none)或者改最后一帧。这个特性经常导致“我明明用 JS 改了样式却没反应”,排查方向就错了。
3.3 暂停与恢复:animation-play-state 与 JS 协同
animation-play-state是八个属性里唯一“天生为交互而生”的。它的取值只有两个,running和paused,但配上 JS 能做出不少实用效果。
const spinner = document.querySelector('.spinner'); // 悬停暂停 spinner.addEventListener('mouseenter', () => { spinner.style.animationPlayState = 'paused'; }); spinner.addEventListener('mouseleave', () => { spinner.style.animationPlayState = 'running'; });这个场景的实际价值比看上去大。长列表里有个一直在转的加载指示器,用户盯着它看会觉得“卡住了”,鼠标移上去暂停,反而给了他一种“我能控制”的错觉,焦虑感会下降。原理上也说得通——暂停时元素停在当前帧,视觉上就是“定格”,比继续匀速旋转更容易被接受。
另一个高频用法是“切换标签页时暂停全部动画”。页面上十来个无限循环动画同时跑,切到后台时确实会消耗资源,虽然浏览器本身会对不可见标签做节流,但显式暂停更稳:
document.addEventListener('visibilitychange', () => { const state = document.hidden ? 'paused' : 'running'; document.querySelectorAll('[data-anim]').forEach((el) => { el.style.animationPlayState = state; }); });配合>card.addEventListener('animationstart', () => { card.style.willChange = 'transform, opacity'; }); card.addEventListener('animationend', () => { card.style.willChange = 'auto'; });
无限循环的动画(如 loading)可以不移除,因为它的整个生命周期都在动。但一次性入场动画一定要在animationend里清掉,否则合成层会一直存在,页面越用越吃内存。
还有一个更轻量的替代方案:transform: translateZ(0)或者translate3d(0, 0, 0),也能触发提层。它在老代码里很常见,作用是“强制开启 GPU 加速”。但它和will-change一样是把双刃剑,而且意图不透明,现在的新项目我更倾向直接用will-change,语义清楚,也方便后续清理。
4.3 无障碍与降级:prefers-reduced-motion
这一节是我在练习里差点漏掉的部分,后来在别的项目里被人提了一嘴才补上。有一部分用户因为前庭功能敏感,看到大面积位移、缩放、视差滚动会出现眩晕甚至不适,操作系统的“减弱动态效果”开关就是给他们准备的。前端要做的事情很简单,用媒体查询接住:
@media (prefers-reduced-motion: reduce) { .breath, .shimmer, .typing, .card { animation: none !important; transition: none !important; } /* 需要保留状态变化的,压缩到接近 0 */ .card { animation: cardIn 1ms linear both; } }注意这里不能简单粗暴地全部animation: none。像卡片入场这种“动画结束后元素才可见”的场景,直接none会导致元素停在opacity: 0上彻底看不见。两个解法:要么把animation-duration压到1ms,让动画瞬间走完但终态保留;要么在reduce分支里显式把终态样式写上(比如opacity: 1; transform: none;)。我前面那段的写法其实有个矛盾——先animation: none又写animation: cardIn 1ms,后面那条会覆盖前面,实际生效的是 1ms。这个“用极短时长代替取消”的技巧比逐个补终态样式稳妥,不容易漏。
另外还有一层:入场动画别把关键操作的按钮延迟太久。骨架屏转 1.6 秒没问题,但一个提交按钮要等 300 毫秒才出现,配合网络延迟,用户会觉得页面卡了。我的经验值是交互元素的动效延迟不超过 120ms,且总时长不超过 300ms。
5. 调参、排查与踩坑实录
5.1 DevTools 动画面板的正确用法
@keyframes最难的地方不是写,是调。纯靠改代码刷新页面来试曲线,效率极低。浏览器开发者工具里有个 Animations 面板(在更多工具里),是我调动画时必开的。
它能做三件很有价值的事。第一是降速播放,把动画速度调到 10%,那些 0.3 秒一闪而过的细节就能看清楚到底是哪里不自然。第二是拖时间轴,暂停之后手动拖进度,逐帧检查某个百分比节点的实际样子,特别适合确认过冲帧的幅度够不够。第三是直接看关键帧列表,点开某个动画会列出它所有的关键帧百分比和对应属性,能立刻发现“我是不是漏写了一个transform”。
面板里还可以直接改duration和delay,改完立刻生效,不用改代码。我调错峰入场的时间间隔时,全靠这个快速扫不同的delay值,从 40ms 试到 120ms,找到看着最舒服的 60ms,然后才回去改 CSS。
一个补充技巧:调cubic-bezier的时候,很多人不知道cubic-bezier可以用另外两个点的坐标手算,也不需要在浏览器里反复试。我一般会先想清楚“是开头快还是结尾快”,开头快要cubic-bezier(0.2, 0.8, 0.4, 1),结尾快要cubic-bezier(0.6, 0, 0.8, 0.3),先套一个接近的,再微调。比从默认ease开始瞎试快得多。
5.2 常见问题速查表
下面这张表是我这几年整理下来的,几乎每次写动画都会碰到其中几条:
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 动画完全不生效 | animation-name和@keyframes名不一致 | 两边逐字符对一遍,注意大小写 |
| 动画不生效 | duration写成0或漏写 | 检查是否只写了 name |
| 动画不生效 | 元素是display: none | 改用visibility或先渲染再触发 |
| 动画只在第一次生效 | 类名移除后没触发重排 | 加void el.offsetWidth强制回流 |
| 结束后元素“弹回去” | 缺animation-fill-mode: forwards | 改为both |
| 延迟期间先闪一下 | 缺backwards或首帧状态未声明 | 用both覆盖延迟期 |
| 循环时有突跳 | 首尾帧属性值不一致 | 用0%, 100%合并写 |
| 动画卡顿掉帧 | 动了width/top/box-shadow | 换transform/opacity |
| 多动画互相覆盖 | 两条动画改了同一属性 | 拆到不同元素,或合并成一条 |
animationend不触发 | iteration-count: infinite | 无限动画本就没有结束事件 |
| 子元素也触发了事件 | animationend会冒泡 | 判断e.target === el |
其中“类名移除后没触发重排”这条值得展开。想重播一个动画,直觉写法是先remove再add同一个 class:
function replay(el, cls) { el.classList.remove(cls); el.classList.add(cls); }这段代码有相当概率不生效。原因是浏览器把这两次操作合并到同一帧的样式计算里,没观察到“类名从无到有”的过程,动画自然不重启。可靠写法是中间插一次强制回流:
function replay(el, cls) { el.classList.remove(cls); void el.offsetWidth; // 读取布局属性,强制同步回流 el.classList.add(cls); }void el.offsetWidth里的void只是为了让代码读起来明确“我不需要这个值”,不加也照样能触发回流。这个技巧本质上是“用一次廉价的重排换动画的重播”,成本可接受。
5.3 我踩过的几个坑
第一个坑是animationend冒泡。我在列表容器上挂了一次animationend监听,想统计“所有卡片入场完成”的时机,结果每张卡片结束都触发一次,计数器瞬间飙到六。修法是判断e.target,只认容器本身。但容器本身也可能有动画,所以更稳的做法是给卡片单独绑定,或者用e.animationName过滤掉不关心的动画名——一个元素上可能挂了两条动画,animationend会分别触发两次。
第二个坑是 CSS 变量在@keyframes里的滞后。我在:root上定义了--offset: 24px,关键帧里写transform: translateY(var(--offset)),然后用 JS 改--offset,期望动画路径跟着变。实测下来很多浏览器不会更新,因为关键帧里的var()在动画开始时就解析了,后续改变量不会重启动画。解决办法要么移除并重加动画,要么干脆把变量换成类名切换。这个坑挺隐蔽,因为动画不会报错,只是“看起来没反应”。
第三个坑是overflow: hidden和border-radius一起用导致的裁剪失效。做旋转 loading 时,我用圆形容器加overflow: hidden裁掉外圈,某些浏览器上圆角处会漏出一条直角边。后来改成给内层元素自己加圆角、或者用clip-path: circle(),就干净了。这类“圆角裁剪”的问题在transform参与时特别容易出现,因为合成层和裁剪的边界处理有微妙差异。
第四个坑是多个animation简写拼在一起的顺序问题。我写过animation: spin 1s linear infinite, pulse 2s ease-in-out infinite 0.5s;,本意是第二条延迟 0.5 秒,结果发现0.5s被当成了第二条的duration,2s被当成了delay。因为简写里第一个时间值是时长、第二个才是延迟,所以正确顺序是pulse 2s 0.5s ease-in-out infinite。这种错误非常难靠肉眼发现,因为两条动画都在动,只是节奏不对。从那以后我彻底放弃多动画的简写,全部拆成独立属性。
5.4 把练习沉淀成自己的动画片段库
练习做完最大的收获不是学会了写某个效果,而是攒出了一套能直接复用的片段。我的做法是在项目里单独放一个motion.css,只收三类东西:关键帧定义、常用的动画工具类、无障碍降级。
关键帧统一用动词加对象命名,比如cardIn、shimmerSweep、caretBlink,不用fade、move这种过于宽泛的名字,避免和第三方库撞名。工具类只保留真正高频的三个:.anim-fade-up、.anim-fade-in、.anim-spin,其余全部按场景写。类名尽量只声明animation相关属性,不掺颜色和尺寸,保证它能在任何元素上叠加。
还有一条经验:把时长、延迟、过冲幅度抽成:root上的变量,但只在普通声明里用,不在@keyframes内部用。比如:
:root { --dur-fast: 180ms; --dur-base: 300ms; --dur-slow: 460ms; --delay-step: 60ms; } .anim-fade-up { animation: cardIn var(--dur-slow) cubic-bezier(0.22, 0.68, 0.35, 1.06) both; }这样调整整个项目的动效节奏时,改三个数字就够,不用满项目搜0.3s。而关键帧内部保持字面量,避开前面说的变量滞后问题。
最后分享一个小习惯,我觉得比任何技巧都管用:每写完一个动画,先把速度降到 10% 看一遍。慢放能暴露大量正常速度下看不见的问题——首尾帧没对齐导致的突跳、过冲帧幅度过大导致的抖动、两条动画相位错位导致的违和感。正常速度下“看着还行”的动画,慢放下来十有八九都有毛病,修完再恢复正常速度,质感会明显不一样。