CSS 动画

用过渡和关键帧表达界面状态变化,并正确处理时间线、填充模式、渲染成本与减少动态效果偏好。

难度 进阶 时长 标准深度约 12分钟
版本 CSS Animations Level 1
what

CSS 过渡在属性值变化时插入中间值;CSS 关键帧动画则定义一条可重复、可分段控制的时间线。

trap

animation-fill-mode: forwards 不会把终点写回普通样式,transition: all 也会让后来新增的属性意外参与过渡。

fix

明确列出参与动画的属性,把最终状态写进普通规则,并为 prefers-reduced-motion 提供不会依赖结束事件的路径。

是什么,为什么存在

CSS 动画(CSS animation)让浏览器随时间改变元素的呈现值。它解决的不是「让页面动起来」这一抽象目标,而是把界面从一个可解释的状态带到另一个状态:按钮确认保存、通知进入视野、进度状态持续提示,或列表按顺序出现。

CSS 提供两种主要机制。 CSS 过渡(CSS transition) 观察属性的前值与后值,在值发生变化时生成插值过程。关键帧动画通过 @keyframes 描述一个周期内的多个位置,再由 animation-* 属性启动并控制这条时间线。

状态已经由伪类、媒体查询、属性或类名表达,而且只需从当前值到目标值时,过渡通常更合适。效果需要自动开始、重复、交替,或者经过三个以上明确阶段时,关键帧动画更直接。二者都只改变呈现,不应承担业务状态、焦点管理或可访问名称。

动画必须传达有用的反馈,同时允许用户减少非必要运动。颜色或透明度变化也能表达状态,但不能假设每个人都能安全、舒适地接受缩放、平移或持续旋转。

工作原理

浏览器先通过层叠与继承得到属性的计算值,再在时间线上为可动画属性求中间值。动画效果会参与计算值的生成,但它不会改写样式表中的声明,也不会替你修改 DOM 状态。动画结束后的视觉结果和应用真实状态因此必须分开考虑。

下面的状态图区分了普通样式、活动效果与填充效果。所有路径最终都要能回到由普通样式和 DOM 状态描述的界面。

过渡响应值变化

过渡比较一次样式更新前后的值。属性被列入 transition-property、持续时间大于 0s、前后值不同且可插值时,浏览器才会为它生成过渡。仅仅给元素声明 transition 不会产生运动;还需要伪类、类名、属性或脚本造成目标值变化。

简写中第一个时间值是持续时间,第二个时间值是延迟。逗号分隔的每一组描述一个属性,因此 opacity 120ms linear, transform 240ms ease 40ms 会生成两条时间参数不同的过渡。明确列出属性能把组件的视觉契约保留在规则中。

中途反转时,新的过渡从当前呈现值接续,而不是先跳回旧端点。快速移入再移出悬停目标时,这条规则通常能得到连贯反馈;测试不能只覆盖完整播放一次的路径。

关键帧定义一个周期

关键帧(keyframe) fromto 或百分比标出一个动画周期中的属性值。浏览器在相邻关键帧之间插值;缺少 0%100% 时,会用元素对应属性的计算值补出端点。

animation 简写把名称、持续时间、 缓动函数(timing function) 、延迟、迭代次数、方向、填充模式和播放状态组合起来。持续时间的初始值是 0s,所以只写 animation-name 看不到持续变化。多条动画用逗号分隔;若它们同时写同一个属性,列表中靠后的动画效果取得优先权。

缓动函数把线性时间进度映射为视觉进度。linear 保持匀速,cubic-bezier() 提供连续曲线,steps() 则产生离散台阶。动画上的缓动会分别作用于相邻关键帧区间,而不是只把整条多段时间线当成一段。

填充模式只延长效果

默认的 animation-fill-mode: none 只让动画在活动阶段影响呈现。backwards 可在延迟期间应用首帧,forwards 可在结束后继续提供末帧效果,both 同时包含两者。这些值没有把末帧写进类名、行内样式或组件状态。

如果动画代表持久状态变化,普通规则应表达最终状态,动画只负责到达它的过程。这样即使动画被禁用、取消或从未生成,界面仍处于正确状态。

事件只观察效果

过渡可发出 transitionruntransitionstarttransitionendtransitioncancel,关键帧动画有对应的开始、迭代、结束与取消事件。一个元素上的多个属性或动画可以分别发出事件,处理器必须检查 propertyNameanimationName

