CSS 过渡与 Vue Transition 动画

1. 为什么要学动画

后端没见过动画(日志不会"淡入",SQL 不会"滑动展开"),但前端少了动画会显得"硬":

动画不是装饰,是状态变化的可视化反馈。学完这篇你能做出 80% 业务里需要的动画。

Java 对照:类似日志分级——INFO/WARN/ERROR 区别不是"多几个字",是让观察者快速理解发生了什么。UI 动画同样帮用户"看懂变化"。


2. CSS 过渡:最朴素也最常用

transition 让 CSS 属性的变化从"瞬间"变成"渐变"。

2.1 最简单的例子

.btn {
  background-color: #3b82f6;
  transition: background-color 200ms ease;
}
.btn:hover {
  background-color: #2563eb;
}

鼠标悬停时颜色在 200ms 内平滑过渡,不写 transition 就是硬跳。

2.2 语法

transition: <property> <duration> <timing-function> <delay>;

多个属性:

transition: background-color 200ms ease, transform 300ms ease;

2.3 transition: all 的陷阱

/* ❌ 不推荐 */
transition: all 300ms;

问题:一些属性你不想动画(displaywidth 自适应容器时),它也跟着抖。显式列出要过渡的属性,或者用 Tailwind 的 transition-colors / transition-transform 精确控制。

2.4 Tailwind 写法

<button class="bg-blue-500 hover:bg-blue-600 transition-colors duration-200">
  按钮
</button>
Tailwind 类 含义
transition 过渡一组常用属性(颜色、transform、opacity...)
transition-colors 只过渡颜色相关
transition-transform 只过渡 transform
duration-200 200ms
ease-in-out / ease-out 缓动曲线
delay-100 延迟 100ms

3. 缓动函数:别都用默认 ease

ease 是默认值但不是万能的。选对缓动函数能让动画"有性格":

函数 效果 适合
linear 匀速 进度条、加载动画
ease-in 慢 → 快 元素退场
ease-out 快 → 慢 元素入场(常用)
ease-in-out 慢 → 快 → 慢 双向循环(滑块、折叠)
cubic-bezier(0.34, 1.56, 0.64, 1) 超出再回弹 强调弹出(如点赞爱心)

经验法则


4. transform 与 opacity:性能友好的两个属性

CSS 属性分两大类:

动画首选 transform + opacity

/* ❌ 卡顿(改变布局) */
.card:hover {
  margin-top: -4px;
}

/* ✅ 丝滑(只改合成层) */
.card {
  transition: transform 200ms ease;
}
.card:hover {
  transform: translateY(-4px);
}

transform 能做位移(translate)、缩放(scale)、旋转(rotate)、倾斜(skew),几乎所有视觉变化都能用它替代。

Java 对照:类似"数据库操作走索引 vs 全表扫"——结果一样,性能差一个量级。


5. @keyframes:关键帧动画

transition 只能"从 A 到 B",要做"加载转圈""脉冲闪烁"这种循环动画,用 @keyframes

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

.loader {
  animation: spin 1s linear infinite;
}

多个关键帧:

@keyframes pulse {
  0%   { opacity: 1; }
  50%  { opacity: 0.5; }
  100% { opacity: 1; }
}

.dot {
  animation: pulse 2s ease-in-out infinite;
}

Tailwind 内置常用动画:

<div class="animate-spin">转圈</div>
<div class="animate-pulse">脉冲</div>
<div class="animate-bounce">弹跳</div>
<div class="animate-ping">波纹</div>

6. Vue 的 <Transition>:解决"元素进出"动画

CSS 过渡搞不定的一个场景:元素刚插入 DOM 或即将从 DOM 移除时的动画。因为 v-if 的元素不存在时没有样式可过渡。

Vue 的 <Transition> 组件帮你挂上临时 CSS 类来处理进出场:

<script setup lang="ts">
import { ref } from 'vue'
const show = ref(true)
</script>

<template>
  <button @click="show = !show">切换</button>

  <Transition name="fade">
    <p v-if="show">Hello, in4vue</p>
  </Transition>
</template>

<style scoped>
/* 进入的起点 */
.fade-enter-from {
  opacity: 0;
}
/* 进入过程中 */
.fade-enter-active {
  transition: opacity 300ms ease-out;
}
/* 离开过程中 */
.fade-leave-active {
  transition: opacity 200ms ease-in;
}
/* 离开的终点 */
.fade-leave-to {
  opacity: 0;
}
</style>

