CSS过渡与简单动画

张玥 2026年9月20日 阅读时间 45分钟
CSS transition @keyframes transform 性能优化
CSS过渡与简单动画

如果说布局决定了一个页面的“骨架”,那么动效决定的是它的“呼吸”。一个按钮在悬停时轻轻抬起 2 像素,一个下拉菜单用 180ms 滑出而不是瞬间弹出,一个卡片在滚动到视口时淡入——这些细节加起来,就是用户口中那句说不清道不明的“这个网站用起来很舒服”。CSS 过渡与动画,正是实现这一切成本最低、性能最好的手段:不需要任何第三方库,不需要 JavaScript 参与逐帧计算,交给浏览器的合成器线程就能跑满 60fps。但低门槛也意味着滥用——很多人写的动画又卡又乱,根源往往不是“写得不好看”,而是压根不了解过渡的四要素、缓动曲线的数学含义、以及浏览器渲染流水线里哪些属性便宜、哪些属性昂贵。这篇 45 分钟的长文,从 transition 的每一个子属性讲起,途经缓动函数、transform 变换、@keyframes 八大属性、渲染性能、实战模式、无障碍与调试技巧,最后给出一份可以直接贴到工位上的检查清单。读完它,你应该能在任何项目里自信地写出既好看又快、还对所有人都友好的动效。

一、transition:两个状态之间的补间

过渡(transition)解决的问题极其简单:当某个 CSS 属性的值发生变化时,不要让浏览器瞬间切换,而是在一段时间内平滑地从旧值插值到新值。它的语法由四个子属性组成:

/* 简写:属性 时长 缓动 延迟 */
.card {
  transition: transform 0.3s ease 0s;
}

/* 等价的长写法 */
.card {
  transition-property: transform;
  transition-duration: 0.3s;
  transition-timing-function: ease;
  transition-delay: 0s;
}

/* 多个属性用逗号分隔,各自的时长可以不同 */
.card {
  transition: transform 0.25s ease-out, box-shadow 0.4s ease, opacity 0.2s linear;
}

/* 触发状态变化 */
.card:hover {
  transform: translateY(-6px);
  box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
  opacity: 0.95;
}

这段代码有一个容易被忽略的细节:transition 写在基础状态(.card)上,而不是 :hover 上。写在基础状态,意味着进入和离开两个方向都有过渡;只写在 :hover 里,则鼠标移入时有动画、移出时瞬间弹回。当然,有时候“进出不对称”正是你想要的效果——比如进入快、离开慢,那就分别写在两个规则里。

哪些属性可以被过渡

过渡的本质是“插值”(interpolation),所以只有那些存在中间值的属性才能参与。长度(px/rem/%)、颜色、透明度、角度、transform、filter、clip-path 都可以;而 display、position、font-family、visibility 的切换行为要么是离散的、要么根本没有中间态。

属性类别 代表属性 能否过渡 渲染代价
变换类 transform、translate、rotate、scale 可以 仅合成
透明度 opacity 可以 仅合成
颜色类 color、background-color、border-color 可以 重绘
滤镜 filter、backdrop-filter 可以 合成 + 栅格化
形状 clip-path、border-radius 可以 重绘
尺寸位置 width、height、top、left、margin 可以 重排
显示模式 display、position 不可以 —
关键字值 height: auto、width: fit-content 不可以 —

最后一行值得单独说明:auto 不是长度,浏览器不知道从 0 到 auto 之间该怎么插值,所以 height: 0 → height: auto 的展开动画是无效的。想实现“高度自适应展开”,要么用 max-height 从一个足够大的值过渡到 0,要么用现代的 grid-template-rows: 0fr → 1fr 技巧,要么直接改用 transform: scaleY()(但它会拉伸内容)。

/* 方案一:max-height(简单,但缓动会失真) */
.panel {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.4s ease;
}
.panel.open { max-height: 500px; }

/* 方案二:grid-template-rows 的 0fr → 1fr(推荐) */
.panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.4s ease;
}
.panel.open { grid-template-rows: 1fr; }
.panel > div { overflow: hidden; min-height: 0; }

transition-property: all 是个温柔的陷阱

很多人图省事写 transition: all 0.3s;。它在小项目里确实方便,但随着样式表增长,问题会逐渐暴露:

  • 任何属性变化都会走动画,包括你根本没打算动的东西。比如某个布局属性因为窗口缩放发生变化,也会被“平滑过渡”,产生莫名其妙的抖动。
  • 性能不可控。今天你只改了颜色,明天有人在同一个元素上加了 width,动画就悄悄从“仅重绘”升级成了“重排”。
  • 调试困难。当页面出现奇怪的延迟感,你得逐个排查到底是哪个属性在动。

正确的做法是显式列出需要过渡的属性。如果确实需要很多属性,也应该分组写、分别给时长,而不是一把梭。all 只在你写原型、确认完所有属性之后再考虑保留。

transition 的三个触发条件