结束事件适合清理临时类名或记录视觉完成,不适合成为提交业务状态的唯一入口。效果没有生成、被取消或元素停止渲染时,代码仍需有确定的完成路径。

示例

由状态变化触发过渡

按钮的 aria-pressed 同时表达真实状态并作为 CSS 选择器。过渡只是状态切换时的反馈,脚本不需要控制每一帧。

state-transition.html
<button id="save" class="save-button" aria-pressed="false">
  Save
</button>

<style>
  .save-button {
    transform: translateY(0);
    background: #334155;
    color: white;
    border: 0;
    border-radius: 0.375rem;
    padding: 0.5rem 0.875rem;
    font: inherit;
    cursor: pointer;
    box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
    transition: transform 120ms linear, background-color 120ms linear;
  }

  .save-button[aria-pressed="true"] {
    transform: translateY(-4px);
    background: #047857;
  }
</style>

<script>
  const button = document.querySelector('#save');
  console.log(`before: saved=${button.ariaPressed}`);

  button.addEventListener('transitionend', (event) => {
    if (event.propertyName === 'transform') {
      console.log(`after: saved=${button.ariaPressed}; event=transform`);
    }
  });

  button.addEventListener('click', () => {
    button.ariaPressed = String(button.ariaPressed !== 'true');
  });

  requestAnimationFrame(() => requestAnimationFrame(() => button.click()));
</script>
before: saved=false
after: saved=true; event=transform

浏览器实际为 transformbackground-color 分别生成过渡,所以会产生两个 transitionend 事件。处理器按 propertyName 过滤,只记录一次完成状态。即使 CSS 没有加载,按钮的 aria-pressed 仍然会被正确更新。

用关键帧表达多段进入过程

通知先淡入并上移过头,再回到静止位置。这是三个阶段,所以关键帧比在脚本里连续切换多个类名更清楚。

keyframe-entry.html
<p id="notice" class="notice" role="status">Profile saved</p>

<style>
  .notice {
    opacity: 0;
    transform: translateY(12px);
    width: fit-content;
    margin: 0;
    padding: 0.625rem 0.875rem;
    border: 1px solid #86efac;
    border-radius: 0.375rem;
    background: #f0fdf4;
    color: #14532d;
    font: 1rem/1.4 system-ui;
  }

  .notice.is-visible {
    animation: reveal 120ms ease-out both;
  }

  @keyframes reveal {
    0% { opacity: 0; transform: translateY(12px); }
    70% { opacity: 1; transform: translateY(-2px); }
    100% { opacity: 1; transform: translateY(0); }
  }
</style>

<script>
  const notice = document.querySelector('#notice');

  notice.addEventListener('animationstart', (event) => {
    console.log(`animationstart: ${event.animationName}`);
  });

  notice.addEventListener('animationend', () => {
    console.log(`animationend: opacity=${getComputedStyle(notice).opacity}`);
  });

  requestAnimationFrame(() => notice.classList.add('is-visible'));
</script>
animationstart: reveal
animationend: opacity=1

both 让首帧能覆盖开始前的短暂间隙,并在结束后保持末帧效果。这里普通 .notice 规则仍是隐藏状态,因此移除 is-visible 会回到隐藏;若通知之后必须永久可见,就应把可见性写成独立状态,而不是长期依赖填充。

用自定义属性生成错峰延迟

每一项复用同一条动画,只通过自定义属性提供序号。持续时间与错峰间隔仍由 CSS 统一管理,模板不必生成完整的 animation 字符串。

staggered-list.html
<ol id="steps">
  <li class="step" style="--index: 0">Validate</li>
  <li class="step" style="--index: 1">Save</li>
  <li class="step" style="--index: 2">Notify</li>
</ol>

<style>
  #steps {
    display: grid;
    gap: 0.25rem;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .step {
    opacity: 0;
    animation: arrive 100ms ease-out both;
    animation-delay: calc(var(--index) * 40ms);
  }

  @keyframes arrive {
    from { opacity: 0; transform: translateY(6px); }
    to { opacity: 1; transform: translateY(0); }
  }
</style>

