你有没有遇到过这种情况:照着别人的代码把 transition: all 0.3s ease 抄上去,页面确实动了,但动起来总有一种说不出来的“假”。按钮变色像是在硬切,卡片伸缩像根老化弹簧,鼠标一移走元素“啪”地弹回原样。于是你开始怀疑自己代码写错了,把属性位置换了又换,最后干脆一句“浏览器渲染问题”把锅甩了出去。
其实问题不在浏览器,也不在你不够细心,而是 CSS 过渡缓动这条线,绝大多数教程只让你背语法,从没告诉你怎么用。这篇不绕弯子,先从“浏览器到底在过渡什么”讲起,再把缓动函数掰开揉碎,最后给三个可以直接抄进项目的案例(涟漪扩散、流光边框、数字滚动)和几个新手必踩的坑位。适合谁看?刚写完静态页面想给交互加点质感的同学,以及在面试里被问到“transition 和 animation 区别”会卡壳的同学。看完你会发现,缓动这件事,本质上就是给元素一点“人的反应速度”。
1. transition 全写在一起就能上手,但真正决定成败的是你写在哪个状态
1.1 一条 transition 简写里到底藏了什么
transition: all 0.3s ease; 是新手最常写的“全家桶”,它能动起来,但大概率不是最优解。拆开看,这条简写一次性设置了四个子属性:
transition-property:要过渡的属性,all表示全部transition-duration:过渡耗时transition-timing-function:缓动函数transition-delay:延迟多久才开始过渡
写成完整形式是:
css复制.btn {
transition-property: all;
transition-duration: 0.3s;
transition-timing-function: ease;
transition-delay: 0s;
}
如果你想同时过渡多个属性,可以用逗号分隔,比如:
css复制.btn {
transition: background-color 0.25s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
很多人只知道“写属性加时长”,却不知道 transition-property 指定的是“你想让哪一个属性在变化时生成补间帧”。如果写 all,相当于告诉浏览器“这个元素上所有可过渡属性发生变化时都给我补间”,看起来省事,但窗口 resize、样式刷新时会把大量无关属性纳入过渡计算,体感就是卡顿和抖动。
还有个隐藏细节:transition 的默认值并不是很多人以为的“没有动画”。就算你只写 transition: 1s,浏览器也会自动补上 all、ease,但因为 transition-duration 有了具体值,所有属性变化都会被纳入过渡。如果你只想要某个属性动,就必须显式列出来。
1.2 一个真实按钮:为什么鼠标移开后反而跳变
先看一段再普通不过的代码:
css复制.btn {
width: 160px;
height: 44px;
border: none;
border-radius: 8px;
background: #4f8cff;
color: #fff;
transition: background-color 0.25s ease;
}
.btn:hover {
background: #2b6bff;
}
这段没问题。transition 写在 .btn 默认状态上,鼠标移入和移出时,background-color 都有过渡效果。
但很多新手会不自觉写成这样:
css复制.btn:hover {
background: #2b6bff;
transition: background-color 0.25s ease;
}
这时候鼠标移入,过渡正常播放;鼠标一移出,:hover 状态被撤销,transition 属性连同 background-color 一起被移除,元素瞬间弹回浅蓝色。这个“还原跳变”是很多人口中“越写越僵硬”的源头。
结论一句话:transition 应该写在元素的默认状态上,让它成为元素的“固有属性”;:hover、:focus 等状态只负责修改属性值,不要负责“开启动画”。
1.3 时间和延迟的直觉:不是越长越好
过渡时间的选择,直接决定用户觉得“灵敏”还是“拖沓”。我做交互时有个基准线:100ms 到 300ms 是交互反馈最舒服的范围。少于 100ms 几乎感觉不到过渡,超过 400ms 用户会开始觉得系统变慢了。
具体场景可以这样拆:按钮颜色变化用 200ms 左右;弹窗入场用 200ms 到 300ms;大区块移动可以放到 350ms;那种需要刻意强调的“超级慢动作”另说。至于 transition-delay,新手常常忽略它,但它非常适合做“错峰”效果。比如涟漪扩散时,第二层光圈延迟 80ms 再开始,视觉上就有了一前一后的层次感,而不是糊成一团。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓动函数是手感差异的根源:从 linear 到 cubic-bezier 的自定义逻辑
2.1 浏览器在过渡时到底做了什么
transition 的本质是补间动画。起始值和目标值中间有无数可能的中间值,浏览器拿到这两个值之后,会在设定的时长内,按帧把中间值算出来填进去。
比如一个元素 left 从 0px 变到 200px,耗时 0.4s。浏览器不会瞬间把 200px 丢给你,而是每 16ms(一帧)取一个中间值,让你看到元素从 0 平滑走到 200 的过程。
transition-timing-function 决定的就是“时间进度到属性进度”的映射关系。这句话很抽象,我用走路类比一下:同样是走 200 米路,linear 是每一步都一样长,全程匀速;ease-out 是刚出发时步子迈得很大、快要到终点时慢慢收;ease-in 是先磨蹭小步走,后程发力冲刺。三种走法总时间一样,但用户感知到的“节奏”完全不同。
2.2 内置缓动函数:选错本质上是“出/入/往返”搞混了
CSS 内置的缓动函数只有五个:linear、ease、ease-in、ease-out、ease-in-out。日常够用,但绝大多数人习惯闭眼用默认的 ease,结果所有动画都是一种模棱两可的“中间快两头慢”。
| 函数 | 曲线特征 | 适合场景 |
|---|---|---|
linear |
直线匀速 | 进度条、加载条、机械运动、颜色闪烁 |
ease |
两头略慢中间快 | 默认值,但多数场景目标不明确 |
ease-in |
慢到快 | 离场、消失、被甩出去 |
ease-out |
快到慢 | 入场、出现、弹窗落下后停稳 |
ease-in-out |
慢到快再慢 | 往返移动、元素从 A 点移到 B 点 |
为什么要专门区分出和入?现实世界里,物体接近终点时一般会减速,所以“出现”用 ease-out 会显得自然;而“消失”更像是被吸走,越走越快反而贴合直觉,用 ease-in。新手最容易犯的错就是所有元素都用同一个 ease,出来的效果说不上难看,但就是没有“手感”。
2.3 cubic-bezier 不长记性:去理解控制点
内置缓动不够用时,就要上 cubic-bezier(x1, y1, x2, y2)。这个函数名看着吓人,其实只是告诉你:贝塞尔曲线由两个控制点决定。
两个控制点 P1(x1, y1)、P2(x2, y2) 分别控制曲线前半段和后半段“拱”的程度。其中 x 值限制在 0 到 1,代表时间轴上的位置;y 值可以小于 0 也可以大于 1,一旦 y 出了 0 到 1 的区间,曲线就会“冲出终点”,视觉上表现为回弹或者反向。
不用记太多曲线,我实际项目里常驻的就这几条:
- 标准出场:
cubic-bezier(0.22, 1, 0.36, 1),又快又稳的减速 - 轻微回弹:
cubic-bezier(0.34, 1.56, 0.64, 1),适合按钮按下、小卡片弹出 - 反向过冲:
cubic-bezier(0.68, -0.55, 0.265, 1.55),会先向后缩再冲过终点
比如按钮悬停时略带一点“弹起来再落回去”的感觉:
css复制.btn {
transform: translateY(0);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.btn:hover {
transform: translateY(-4px);
}
因为 y 值超过 1,元素先冲过 -4px 又弹回,视觉上就是一个轻巧的回弹。调试这些曲线时,我基本不背数值,直接在浏览器 DevTools 的 Easing 编辑器里拖控制点,拖到满意再复制曲线值。
2.4 一出一入一快一慢:完整弹窗的节奏
弹窗是缓动函数最典型的应用场景。入场上,透明度从 0 到 1,同时位移从 12px 到 0,用 200ms 的 ease-out;离场时,透明度从 1 到 0,位移从 0 到 8px,用 150ms 的 ease-in。
css复制.popup {
opacity: 0;
transform: translateY(12px);
transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}
.popup.show {
opacity: 1;
transform: translateY(0);
}
.popup.hide {
opacity: 0;
transform: translateY(8px);
transition: opacity 0.15s ease-in, transform 0.15s ease-in;
}
入和出分开写,视觉上就不一样。这个细节做完之后,弹窗不再是“生硬的矩形”,而是有了一点类似真实物体运动的质感。
3. 最容易劝退新手的几个 transition 坑位
3.1 display:none 一切过渡都会瞬移
这是新手问得最多的问题:“我给元素写了 transition: opacity 0.3s,为什么隐藏它的时候还是瞬间消失?”
原因很简单:display 属性不是可过渡属性。从 display: none 到 display: block,浏览器会直接渲染出来,没有中间值可算;从 block 到 none,也一样瞬移。你光改了 display,哪怕旁边写了 opacity 过渡,浏览器也只认“最终状态”。
正确做法是让元素“看不见但仍然占用渲染通道”,用 visibility 配合 opacity:
css复制.pop {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0s 0.2s;
}
.pop.show {
opacity: 1;
visibility: visible;
transition: opacity 0.2s ease;
}
注意隐藏状态那条 visibility 0s 0.2s,含义是“隐藏要等 0.2s 淡出结束后再生效”;而 .show 里的显示状态没有延迟,所以透明度一变就能出现。这个延迟错开的写法,能同时解决淡入淡出和点击穿透问题。
3.2 height:auto 无法过渡,但展开收起又必须做
展开收起菜单是高频需求,但 height: auto 没法直接过渡,因为浏览器算不出“auto 到 200px 之间第一帧长什么样”。常规解法是用 max-height 设定一个足够大的值:
css复制.collapse {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.collapse.open {
max-height: 400px;
}
这个方案的问题是 400px 是个魔法数字,内容高度差太多的时候会出现“前 200px 慢慢展开,后 200px 突然加速”的假像。新一点的浏览器可以换用 grid-template-rows:
css复制.wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.wrap.open {
grid-template-rows: 1fr;
}
.wrap > .inner {
min-height: 0;
overflow: hidden;
}
0fr 到 1fr 是可补间的,内容高度由内部撑开,魔法数字彻底消失。唯一要注意的是内部盒子必须加 min-height: 0,否则内容会把 0fr 撑破。
3.3 transition: all 是卡顿和抖动的源头
前面提过 all 会把所有属性都纳入过渡。实际项目里,这意味着你拖动窗口、切换主题、动态插入 class 时,凡是发生变化的属性都会尝试补间,不必要的重绘和重排接踵而来。
我习惯每个属性单独写,或者至少把需要过渡的属性显式列出来。比如卡片既要位移又要透明度:
css复制.card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
这样浏览器就知道只需要为 transform 和 opacity 创建补间任务,其他属性变化直接跳过,性能负担小很多。排查卡顿的时候,先把 transition: all 找出来改成具体属性,大概率能解决一多半问题。
3.4 同一个动画只触发一次,第二次点击没反应
用 JS 配合 class 切换时,容易掉进这个坑。假设按钮每次点击都要放大缩小一次,第一次正常,第二次就没反应了。原因是第一次结束后 class 被移除,属性值已经回到初始态,下一次再添加相同 class 时,浏览器认为“前后值都一样”,直接不产生过渡。
解决办法是在 DOM 操作里强制浏览器重新计算一次样式,也就是强制回流:
js复制// 第一次结束后
el.classList.remove('pulse');
void el.offsetWidth; // 强制回流,让浏览器重新记录当前状态
el.classList.add('pulse');
el.offsetWidth 就像按了一下刷新键,浏览器重新读取布局数据之后,class 再次变化就有过渡可播了。
3.5 fixed 定位突然失效,锅不一定在 transition
还有种情况:某个 position: fixed 的元素突然不固定了,开始跟着页面滚动,或者定位基准变成了它的父级。新手第一反应是 transition 写错,但其实问题在 transform 上。
一旦某个父级元素设置了 transform,它就会变成内部所有 fixed 后代的“包含块”。fixed 不再相对于视口定位,而是相对于这个父级。这个行为和 transition 没有直接关系,但很多动效都要用 transform,所以特别容易遇到。如果你发现 fixed 失效,检查一下是不是有祖先元素加了 transform、perspective 或 filter。
4. 三个能直接抄的高颜值案例:涟漪扩散、流光边框、数字滚动
4.1 涟漪光圈扩散:用 scale 代替 width/height
涟漪效果常见于按钮点击或者卡片悬停,视觉上是一个光圈从中心向外扩散,同时淡出。用伪元素加 transition 就能实现,关键是用 transform: scale() 而不是 width/height 做扩散,因为前者不触发布局重排,性能更好。
html复制<a class="ripple-btn" href="#">悬停扩散</a>
css复制.ripple-btn {
position: relative;
overflow: hidden;
display: inline-block;
padding: 12px 28px;
background: #4f8cff;
color: #fff;
}
.ripple-btn::before,
.ripple-btn::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.6);
transform: translate(-50%, -50%) scale(1);
opacity: 1;
transition: transform 0.6s ease-out, opacity 0.6s ease-out;
}
.ripple-btn::after {
transition-delay: 0.1s;
}
.ripple-btn:hover::before,
.ripple-btn:hover::after {
transform: translate(-50%, -50%) scale(8);
opacity: 0;
}
两层伪元素一个立即扩散、一个延迟 0.1s 跟上,就有了“光圈层层推进”的感觉。overflow: hidden 保证了光圈不会跑到按钮外面去。
如果你要做的是“每次点击都从点击位置扩散”,那 CSS 本身没法直接拿到鼠标坐标,需要 JS 配合设置 --x、--y 变量,思路是一样的:先定位到点击位置,再把元素 scale 放大。
4.2 流光边框:渐变边框和扫光带两种方案
流光边框在活动页、个人名片、提示卡上都很出彩。第一种方案是用渐变背景加 background-position 过渡:
css复制.border-flow {
border: 1px solid transparent;
background:
linear-gradient(#fff, #fff) padding-box,
linear-gradient(120deg, #f1650f, #ffd000, #f1650f) border-box;
background-size: 200% 200%;
background-position: 100% 0;
transition: background-position 0.6s ease;
}
.border-flow:hover {
background-position: 0 100%;
}
原理是两层背景:内层白色背景负责填充内容区域,外层渐变背景通过 border-box 显示在边框区域。悬停时让渐变背景的 background-position 从右下角划到左上角,视觉上就像一束光沿着边框扫过。
第二种方案更适合用在文字扫光上。伪元素里放一条高光带,从左到右滑过:
css复制.text-shine {
position: relative;
overflow: hidden;
}
.text-shine::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
transform: translateX(-100%);
transition: transform 0.8s ease;
}
.text-shine:hover::before {
transform: translateX(250%);
}
两种方案的取舍:渐变边框适合边框本身色彩流动;伪元素扫光适合在图片、标题上制造高光掠过。注意伪元素扫光带宽度不要太大,40% 左右效果最好,太宽会显得像整块曝光。
4.3 数字滚动:用 transform 做“卷帘”效果
数字加载动画、倒计时、数据大屏上的数字切换,都能用这个方案做。思路是外层盒子固定高度并设置 overflow: hidden,内层数字列每次切换时整体位移,形成向上滚动的错觉。
html复制<div class="digit">
<div class="inner">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
</div>
</div>
css复制.digit {
height: 40px;
overflow: hidden;
font-size: 40px;
line-height: 40px;
}
.digit .inner {
display: flex;
flex-direction: column;
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.digit .inner span {
height: 40px;
text-align: center;
}
JS 侧要做的事很简单:把目标数字换算成 translateY 值写到 .inner 上,CSS 自动补动画:
js复制function setDigit(el, target) {
el.querySelector('.inner').style.transform = `translateY(-${target * 40}px)`;
}
-40px * target 就是让目标数字的那一行刚好出现在可见区域。这里 CSS 负责的是“播放动画”,JS 只负责“告诉浏览器终点在哪”,分工很清晰。这也是 transition 类动效的通用套路:触发源可以是 hover、点击,也可以是 JS 改变属性值。
5. 再往前走一步:transform 组合、steps() 与性能意识
5.1 多属性过渡时,你只需要一个 transform
新手经常犯一个错误:想同时缩放和位移,结果写了两个 transition,发现后一个覆盖了前一个。因为元素只能有一个 transform 属性,所有变换要写在同一个值里:
css复制.ball {
transform: translateX(40px) scale(1);
transition: transform 0.3s ease;
}
.ball:hover {
transform: translateX(60px) scale(1.2);
}
这里需要提醒一个容易懵的细节:CSS 的 transform 书写顺序是从右往左应用的。translateX(40px) scale(1) 先执行 scale 再执行 translate,反过来写效果就不同。实际项目里,为了让位移和缩放符合直觉,我一般按“scale 在前、rotate 其次、translate 最后”的顺序排列,也就是先把元素缩放到目标比例,再移动位置,这样位移距离不会受缩放影响。
5.2 什么时候该用 animation,而不是 transition
transition 只能定义“开始态”和“结束态”,它擅长的是单次交互反馈。但如果你要表达更丰富的节奏,比如“弹一下、停住、再抖两下”,就必须用 animation + @keyframes:
css复制@keyframes bounce {
0% { transform: scale(1); }
30% { transform: scale(1.2); }
60% { transform: scale(0.95); }
100% { transform: scale(1); }
}
.card {
animation: bounce 0.6s ease-out;
}
@keyframes 可以定义多个关键帧,浏览器在帧与帧之间同样会自动补间。面试常问到“transition 和 animation 的区别”,核心就一句话:前者需要状态变化触发、只有起止两态;后者可以主动播放、支持多关键帧循环。
另外注意,animation 默认只播放一次,想循环要加 infinite。做加载条、骨架屏这类持续动效,用 animation 比强行用 transition 反复切换 class 省事得多。
5.3 steps():逐帧动画的正确打开方式
如果你在做雪碧图动画,比如一个跑步动作拆成了 6 帧,想按帧切换而不是平滑过渡,那要用 steps() 这个缓动函数:
css复制.sprite {
width: 100px;
height: 100px;
background-image: url("run-sprite.png");
background-size: 600px 100px;
animation: play 0.6s steps(6) infinite;
}
@keyframes play {
from { background-position: 0 0; }
to { background-position: -600px 0; }
}
steps(6) 意思是在 0.6s 内分成 6 个等长阶段跳动,每帧直接跳到下一帧,没有平滑补间。这个适合像素风、翻页卡、表情图标这类需要“顿挫感”的动效,用错了就会出现模糊的拉伸效果。
5.4 性能意识:只过渡“画”得快的属性
动效卡不卡,性能瓶颈往往不在 CSS 本身,而在被过渡的属性属于哪个渲染阶段。
transform和opacity:合成器直接处理,不触发布局和重绘,GPU 友好,随便用。margin、left、top、width、height:会触发布局计算,每一帧浏览器都要重新算位置,量大必卡。color、background-color、box-shadow:会触发重绘,不触发布局,但也不便宜。
所以一个我反复提醒自己的原则是:能用 transform 完成的位置变化,绝不用 left/top。比如做“回到顶部”的按钮,让它在页面角落弹出来,用 transform: translateY(20px) 到 translateY(0),而不是去改 top 值。
至于 will-change,它确实能提前告诉浏览器“这个元素接下来要动”,但别到处加。正确的用法是给“正在过渡或即将持续动画”的元素加,动画结束就移除。给全站所有元素加 will-change: transform,会白白吃掉大量 GPU 内存,反而更卡。
5.5 关于“屏幕穿出来”这类视角效果
热搜里常有人问“CSS 能实现屏幕穿出来的效果吗”,答案是可以,但和 transition 关系不大,主要是 transform: perspective() 和 translateZ() 的 3D 变换在起作用。如果你只想在卡片悬停时有一点“朝你推出来”的体感,用 scale 就够;如果要更夸张的穿屏感,得配合 perspective 和 translateZ,而且要注意设置父级的 transform-style: preserve-3d,否则子元素的 3D 效果会被压平。这个方向有兴趣可以单独深入,日常交互里别一上来就上 3D,很容易给用户造成眩晕感。
最后说一个我做动效时的固执习惯:所有交互动效必须写退场。进场丝滑不算完,鼠标一移走,元素要是瞬间跳回原样,前面建立的所有好感都会垮掉。所以每一条 transition 写完,我都会快速移入移出,盯一眼还原路径,确认两条方向都有动效再提交。缓动曲线的积累很像手感,多调几次,慢慢就会形成肌肉记忆,看到一条曲线就能大概想象出它的节奏。这个能力,比背多少个 API 都值钱。