一段过渡要真正跑起来,必须同时满足:

  1. 元素在两个状态下都有明确的值。如果起始值是 auto、或者元素刚刚被 display: none 隐藏,就没有起点。
  2. 属性值确实发生了变化。把 opacity 从 0.5 改到 0.5,不会有任何动画。
  3. 元素处于渲染树中。用 JS 刚 createElement 出来、还没插入文档的元素,设置样式不会触发过渡。
反例
el.style.display = 'block';
el.style.opacity = 1;

同一帧内先 display: block 再改透明度,浏览器还没有为新元素建立起始样式,透明度会瞬间变成 1,完全看不到淡入。
正解
el.style.display = 'block';
void el.offsetWidth;  // 强制回流,确立起始样式
el.style.opacity = 1;

先让浏览器“看一眼”初始状态,再触发变化。

更现代的写法是用 requestAnimationFrame 把第二次赋值推到下一帧,或者干脆用 @starting-style 规则声明入场起点。后者是 CSS 的新特性,专门用来解决“元素首次出现时无法过渡”这个老问题:

.toast {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

@starting-style {
  .toast {
    opacity: 0;
    transform: translateY(-12px);
  }
}

这段代码的意思是:“当这个元素第一次被渲染时,先把它的初始样式设定为透明并上移 12px,然后过渡到正常状态。”不需要任何 JS,toast 就自动拥有了入场动画。

二、缓动函数:动画的“性格”来自哪里

时长决定动画有多快,缓动决定动画的“性格”。同样的 300ms,用 linear 是机械的,用 ease-out 是轻快的,用 cubic-bezier(0.34, 1.56, 0.64, 1) 则带一点俏皮的回弹。很多人写动画时只改时长不改缓动,结果页面怎么调都“差点意思”,问题通常就出在这里。

五个关键字,记住它们背后的曲线

linear 匀速。等价于 cubic-bezier(0, 0, 1, 1)。适合进度条、旋转加载图标这类“持续稳定”的运动。
ease 默认值,cubic-bezier(0.25, 0.1, 0.25, 1)。中段快、两端慢,是个“万金油”,但也因此缺乏个性。
ease-in 慢起快终。像物体从静止加速,适合“元素飞出去”的场景。
ease-out 快起慢终。像物体减速停下,最适合 UI 元素的入场,因为用户注意力集中在运动结束的位置。
ease-in-out 两端慢中间快。适合需要“回到原位”的往返运动,比如抽屉开合、模态框缩放。

有一条经验法则值得记住:用户看向哪里,哪里就应该慢。元素从屏幕外飞入时,用户的目光会追随它到达终点,所以入场用 ease-out;元素飞出屏幕时,用户的注意力还留在原地,所以离场用 ease-in 更自然。反过来用会让人产生“还没看清就没了”或“卡住了”的错觉。

cubic-bezier:四个数字的物理学

cubic-bezier(x1, y1, x2, y2) 描述的是三次贝塞尔曲线的两个控制点,起点固定在 (0,0)、终点固定在 (1,1)。横轴是时间进度(0 到 1),纵轴是属性值的变化进度(可以超出 0~1,这就是回弹效果的来源)。

/* 平滑减速,常用于大型元素入场 */
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);

/* 轻微回弹,y1 大于 1 就会“冲过头再回来” */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);

/* 强回弹 */
transition-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55);

/* 阶跃:6 步走完,适合打字机、帧动画 */
transition-timing-function: steps(6, end);

steps() 是缓动函数家族里的异类,它不做插值,而是把整个过程切成 N 个离散的台阶。steps(6, jump-end) 的意思是“分 6 段跳,最后一段结束时不再跳”。它最常见的用途是打字机效果、逐帧精灵图动画,以及任何需要“卡顿感”的复古风格。

动手试一试

下面的实验台可以让同一个小球用不同的缓动函数跑完同样的距离。注意观察:距离和时长完全一致,但因为缓动不同,你会觉得它们速度差别巨大——其中回弹曲线还会冲出终点再折返。

transition-timing-function: linear

缓动与“反向播放”的坑

还有一个大多数人没意识到的细节:当过渡反向播放时,缓动曲线也会反向。也就是说,一个用 ease-in(慢起快终)的元素,在鼠标移出、动画倒放时,会变成“快起慢终”。如果你希望移入和移出都符合直觉,就要在基础状态和 :hover 状态分别指定缓动函数:

.btn {
  transform: translateY(0);
  /* 离开时:慢慢回到原位 */
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}

.btn:hover {
  transform: translateY(-4px);
  /* 进入时:干脆利落地抬起 */
  transition: transform 0.18s ease-out;
}

三、transform:不触发重排的几何变换

如果只能记住一条 CSS 动画的性能建议,那一定是这条:能用 transform 实现的位移和缩放,绝对不要用 top / left / width / height。前者在渲染流水线的最后一步(合成)完成,后者则会引发整个页面的重新布局。

四类基础变换