<script>
  const steps = [...document.querySelectorAll('.step')];
  const delays = steps.map((step) => getComputedStyle(step).animationDelay);
  console.log(`delays: ${delays.join(', ')}`);

  const finished = steps.map((step) => new Promise((resolve) => {
    step.addEventListener('animationend', resolve, { once: true });
  }));

  Promise.all(finished).then(() => {
    const opacities = steps.map((step) => getComputedStyle(step).opacity);
    console.log(`completed: ${opacities.join(', ')}`);
  });
</script>
delays: 0s, 0.04s, 0.08s
completed: 1, 1, 1

这里的延迟按 0ms40ms80ms 递增,但 DOM 顺序和内容顺序没有改变。错峰只应作为视觉增强;读取顺序、键盘顺序和业务处理顺序不应等待动画。

为减少动态效果提供稳定路径

旋转只表示后台同步仍在进行,不承载唯一信息。减少动态效果时可以移除旋转,同时保留状态文本和布局。

reduced-motion.html
<p class="busy" role="status">
  <span id="orbit" class="orbit" aria-hidden="true"></span>
  Syncing
</p>

<style>
  .busy {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0;
    color: #0f172a;
    font: 1rem/1.4 system-ui;
  }

  .orbit {
    display: inline-block;
    flex: none;
    width: 0.75rem;
    height: 0.75rem;
    border: 2px solid #94a3b8;
    border-top-color: #0f172a;
    border-radius: 50%;
    animation: orbit 600ms linear infinite;
  }

  @keyframes orbit {
    to { transform: rotate(1turn); }
  }

  @media (prefers-reduced-motion: reduce) {
    .orbit { animation: none; }
  }
</style>

<script>
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const name = getComputedStyle(document.querySelector('#orbit')).animationName;
  console.log(`${reduced ? 'reduce' : 'no-preference'}: ${name}`);
</script>
no-preference: orbit
reduce: none

两行输出来自同一页面在两个浏览器媒体偏好配置下的实际结果。减少动态效果分支没有隐藏「Syncing」,也没有把所有过渡强行缩短到接近零;它只移除了非必要旋转。

陷阱

深入 动画效果如何参与层叠

动画效果如何参与层叠

CSS 动画产生的值会加入层叠。普通的非 !important 声明通常会被正在运行的 CSS 动画覆盖,而 !important 声明可以覆盖动画值。过渡在层叠中的优先级又高于动画,因此同一属性同时发生过渡和关键帧动画时,眼前的结果未必来自代码中看起来更具体的选择器。

同一元素上的多条关键帧动画是列表值。若多条动画在同一时刻为同一属性提供值,列表中靠后的动画取得优先权;这不是通用的自动相加机制。复杂组合应明确分配属性或包装层,不要依赖声明顺序碰巧得到的画面。

填充模式把动画效果延伸到活动区间之外,所以填充中的值仍可能压过后来修改的普通声明。长期保留 forwards 会让已经结束的动画继续影响调试结果。持久终点最好由普通状态规则拥有,动画完成后可以安全移除动画类。

过渡则由前后两次样式变化之间的差异生成。它从当前呈现值接续中途反转,并可能缩短反向过程。因而事件次数和总时长不能仅从静态 CSS 字符串推断;需要在实际交互序列中观察。

用测量判断渲染成本

属性变化可能要求重新计算样式、布局、绘制或合成,具体路径取决于属性、元素、浏览器和当前图层状态。transformopacity 常能避免布局工作,但这不等于每个元素都已在独立 GPU 图层,也不保证大量透明层或大面积缩放便宜。

性能审查应先记录真实交互。用浏览器的 Performance 工具查看主线程中的 Style、Layout 与 Paint 事件,再检查 Layers 或渲染诊断信息。记录低端目标设备上的长帧与受影响区域;没有录制证据时,不给出「最快」或固定帧率承诺。

布局属性并非绝对禁止。折叠区域的高度变化可能必须影响文档流,使用缩放假装折叠反而会保留布局空间。先保证语义和最终布局正确,再比较实现的实际帧成本。

will-change 是让浏览器提前准备某种变化的提示,不是创建合成层的命令。过早或广泛使用会延长资源占用。把它限制在即将发生且测量确认受益的效果上,并在效果结束后恢复为 auto

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 前端开发基础
下一篇 Accessibility 即将上线 Performance 即将上线 响应式网页设计
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?