6 个类的生命周期(记住前缀规律:<name>-<阶段>-<时机>):

时机
fade-enter-from 进入动画的起点(元素刚插入 DOM 的瞬间)
fade-enter-active 整个进入过程(放 transition 定义)
fade-enter-to 进入动画的终点(一帧后自动切到这里)
fade-leave-from 离开动画的起点
fade-leave-active 整个离开过程
fade-leave-to 离开动画的终点

工作流程:

  1. v-if="true" → 元素插入 → 立即加 fade-enter-from + fade-enter-active
  2. 下一帧 → 移除 -from,加 -to → CSS 过渡触发
  3. 过渡结束 → 移除所有类
  4. v-if="false" → 加 -leave-from + -leave-active,一帧后切 -leave-to → 过渡结束才真正从 DOM 移除

7. 模态框淡入 + 缩放:常用场景

<Transition name="modal">
  <div v-if="visible" class="fixed inset-0 flex items-center justify-center bg-black/50">
    <div class="bg-white rounded-lg p-6 shadow-xl">模态框内容</div>
  </div>
</Transition>

<style scoped>
.modal-enter-active,
.modal-leave-active {
  transition: opacity 200ms ease, transform 200ms ease;
}
.modal-enter-from,
.modal-leave-to {
  opacity: 0;
  transform: scale(0.95);
}
</style>

细节


8. 内置过渡:Tailwind + Vue 配合

不想写 CSS?用 Tailwind 的 enter-* / leave-* 类:

<Transition
  enter-active-class="transition duration-200 ease-out"
  enter-from-class="opacity-0 scale-95"
  enter-to-class="opacity-100 scale-100"
  leave-active-class="transition duration-150 ease-in"
  leave-from-class="opacity-100 scale-100"
  leave-to-class="opacity-0 scale-95"
>
  <div v-if="show">...</div>
</Transition>

完全不用写 <style>,可读性也不差。团队如果统一用 Tailwind,这种写法更一致。


9. <TransitionGroup>:列表动画

<Transition> 只能包一个元素,列表增删要用 <TransitionGroup>

<script setup lang="ts">
import { ref } from 'vue'

const todos = ref(['学 ES6', '学 Vue', '学 Tailwind'])
const add = () => todos.value.push('新任务 ' + todos.value.length)
const remove = (i: number) => todos.value.splice(i, 1)
</script>

<template>
  <button @click="add">添加</button>

  <TransitionGroup name="list" tag="ul">
    <li
      v-for="(todo, i) in todos"
      :key="todo"
      class="flex justify-between p-2 bg-white border-b"
    >
      {{ todo }}
      <button @click="remove(i)">删除</button>
    </li>
  </TransitionGroup>
</template>

<style scoped>
.list-enter-active,
.list-leave-active {
  transition: all 300ms ease;
}
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(20px);
}
/* 关键:让剩下的元素也平滑上移 */
.list-leave-active {
  position: absolute;
}
.list-move {
  transition: transform 300ms ease;
}
</style>

三点关键

  1. v-forkey 必须唯一稳定,不然 Vue 认不出"是哪一项在进/出"
  2. .list-move 类处理未被删除但位置变化的元素(比如删掉第 2 项,第 3 项要平滑上移)
  3. .list-leave-active { position: absolute } 把离场元素脱离文档流,避免它占位导致后面元素"晚移动"

常见 Bug:列表 keyindex —— 动画会乱跳,因为删除后索引变了,Vue 认为是"新元素"。永远用数据本身的 ID 做 key。


10. 路由切换动画

想要页面切换时淡入淡出?包一下 <router-view>

<!-- App.vue 或 layouts/AdminLayout.vue -->
<router-view v-slot="{ Component }">
  <Transition name="page" mode="out-in">
    <component :is="Component" />
  </Transition>
</router-view>

<style>
.page-enter-active,
.page-leave-active {
  transition: opacity 200ms ease;
}
.page-enter-from,
.page-leave-to {
  opacity: 0;
}
</style>

mode="out-in" 的含义:旧页面先淡出,再淡入新页面(默认是同时进行,会短暂重叠)。