/* 平移:不改变元素在文档流中的占位 */
transform: translateX(20px);
transform: translateY(-50%);
transform: translate3d(0, 20px, 0);

/* 缩放:1 是原尺寸 */
transform: scale(1.08);
transform: scaleX(1.2);

/* 旋转:正值为顺时针 */
transform: rotate(45deg);
transform: rotate(-0.5turn);

/* 斜切:会让文字变形,慎用 */
transform: skewX(-12deg);
translate 位移,百分比相对于自身尺寸
scale 缩放,会放大像素导致模糊
rotate 旋转,单位 deg / rad / turn
skew 斜切,文字会跟着倾斜
注意 transform 不影响周围元素的布局

translate 的百分比基准是元素自身的宽高,这带来一个极其常用的技巧:绝对定位居中。

/* 经典绝对居中:先偏移父容器的一半,再回退自身的一半 */
.center {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

更重要的是,当这个元素的尺寸变化时(比如文字增多),-50% 会自动重新计算,不需要像 margin-left: -100px 那样写死数值。

组合顺序决定最终结果

多个变换函数可以写在同一个 transform 里,但顺序不可交换。浏览器会按从左到右的顺序依次应用矩阵乘法,而矩阵乘法不满足交换律:

/* A:先平移 100px,再整体旋转 45° —— 运动轨迹是一条圆弧 */
transform: translateX(100px) rotate(45deg);

/* B:先旋转 45°,再沿旋转后的坐标轴平移 —— 轨迹是一条 45° 斜线 */
transform: rotate(45deg) translateX(100px);

记忆方法:把它想象成你手里拿着一个物体。写在前面的操作,相当于先移动你的手,再转动物体。写反了,物体就会跑到意想不到的位置。

transform-origin:变换的支点

默认的变换原点是元素的几何中心(50% 50%)。把原点改掉,同样的变换会呈现出完全不同的效果。下面这个“菜单图标旋转成叉号”的动画,就非常依赖原点与旋转角度的配合:

.burger-line {
  transform-origin: center;
  transition: transform 0.3s ease, opacity 0.2s ease;
}

/* 上下两条线各旋转 45°,中间那条淡出 */
.burger.open .line-1 { transform: translateY(8px) rotate(45deg); }
.burger.open .line-2 { opacity: 0; transform: scaleX(0); }
.burger.open .line-3 { transform: translateY(-8px) rotate(-45deg); }

注意 transform-origin 本身也是可以过渡的,但在动画过程中改变它会显得非常怪异,通常只在静态切换时使用。

3D 变换与 perspective

只要加上 perspective,平面的 transform 就立刻拥有了纵深。这是卡片翻转、3D 轮播、悬浮倾斜效果的基础:

.scene {
  perspective: 1000px; /* 观察者到 z=0 平面的距离 */
  perspective-origin: 50% 50%;
}

.card-3d {
  transform-style: preserve-3d; /* 让子元素保留三维空间 */
  transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}

.card-3d.flipped {
  transform: rotateY(180deg);
}

.card-face {
  backface-visibility: hidden; /* 背面不可见 */
  position: absolute;
  inset: 0;
}
.card-back { transform: rotateY(180deg); }

perspective 的值越小,透视越夸张、元素看起来离你越近;值越大,越接近正交投影。做卡片翻转时,800px 到 1200px 是比较舒服的区间。

为什么不建议用 scale 放大文字

scale 是纯视觉缩放,浏览器不会重新排版,也不会重新栅格化文本到新的字号——它只是把已经画好的像素放大。所以从 1 放大到 3,文字会明显发虚。如果确实需要清晰的大字号,应该改变 font-size 并接受重排的代价,或者把缩放幅度控制在小范围内(1.05~1.2 通常肉眼很难察觉模糊)。

四、@keyframes 与 animation 的八大属性

过渡只能描述“从 A 到 B”,而且必须由状态变化触发。当动画需要多个阶段、需要自动播放、需要循环、需要中途改变方向时,就该 @keyframes 出场了。

关键帧的写法

@keyframes pulse {
  0% {
    transform: scale(1);
    box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5);
  }
  70% {
    transform: scale(1.05);
    box-shadow: 0 0 0 14px rgba(37, 99, 235, 0);
  }
  100% {
    transform: scale(1);
    box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
  }
}

/* from / to 是 0% / 100% 的别名 */
@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

关于关键帧有三条实用规则:

  • 没写的属性会被插值。如果 0% 写了 transform 而 100% 没写,浏览器会拿元素的“默认值”(即没有动画时的计算值)来补全。
  • 只写一个关键帧是合法的。@keyframes blink { to { opacity: 0 } } 表示“从当前状态过渡到透明”,等价于省略 from。
  • 关键帧内的 animation-timing-function 作用于该段。这让你可以在一段动画里用不同的缓动,比如“弹起用回弹、落下用加速”。

八个长属性,一次讲透

