CSS 过渡与 Vue Transition 动画
1. 为什么要学动画
后端没见过动画(日志不会"淡入",SQL 不会"滑动展开"),但前端少了动画会显得"硬":
- 菜单一下子出现 vs 展开滑入 —— 后者用户感觉"这页面在思考"
- 删除列表项瞬间消失 vs 淡出收起 —— 后者用户明确"哦,被删了"
- 模态框弹出没有过渡 vs 缩放淡入 —— 后者有层次感,不抢视线
动画不是装饰,是状态变化的可视化反馈。学完这篇你能做出 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>;
property—— 哪个 CSS 属性要过渡(all、background-color、transform...)duration—— 多长时间(200ms、0.3s)timing-function—— 缓动函数(ease、linear、cubic-bezier(...))delay—— 延迟多久再开始
多个属性:
transition: background-color 200ms ease, transform 300ms ease;
2.3 transition: all 的陷阱
/* ❌ 不推荐 */
transition: all 300ms;
问题:一些属性你不想动画(display、width 自适应容器时),它也跟着抖。显式列出要过渡的属性,或者用 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) |
超出再回弹 | 强调弹出(如点赞爱心) |
经验法则:
- 进来用
ease-out(快到的感觉) - 出去用
ease-in(甩出去的感觉) - 不确定时
ease-out通常不会错
4. transform 与 opacity:性能友好的两个属性
CSS 属性分两大类:
- 会触发重排(layout)和重绘(paint)的:
width、height、top、left、margin... 每帧浏览器都要重新计算布局,掉帧风险高 - 只走合成层(GPU)的:
transform、opacity,浏览器直接交给 GPU,60fps 很稳
动画首选 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 |
离开动画的终点 |
工作流程:
v-if="true"→ 元素插入 → 立即加fade-enter-from+fade-enter-active- 下一帧 → 移除
-from,加-to→ CSS 过渡触发 - 过渡结束 → 移除所有类
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>
细节:
scale(0.95)只缩 5%,再多显得夸张- 进入和离开用同一组
transition(大多数场景这样够用)
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>
三点关键:
v-for的key必须唯一稳定,不然 Vue 认不出"是哪一项在进/出".list-move类处理未被删除但位置变化的元素(比如删掉第 2 项,第 3 项要平滑上移).list-leave-active { position: absolute }把离场元素脱离文档流,避免它占位导致后面元素"晚移动"
常见 Bug:列表 key 用 index —— 动画会乱跳,因为删除后索引变了,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 节已经讲过。尽量别动画 width、height、top、left、margin。
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。
小练习
- 给 in4vue 的笔记卡片加
hover动画:向上轻微浮起 + 阴影变深 - 路由切换加
fade动画(第 10 节) - 做一个任务列表 demo,用
<TransitionGroup>让增删带动画 - 写一个
@keyframes pulse脉冲效果,用在"新消息"徽标上 - 加
prefers-reduced-motion媒体查询,在系统"减少动画"开关下禁用所有过渡