切换路由时看到页面"刷新一下"变柔和了,就这么简单。


11. 性能:别让动画卡住主线程

11.1 只用 transform / opacity

第 4 节已经讲过。尽量别动画 widthheighttopleftmargin

11.2 触发 GPU 加速

有些浏览器需要你"提示"它开 GPU 合成层:

.animated {
  will-change: transform;
  /* 或用一个 transform 让它默认开合成层 */
  transform: translateZ(0);
}

但别乱加 will-change:每个合成层都吃显存,加在全站元素上反而会爆内存。只给确实频繁动画的元素加。

11.3 大量动画时用 requestAnimationFrame

如果你在用 JS 改 style(比如跟鼠标位置):

// ❌ 可能掉帧
element.addEventListener('mousemove', (e) => {
  box.style.transform = `translate(${e.x}px, ${e.y}px)`
})

// ✅ 跟浏览器刷新率同步
let rafId = 0
element.addEventListener('mousemove', (e) => {
  cancelAnimationFrame(rafId)
  rafId = requestAnimationFrame(() => {
    box.style.transform = `translate(${e.x}px, ${e.y}px)`
  })
})

VueUse 的 useRafFn 也是这个思路的封装。


12. 可访问性:尊重用户的"减少动画"偏好

有些用户开启了系统的"减少动画"选项(晕动症、注意力障碍)。检测它并关掉动画:

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

Tailwind 也提供变体:

<div class="motion-reduce:transition-none hover:scale-105 transition">...</div>

这不是"可选项",是基本的可访问性要求。发布到生产之前把上面那段 CSS 加上,不费劲还积德。


13. 小决策表:遇到场景怎么选

场景 方案
hover 换色 CSS transition-colors
按钮按下缩一下 CSS transform: scale(0.98) + transition-transform
加载转圈 CSS @keyframes spin / Tailwind animate-spin
模态框出现 Vue <Transition> + opacity + scale
列表增删 Vue <TransitionGroup> + 稳定 key
路由切换 <router-view v-slot> + <Transition mode="out-in">
元素展开/折叠高度 稍微棘手,见下一节

14. 难点:展开/折叠的高度动画

height: auto 不能直接过渡(浏览器不知道 auto 是多少像素)。四种思路:

方案 1:限定最大高度

.collapsible {
  max-height: 0;
  overflow: hidden;
  transition: max-height 300ms ease;
}
.collapsible.open {
  max-height: 500px; /* 估一个足够大的值 */
}

简单,但估值要准:太小会截内容,太大会动画结束前有"空等"。

方案 2:JS 在进场钩子里量高度

Vue 提供 JS 钩子,进场时测量 scrollHeight

<Transition
  @before-enter="el => (el.style.height = '0')"
  @enter="el => (el.style.height = el.scrollHeight + 'px')"
  @after-enter="el => (el.style.height = '')"
  @before-leave="el => (el.style.height = el.scrollHeight + 'px')"
  @leave="el => (el.style.height = '0')"
>
  <div v-if="open" class="overflow-hidden transition-[height] duration-300">
    <!-- 任意高度内容 -->
  </div>
</Transition>

精准,但代码稍多,适合做成可复用的 <CollapseTransition> 组件。

方案 3:直接用 Element Plus 的 <el-collapse-transition>

in4vue 已经装了 Element Plus,可以直接用:

<el-collapse-transition>
  <div v-show="open">任意内容</div>
</el-collapse-transition>

生产项目推荐这个,已经帮你处理了 scrollHeight 测量和边界情况。

方案 4:CSS Grid 技巧(现代浏览器)

.wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms ease;
}
.wrap.open {
  grid-template-rows: 1fr;
}
.wrap > div {
  overflow: hidden;
}

2023+ 的现代写法,干净利落,但 Safari 16 以下不支持。要兼容老环境就退回方案 3。


小练习

  1. 给 in4vue 的笔记卡片加 hover 动画:向上轻微浮起 + 阴影变深
  2. 路由切换加 fade 动画(第 10 节)
  3. 做一个任务列表 demo,用 <TransitionGroup> 让增删带动画
  4. 写一个 @keyframes pulse 脉冲效果,用在"新消息"徽标上
  5. prefers-reduced-motion 媒体查询,在系统"减少动画"开关下禁用所有过渡

延伸阅读