animation-name 关键帧的名字,必需。多个动画用逗号分隔。
animation-duration 一轮动画的时长。默认 0s,也就是说不写这个属性动画根本不会动。
animation-timing-function 每段之间的缓动,默认 ease。
animation-delay 开始前的等待时间,可以是负数(表示从中途开始)。
animation-iteration-count 播放次数,默认 1,infinite 表示无限循环。
animation-direction 方向:normal / reverse / alternate / alternate-reverse。
animation-fill-mode 动画之外的样式:none / forwards / backwards / both。
animation-play-state running / paused,可以在不重置进度的情况下暂停。

animation-fill-mode 是最常被忽略、也最容易造成困惑的一个。用一句话概括:

  • forwards:动画结束后,保持最后一帧的样式。做“淡入后一直可见”的入场动画必须加它,否则动画一结束元素就会闪回原始状态。
  • backwards:在 animation-delay 期间,提前应用第一帧的样式。做错峰入场时,如果不加它,元素会在延迟期间以原始样式显示,然后突然跳到起点。
  • both:前后都应用,最保险的选择。
  • none:默认值,动画前后都不影响元素的样式。
忘了 fill-mode
.item { animation: fadeIn 0.5s ease 0.3s; }
动画结束后元素闪回 opacity: 1 的默认状态;延迟期间还可能先以完全可见的状态“闪一下”。
加上 both
.item { animation: fadeIn 0.5s ease 0.3s both; }
延迟期间保持在起点,结束后停留在终点,视觉上完全连贯。

动手试一试

下面这个调参台把 animation 的几个关键长属性都做成了可调项。试着把 iteration-count 改成 1、把 fill-mode 改成 none,观察正方形最终停在哪里。

animation: labMove 1.6s ease-in-out 0s infinite alternate both

多个动画同时运行

用逗号可以给同一个元素挂多个动画,它们各自独立计时:

.loader {
  animation:
    spin 1.2s linear infinite,
    breathe 2.4s ease-in-out infinite alternate;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

@keyframes breathe {
  from { opacity: 0.5; }
  to   { opacity: 1; }
}

这里有个陷阱:两个动画都操作 transform 时,后声明的会完全覆盖先声明的。多个动画最好不要操作同一批属性,否则行为会变得难以预测。如果确实需要组合变换,应该在同一个关键帧里写完整的 transform 链。

用 animation-play-state 做“暂停/继续”

比起移除再添加动画类(会重置进度),直接切换 animation-play-state 更优雅:

.marquee { animation: scroll 20s linear infinite; }
.marquee.paused { animation-play-state: paused; }

/* 鼠标悬停时暂停跑马灯,是非常常见的交互 */
.marquee:hover { animation-play-state: paused; }

五、渲染流水线与动画性能

要写出流畅的动画,必须先知道浏览器在每一帧里做了什么。当一帧被触发时,渲染引擎会依次走过以下几个阶段:

1
Style
样式计算
把 CSS 规则匹配到 DOM 节点,计算出每个元素最终的“计算样式”。这一步几乎每帧都会发生,成本相对可控。
2
Layout
布局 / 重排
根据计算样式计算每个元素的几何位置与尺寸。任何影响几何的属性变化都会触发它,而且会波及整个文档——这是最昂贵的一步。
3
Paint
绘制 / 重绘
把元素的视觉表现(颜色、阴影、边框、文字)栅格化成像素。颜色、背景、阴影的变化会走到这一步。
4
Composite
合成
把已经绘制好的图层按正确的顺序拼合成最终画面,交给 GPU 输出。transform 和 opacity 的动画可以完全停留在这一步,代价极低。

点击每个阶段可以看到它的详细说明。核心结论是:动画的流畅程度,取决于它能跳过流水线中的多少步。

16.7 毫秒的帧预算

要让动画看起来是 60fps,每一帧的全部工作必须在约 16.7ms 内完成。这个预算需要被分摊到脚本执行、样式计算、布局、绘制、合成等所有环节上。留给你的动画的,往往只有几毫秒。

一帧 16.7ms 的预算分配(示意) 60fps
JS
Style
Layout
Paint
Composite
如果某个环节(尤其是 Layout)超出预算,浏览器就来不及在下一帧刷新前完成工作,画面就会掉帧。把动画限制在 Composite 阶段,意味着你几乎把整块预算都让了出来。

在 120Hz 的高刷屏上,这个预算进一步压缩到 8.3ms。这也是为什么“在我的电脑上看着挺流畅”不能作为验收标准——你需要在一台中端安卓机上验证。

属性代价速查表

动画属性 触发阶段 流畅度 建议
transform Composite 极佳 位移、缩放、旋转的首选
opacity Composite 极佳 淡入淡出的首选
filter / backdrop-filter Paint + Composite 较好 模糊、亮度等效果,慎用于大面积元素
color / background-color Paint 一般 小面积元素没问题,大面积会明显掉帧
box-shadow Paint 一般 大范围阴影动画开销较高
width / height Layout 差 改用 transform: scale()
top / left / margin Layout 差 改用 transform: translate()
border-radius(配合布局变化) Paint 一般 小范围使用无妨

下面的实验台把两种写法并排放在一起:上面一行用 left 驱动,下面一行用 transform: translateX 驱动。在性能较弱的设备上,你能明显看出差异。

animation: left → 触发布局 高开销
animation: transform → 仅合成 低开销
上面的数字是示意值,用于说明两者的量级差异。真实帧率请在 DevTools 的 Performance 面板中录制并观察 FPS 曲线。

will-change:提前告知,但别滥用

will-change 是给浏览器的一个提示:“这个元素的这个属性马上就要变了,请提前优化。”它最常见的用法是把元素提前提升为独立的合成层:

/* 提示浏览器:transform 即将变化 */
.card {
  will-change: transform;
}

/* 更稳妥的写法:只在真正要动的时候加,动完就撤 */
.card:hover {
  will-change: transform;
}

/* 绝对不要这样 —— 等于给页面上所有元素都开了图层 */
* { will-change: transform; }

will-change 的代价是什么?每个合成层都会占用显存,图层数量爆炸会显著增加内存占用,在低端设备上甚至会导致整个页面变慢。正确的心态是:把 will-change 当作一种性能补丁,而不是默认配置。只有当你在 DevTools 中确认某个动画确实掉帧、且瓶颈在合成阶段时,才考虑加上它。

用 DevTools 观察动画

Chrome DevTools 提供了几件诊断动效的利器:

  • Performance 面板:录制一段交互动画,查看 FPS 曲线、主线程耗时,以及是否出现紫色的 Layout 块。如果动画期间有长条的 Layout,说明你在动画布局属性。
  • Rendering 面板:勾选 Paint flashing,绿色高亮区域表示正在重绘的部分;勾选 Layer borders,可以看到合成层的边界,确认 will-change 是否真的生效。
  • Animations 面板:可以慢放动画、逐帧查看,还可以直接拖动时间轴修改缓动曲线并实时预览。调试入场动画时极其好用。
  • CSS Overview / Coverage:检查是否有从未被使用、却写了一大堆过渡的属性。

一个非常实用的技巧是:在 Performance 面板里把 CPU 降速 4~6 倍,然后再去操作页面。中低端手机的真实体验,大致就相当于这个降速后的桌面浏览器。

六、过渡还是动画:如何选择

两者并非替代关系,而是各管一段。判断依据可以浓缩成三个问题:

transition
状态驱动
animation
时间驱动
维度 transition animation
触发方式 必须有状态变化(hover / class / 属性) 自动开始,无需触发
关键帧数量 只能两端(A → B) 任意多段
循环 不支持 支持 infinite
往返 需要两个状态互相切换 alternate 一行搞定
暂停/继续 不能中途暂停 animation-play-state
JS 事件 transitionend animationstart / iteration / end
典型场景 悬停反馈、菜单展开、颜色切换 加载动画、入场序列、持续脉动

几条实践建议

  • 交互反馈一律用 transition。按钮、链接、输入框的状态变化天然是“状态驱动”的,用过渡最自然,也不会在页面加载时莫名其妙地动起来。
  • 持续播放的动效一律用 animation。加载图标、呼吸提示、跑马灯,这些没有“另一个状态”的概念。
  • 入场序列用 animation + 延迟。内容首次出现在屏幕上时没有“前一个状态”,过渡往往需要 JS 介入才能生效,而关键帧天生适合。
  • 能不用 JS 就不用。除非需要根据滚动位置、鼠标坐标实时计算,否则纯 CSS 方案永远更可靠、更容易维护。

两者可以共存

一个元素上同时写 transition 和 animation 是完全合法的,只要它们不操作同一批属性。例如一个提示气泡,用 animation 做首次入场的淡入,用 transition 做悬停时的颜色变化:

.tooltip {
  /* 入场:自动播放一次 */
  animation: tooltipIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;

  /* 悬停:状态驱动 */
  background-color: #1e293b;
  transition: background-color 0.2s ease;
}

@keyframes tooltipIn {
  from { opacity: 0; transform: translateY(6px) scale(0.96); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

七、实战模式:八个可以直接抄的动效

理论说完了,下面是日常开发中复用率最高的几种动效写法。每一个都标注了推荐时长——这是经过大量产品验证的“体感舒适区”。

1. 按钮的悬停与按压

微交互的黄金时长是 120ms ~ 200ms。再慢就会显得迟钝,再快用户又感觉不到。悬停和按压应该用不同的幅度:悬停轻微抬起,按压则要“按下去”。

.btn {
  background: #2563eb;
  color: #fff;
  border: none;
  padding: 10px 22px;
  border-radius: 8px;
  transform: translateY(0);
  transition: transform 0.16s ease-out, box-shadow 0.16s ease-out, background-color 0.16s ease-out;
}

.btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35);
}

.btn:active {
  transform: translateY(0) scale(0.98);
  box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3);
}

/* 键盘用户也要有同样的反馈 */
.btn:focus-visible {
  outline: 2px solid #93c5fd;
  outline-offset: 2px;
}

2. 卡片浮起

卡片是列表页的主角。悬停时抬升 4px ~ 8px、阴影扩散,是最通用的处理方式。注意阴影本身是一个较贵的属性,如果列表很长,可以把过渡限制在 transform 上,用伪元素承载阴影:

.card {
  position: relative;
  border-radius: 12px;
  background: #fff;
  transform: translateY(0);
  transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}

/* 阴影单独放在伪元素上,只过渡它的 opacity */
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14);
  opacity: 0;
  transition: opacity 0.28s ease;
  pointer-events: none;
}

.card:hover { transform: translateY(-6px); }
.card:hover::after { opacity: 1; }

3. 下拉菜单的展开

菜单需要一个“从触发器生长出来”的感觉。transform-origin 指向触发按钮,配合缩放和透明度,视觉上非常连贯:

.dropdown-menu {
  transform-origin: top left;
  transform: scale(0.96) translateY(-4px);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.16s ease,
    transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
    visibility 0s linear 0.2s;
}

.dropdown.open .dropdown-menu {
  transform: scale(1) translateY(0);
  opacity: 1;
  visibility: visible;
  transition-delay: 0s, 0s, 0s;
}

这里用到了一个小技巧:visibility 也是可以过渡的,只不过它的插值方式是“跳变”。把它的过渡延迟设为动画时长,就能实现“关闭时等淡出结束再隐藏”,从而避免菜单消失后仍然可以点击的问题。visibility: hidden 同时也把元素从无障碍树中移除了,比 opacity: 0 更彻底。

4. 模态框的入场

模态框通常由两层组成:半透明遮罩和内容面板。遮罩淡入,面板轻微上移放大。为了不显得拖沓,整体控制在 250ms 以内:

.overlay {
  background: rgba(15, 23, 42, 0.45);
  opacity: 0;
  transition: opacity 0.22s ease;
}

.modal-panel {
  transform: translateY(12px) scale(0.98);
  opacity: 0;
  transition:
    opacity 0.22s ease,
    transform 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}

.modal.show .overlay { opacity: 1; }
.modal.show .modal-panel {
  transform: translateY(0) scale(1);
  opacity: 1;
}

如果直接使用原生 <dialog>,可以配合 ::backdrop 伪元素和 @starting-style 实现同样的效果,同时还免费获得焦点管理。

5. 骨架屏的微光

骨架屏解决的是“加载期间页面一片空白”的焦虑感。它的关键是让高光从左到右循环扫过:

.skeleton-line {
  background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 37%, #e2e8f0 63%);
  background-size: 400% 100%;
  animation: shimmer 1.4s ease infinite;
}

@keyframes shimmer {
  from { background-position: 100% 50%; }
  to   { background-position: 0 50%; }
}

这里动画的是 background-position,属于重绘类属性,但骨架屏通常面积不大,可以接受。如果页面里同时有几十个骨架块在动,建议改用伪元素 + transform: translateX() 来实现同样的效果。

6. 列表的错峰入场

让一列元素依次出现,比让它们同时出现更有节奏感。实现方式是为每个元素设置递增的 animation-delay:

transition transform @keyframes cubic-bezier will-change
/* CSS:统一的入场关键帧 */
.item {
  opacity: 0;
  transform: translateY(14px);
  animation: staggerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

@keyframes staggerIn {
  to { opacity: 1; transform: translateY(0); }
}

/* 用 nth-child 生成延迟,避免写 JS */
.item:nth-child(1) { animation-delay: 0s; }
.item:nth-child(2) { animation-delay: 0.06s; }
.item:nth-child(3) { animation-delay: 0.12s; }
.item:nth-child(4) { animation-delay: 0.18s; }
.item:nth-child(5) { animation-delay: 0.24s; }

两点注意事项:单步延迟建议控制在 50ms ~ 80ms,太大就显得拖沓;列表很长时要给延迟设上限,否则第 50 个元素的入场要等到三秒后,用户早就滚过去了。用 :nth-child(n+6) 统一给后面的元素一个固定延迟即可。

7. 滚动到视口时显现

纯 CSS 无法感知元素是否进入视口,这里必须借助 IntersectionObserver。但动画本身仍然交给 CSS 负责,JS 只做一件事:加一个类。

/* CSS:初始状态 + 显现状态 */
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition:
    opacity 0.6s ease,
    transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

// JS:只负责切换类名
const io = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('is-visible');
      io.unobserve(entry.target);  // 只触发一次
    }
  });
}, { threshold: 0.15, rootMargin: '0px 0px -60px 0px' });

document.querySelectorAll('.reveal').forEach((el) => io.observe(el));

两个容易踩的坑:第一,如果 JavaScript 加载失败,.reveal 会永远停留在 opacity: 0,内容彻底不可见。稳妥的做法是用 <noscript> 覆盖样式,或者在 JS 里加一个超时兜底,把元素全部显示出来。第二,unobserve 很重要——不然元素每次进出视口都会重新播放动画,页面会变得非常聒噪。

8. 加载指示器

最经典的环形加载动画,只需要两个关键帧和一点小技巧:

.spinner {
  width: 28px;
  height: 28px;
  border: 3px solid #dbeafe;
  border-top-color: #2563eb;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

注意这里用的是 linear 而不是 ease。匀速旋转是加载动画的灵魂——用缓动会让它看起来一顿一顿的。另外,border-top-color 的变化会触发重绘,但一个 28px 的圆几乎不产生成本。

9. 列表项的删除动画

从列表中移除一项时,如果直接 display: none,其他的项会瞬间跳上来。正确做法是先播放一个退场动画,等动画结束再真正移除 DOM:

.todo-item.removing {
  animation: itemOut 0.28s ease forwards;
  pointer-events: none;
}

@keyframes itemOut {
  to {
    opacity: 0;
    transform: translateX(28px);
    max-height: 0;
    margin-bottom: 0;
    padding-top: 0;
    padding-bottom: 0;
  }
}

// JS:等动画结束再移除节点
el.classList.add('removing');
el.addEventListener('animationend', () => el.remove(), { once: true });

注意 animationend 事件可能被多个动画触发,稳妥的做法是在监听回调里检查 event.animationName,或者给动画单独命名并只监听那一个。

八、无障碍:动画也可能是伤害

动效对大多数人来说是锦上添花,但对一部分人来说可能是实实在在的伤害。前庭功能障碍(vestibular disorders)患者在观看大幅度的位移、缩放、旋转动画时,可能出现眩晕、恶心甚至偏头痛。偏头痛和注意力障碍人群对持续闪烁的元素也非常敏感。

prefers-reduced-motion

操作系统提供了“减少动态效果”的开关(macOS:辅助功能 → 显示 → 减少动态效果;Windows:设置 → 辅助功能 → 视觉效果 → 动画效果)。CSS 可以通过媒体查询读取这个偏好:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

这段“一刀切”的写法是最省事的做法,但它也会把一些完全无害的淡入淡出一起干掉。更细致的做法是分类处理:

/* 默认:完整的动效 */
.hero-card {
  animation: floatUp 3s ease-in-out infinite alternate;
}

/* 减少动效:保留状态变化,去掉位移与循环 */
@media (prefers-reduced-motion: reduce) {
  .hero-card {
    animation: none;
    /* 用透明度变化替代位移,仍然有反馈但不引起不适 */
    transition: opacity 0.3s ease;
  }
}

哪些动画最需要被削减

  • 大面积位移:整屏滑动、视差滚动、大幅度的横向移动。
  • 缩放与旋转:尤其是持续旋转的装饰元素和背景。
  • 无限循环:任何 infinite 的动画都可能成为视觉噪声源。
  • 高频闪烁:每秒闪烁超过 3 次的元素,对光敏性癫痫患者有诱发风险。
  • 自动播放的视频与轮播:应该默认暂停,或至少提供停止按钮。

动画与焦点

除了前庭问题,动效还会影响键盘用户:

  • 不要用动画延迟焦点反馈。用户按下 Tab 时,焦点环应该立即出现,而不是先淡入 300ms。
  • 不要在动画进行中移动焦点。如果元素正在飞入,此时聚焦它会让用户看不清焦点环在哪里。
  • 尊重 prefers-reduced-motion 时,别忘了 scroll-behavior: smooth。平滑滚动对很多人来说是眩晕的元凶,上面那段媒体查询里已经包含了它。
反例
用 outline: none 去掉焦点环,再用 300ms 的 box-shadow 动画代替它。键盘用户完全不知道自己当前在哪。
正解
保留即时的 :focus-visible 轮廓,额外的动效只作为装饰叠加,不承担传递状态的职责。

九、十个最常见的坑与调试技巧

坑 1 写 transition: all 0.3s,然后被某个布局属性的变化拖垮性能
坑 2 用 display: none 隐藏元素,然后疑惑为什么淡入动画不生效
坑 3 动画 height: auto 或 width: fit-content,发现完全没有过渡
坑 4 忘记写 animation-duration,或者忘了 fill-mode: forwards
坑 5 在 @keyframes 里同时改变 transform 和别的属性,导致组合顺序失控
坑 6 给页面所有元素加 will-change,显存被大量合成层吃光
坑 7 用 left / top 做位移动画,在低端机上疯狂掉帧
坑 8 忘了处理 transitionend 被多次触发,回调被执行了好几遍
坑 9 动画时长全站统一 0.3s,导致大元素显得很赶、小元素显得很拖
坑 10 完全忽略 prefers-reduced-motion,让部分用户直接无法使用页面

几个值得展开的细节

关于 transitionend:如果一个元素同时过渡了 transform 和 opacity,那么当 transform 先结束时就会触发一次 transitionend,opacity 结束时再触发一次。如果回调里有移除元素、切换路由这类重操作,就会被执行两次。解决办法是判断 event.propertyName:

el.addEventListener('transitionend', (e) => {
  if (e.propertyName !== 'transform') return;
  // 真正的收尾逻辑
});

另外要注意:如果过渡在完成前被打断(比如用户快速来回悬停),transitionend 根本不会触发,取而代之的是 transitioncancel。做复杂的状态管理时需要考虑这种情况。

关于 !important 对动画的影响:关键帧内部的声明优先级高于普通声明,但 !important 的普通声明又会反过来压过关键帧。这会导致动画“看起来没生效”。如果发现某个属性怎么都不动,先检查它是不是被 !important 覆盖了。

关于动画与 CSS 变量的配合:@keyframes 里使用 var() 是合法的,但有一个重要限制——变量不会在关键帧内部被逐帧重新计算。也就是说,如果你在动画运行期间通过 JS 改变了变量的值,动画不会立刻反映新值,只有下一次重新开始动画时才会生效。

/* 正确用法:变量在动画开始前就确定好 */
.slider {
  --distance: 240px;
  animation: slide var(--duration, 0.6s) ease both;
}

@keyframes slide {
  from { transform: translateX(0); }
  to   { transform: translateX(var(--distance)); }
}

/* 想让它重新读值:先移除动画,强制回流,再加回来 */
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';

这个“移除 → 回流 → 恢复”的三步模式,是重置 CSS 动画的标准套路,值得记在备忘录里。

三个调试小技巧

  1. 把时长放大 10 倍。调试复杂动画时,临时把 0.3s 改成 3s,能让你看清每一帧发生了什么。调完再改回去。
  2. 用 outline 标记正在动的元素。给动画元素临时加 outline: 1px solid red,比在 DevTools 里翻来翻去快得多。
  3. 在 DevTools 的 Animations 面板里暂停。按下暂停键后拖动时间轴,可以逐帧检查中间状态,尤其适合排查关键帧之间的插值是否符合预期。

十、动效设计检查清单与代码基线

把前面所有内容浓缩成一份可以在提交代码前扫一眼的清单。

  • 时长有依据:微交互 120–200ms,面板开合 250–350ms,页面级过渡 400–600ms,不要全站一个值。
  • 缓动有区分:入场用 ease-out,离场用 ease-in,循环用 linear,往返用 ease-in-out。
  • 不写 transition: all,显式列出需要过渡的属性。
  • 位移与缩放一律用 transform,不用 top / left / width / height。
  • 淡入淡出用 opacity,不用 visibility 或 display 做视觉过渡。
  • 关键帧动画记得写 animation-duration,并确认 fill-mode 符合预期。
  • 入场动画用 both,避免延迟期间的样式闪烁和结束后的回弹。
  • 错峰延迟有上限,单步 50–80ms,长列表封顶。
  • will-change 用在刀刃上,不要全局声明,用完能撤就撤。
  • 提供 prefers-reduced-motion 降级,至少关闭无限循环和大面积位移。
  • 焦点反馈不延迟,:focus-visible 的轮廓必须即时可见。
  • 滚动显现动画有 JS 失效兜底,确保内容在任何情况下都可见。
  • 监听 transitionend / animationend 时判断属性名或动画名,避免重复执行。
  • 在 DevTools 里降速 CPU 验证过,不只是在开发机上看着流畅。
  • 移动端实测过,尤其是中低端安卓设备。
/* ===== 一份可直接复用的动效变量表 ===== */

:root {
  /* 时长 */
  --dur-fast: 0.15s;  /* 悬停、按压 */
  --dur-base: 0.25s;  /* 面板、下拉 */
  --dur-slow: 0.45s;  /* 页面级过渡 */

  /* 缓动 */
  --ease-out-soft: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-soft: cubic-bezier(0.7, 0, 0.84, 0);
  --ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
}

/* 统一的无障碍降级 */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-fast: 0.01ms;
    --dur-base: 0.01ms;
    --dur-slow: 0.01ms;
  }
  *, *::before, *::after {
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }
}

/* 常用组合类 */
.lift {
  transform: translateY(0);
  transition: transform var(--dur-fast) var(--ease-out-soft);
}
.lift:hover { transform: translateY(-4px); }
.lift:active { transform: translateY(-1px) scale(0.99); }

.fade-in {
  animation: fadeIn var(--dur-base) var(--ease-out-soft) both;
}
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}

CSS 过渡与动画最有趣的地方在于,它是一个投入产出比极不均衡的领域:写对一个缓动曲线可能只需要改几个字符,但用户对产品的“高级感”评价会因此完全不同。同时,它又是一个容易过犹不及的领域——动画加得越多,页面越像演示文稿,反而降低了可用性。

所以真正的功夫不在于会写多少种效果,而在于判断:这一处,到底需不需要动?如果要动,它应该多快、用什么曲线、在哪一层完成、对谁不友好?把这四个问题回答清楚,你的动效就已经超过了绝大多数项目。