CSS View Transitions API 实战:页面过渡动画新范式
页面切换时的过渡动画,一直是前端的痛点。要么用各种 JS 动画库手动管理生命周期,要么干脆放弃——白屏闪烁就白屏闪烁吧。View Transitions API 改变了这一切:浏览器原生支持,一行代码触发,自动处理新旧状态的快照和动画。
本文从核心概念到跨页面导航、SPA 集成、列表动画,带你完整掌握 View Transitions API。
一、传统 CSS 动画痛点 vs View Transitions API
在 View Transitions API 出现之前,页面过渡动画的实现方式大致有三种:
| 方案 | 优点 | 痛点 |
|---|---|---|
| CSS transition/animation | 原生、高性能 | 只适合单个元素;跨页面无法使用;需手动管理状态 |
| JS 动画库(GSAP、Framer Motion) | 功能强大、生态丰富 | 包体积大;手动编排复杂;跨页面动画仍需大量胶水代码 |
| SPA 路由动画 | 路由级别可控 | 框架绑定;MPA 不适用;旧 DOM 已销毁无法做过渡 |
核心痛点一句话概括:DOM 更新和动画是割裂的。你先更新 DOM(旧节点销毁、新节点创建),再想办法做动画。但旧节点已经没了,怎么从旧状态过渡到新状态?
View Transitions API 的解法:浏览器帮你拍照。更新 DOM 之前,浏览器给旧状态拍一张快照;更新之后,再给新状态拍一张。然后用 CSS 动画把两张快照连起来——你只需要告诉浏览器"更新 DOM",过渡动画自动生成。
二、核心概念
1. document.startViewTransition()
整个 API 的入口就一个函数:
document.startViewTransition(updateCallback);
updateCallback 是一个函数,在里面做 DOM 更新。浏览器会按以下流程执行:
1. 调用 startViewTransition()
2. 浏览器截取当前页面的视觉快照(old state)
3. 执行 updateCallback,更新 DOM
4. 浏览器截取更新后的视觉快照(new state)
5. 构造伪元素树,用动画从 old → new 过渡
6. 动画结束,清理伪元素
updateCallback 返回 Promise 时,浏览器会等 Promise resolve 后再截取新快照。这意味着你可以在回调中做异步数据加载。
2. 伪元素树
过渡期间,浏览器会插入以下伪元素树:
::view-transition
└── ::view-transition-group(root)
└── ::view-transition-image-pair(root)
├── ::view-transition-old(root) ← 旧快照
└── ::view-transition-new(root) ← 新快照
每个伪元素的职责:
| 伪元素 | 职责 | 默认动画 |
|---|---|---|
::view-transition |
顶层容器,覆盖整个视口 | 无 |
::view-transition-group |
管理一组过渡的定位和尺寸 | 从旧位置/尺寸动画到新位置/尺寸 |
::view-transition-image-pair |
容纳 old 和 new 快照,负责隔离 | isolation: isolate |
::view-transition-old |
旧状态的截图(<img> 或截图) |
opacity 1→0(淡出) |
::view-transition-new |
新状态的截图 | opacity 0→1(淡入) |
pointer-events 来调整。
三、基础用法
1. 最简单的页面过渡
// 点击按钮切换主题
document.querySelector('.theme-btn').addEventListener('click', () => {
// ✅ 只需包裹一层 startViewTransition
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark');
});
});
就这么简单。浏览器自动做淡入淡出,不需要写任何 CSS 动画。
2. 自定义淡入淡出
默认的淡入淡出是 250ms,你可以通过 CSS 修改:
/* 修改默认过渡时长 */
::view-transition-old(root) {
animation: 0.4s ease-in fade-out;
}
::view-transition-new(root) {
animation: 0.4s ease-out fade-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
3. 禁用默认过渡
/* 完全禁用默认的 root 过渡 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
这在你只想对特定元素做过渡(而非整个页面)时非常有用——先禁用 root,再对特定 group 启用。
四、自定义动画
1. view-transition-name:给元素命名
view-transition-name 是整个自定义动画的关键。给元素设置这个名字后,它会在伪元素树中获得独立的 group,不再跟随 root 一起过渡。
/* 头像元素独立过渡 */
.hero-avatar {
view-transition-name: hero-avatar;
}
/* 此时伪元素树变成:
::view-transition
├── ::view-transition-group(root) ← 其他所有内容
└── ::view-transition-group(hero-avatar) ← 头像独立过渡
└── ::view-transition-image-pair(hero-avatar)
├── ::view-transition-old(hero-avatar)
└── ::view-transition-new(hero-avatar)
*/
view-transition-name 必须在同一时刻唯一。如果两个可见元素设置了相同的 name,过渡会跳过。动态设置时务必确保唯一性。
2. 修改动画时长和关键帧
/* 头像过渡:缩放 + 位移 */
::view-transition-group(hero-avatar) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 旧头像:缩小淡出 */
::view-transition-old(hero-avatar) {
animation: 0.5s ease both shrink-out;
}
/* 新头像:放大淡入 */
::view-transition-new(hero-avatar) {
animation: 0.5s ease both grow-in;
}
@keyframes shrink-out {
to {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes grow-in {
from {
opacity: 0;
transform: scale(1.2);
}
}
3. 多个元素独立过渡
/* 列表中的每张卡片独立过渡 */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }
.card:nth-child(3) { view-transition-name: card-3; }
/* 所有卡片共享同一动画,但各自独立执行 */
::view-transition-group(card-1),
::view-transition-group(card-2),
::view-transition-group(card-3) {
animation-duration: 0.3s;
}
view-transition-name,比在 CSS 中写 nth-child 更灵活:
document.querySelectorAll('.card').forEach((card, i) => {
card.style.viewTransitionName = `card-${card.dataset.id}`;
});
五、跨页面导航过渡(MPA)
SPA 里做过渡还算简单,毕竟 DOM 在同一个文档中。但 MPA(多页应用)怎么办?页面 A 导航到页面 B,浏览器会销毁整个页面 A——传统方案完全无法做过渡。
Chrome 126+ 支持了跨文档 View Transitions(Cross-Document View Transitions),让 MPA 也能拥有丝滑的页面过渡。
1. 基础配置
/* 在两个页面的 CSS 中都添加 */
@view-transition {
navigation: auto;
}
就这一行。当用户在两个页面之间导航(点击链接、前进/后退)时,浏览器自动触发 View Transition。
2. 自定义跨页面动画
/* 页面 A(列表页) */
.item-image {
view-transition-name: item-image;
}
/* 页面 B(详情页) */
.detail-hero-image {
view-transition-name: item-image; /* 同名!这是关键 */
}
/* 自定义过渡效果 */
::view-transition-group(item-image) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
/* 禁用 root 级别的淡入淡出,只保留图片过渡 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
两个页面中设置相同的 view-transition-name,浏览器就能把页面 A 的元素和页面 B 的元素"匹配"起来,做跨页面的位移动画——这就是经典的"Hero Animation"。
3. 控制哪些导航触发过渡
// 使用 pageswap 和 pagereveal 事件精细控制
// 页面离开时
document.addEventListener('pageswap', (event) => {
// 只对特定链接做过渡
const targetUrl = new URL(event.activationEntry.url);
if (targetUrl.pathname.startsWith('/blog/')) {
// 设置匹配的 view-transition-name
setTransitionNames(event);
}
});
// 页面进入时
document.addEventListener('pagereveal', (event) => {
if (event.viewTransition) {
// 新页面准备好后,设置匹配名称
setupIncomingNames(event);
}
});
view-transition-name 必须在页面加载时就设置好(或通过事件动态设置);不支持跨域导航。
六、SPA 集成:React Router / Vue Router 实战
1. React Router
import { useNavigate } from 'react-router-dom';
function ProductCard({ product }) {
const navigate = useNavigate();
const handleClick = () => {
// ✅ 用 startViewTransition 包裹导航
const transition = document.startViewTransition(() => {
navigate(`/product/${product.id}`);
});
};
return (
<div
className="product-card"
style={{ viewTransitionName: `product-${product.id}` }}
onClick={handleClick}
>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
</div>
);
}
/* 详情页的 Hero 图片 */
.product-detail-hero {
view-transition-name: product-hero; /* 需要与列表页匹配 */
}
startViewTransition 的回调中更新状态时,React 18 的自动批处理可能导致 DOM 不立即更新。解决方案:使用 flushSync 强制同步更新。
import { flushSync } from 'react-dom';
document.startViewTransition(() => {
flushSync(() => {
navigate(`/product/${product.id}`);
});
});
2. Vue Router
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [...],
});
// ✅ 全局路由守卫中包裹 View Transition
router.beforeEach((to, from) => {
// 检查浏览器支持
if (!document.startViewTransition) return true;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve(true);
await new Promise((r) => requestAnimationFrame(r));
});
});
});
更精细的控制——根据路由设置不同的过渡名称:
// 在组件中动态设置 view-transition-name
// ListPage.vue
<template>
<div
v-for="item in items"
:key="item.id"
:style="{ viewTransitionName: `item-${item.id}` }"
@click="goToDetail(item.id)"
>
{{ item.name }}
</div>
</template>
// DetailPage.vue
<template>
<div :style="{ viewTransitionName: `item-${itemId}` }" class="detail-hero">
<h1>{{ item.name }}</h1>
</div>
</template>
3. 封装通用 Hook / Composable
// React: useViewTransition
function useViewTransition(callback) {
return (...args) => {
if (!document.startViewTransition) {
callback(...args);
return;
}
document.startViewTransition(() => callback(...args));
};
}
// 使用
const handleNavigate = useViewTransition((id) => {
flushSync(() => navigate(`/detail/${id}`));
});
// Vue: useViewTransition composable
import { nextTick } from 'vue';
export function useViewTransition() {
const startTransition = async (callback) => {
if (!document.startViewTransition) {
await callback();
return;
}
document.startViewTransition(async () => {
await callback();
await nextTick();
});
};
return { startTransition };
}
七、列表动画
1. 列表重排过渡
经典的 FLIP 动画问题——列表排序时元素位置变化,需要平滑过渡。View Transitions API 让这一切变得极其简单:
function sortList(sortFn) {
document.startViewTransition(() => {
// 给每个列表项设置唯一的 view-transition-name
document.querySelectorAll('.list-item').forEach((item) => {
item.style.viewTransitionName = `item-${item.dataset.id}`;
});
// 执行排序,更新 DOM
sortFn();
});
}
/* 列表项重排时自动做位移动画 */
::view-transition-group(item-*) {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
}
/* 禁用默认的淡入淡出,只保留位移 */
::view-transition-old(item-*) {
animation: none;
}
::view-transition-new(item-*) {
animation: none;
}
::view-transition-group 默认会从旧的位置/尺寸动画到新的位置/尺寸——这正是 FLIP 动画要做的。禁用 old/new 的淡入淡出后,效果就是纯粹的"滑动到新位置",非常自然。
2. 动态网格过渡
function rearrangeGrid(newLayout) {
document.startViewTransition(() => {
// 每个网格项设置唯一 name
document.querySelectorAll('.grid-item').forEach((item) => {
item.style.viewTransitionName = `grid-${item.dataset.id}`;
});
// 应用新布局
applyLayout(newLayout);
});
}
/* 网格项过渡:位移 + 尺寸变化 */
::view-transition-group(grid-*) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 淡入淡出也保留,增加层次感 */
::view-transition-old(grid-*) {
animation: 0.2s ease fade-out;
}
::view-transition-new(grid-*) {
animation: 0.2s ease 0.2s fade-in; /* 延迟淡入,错开节奏 */
}
3. 列表项添加/删除
function addItem(newItem) {
document.startViewTransition(() => {
// 现有项设置 name(保持位置过渡)
document.querySelectorAll('.list-item').forEach((item) => {
item.style.viewTransitionName = `item-${item.dataset.id}`;
});
// 新项不设 name(走 root 的淡入)
addItemToDOM(newItem);
});
}
function removeItem(id) {
document.startViewTransition(() => {
// 所有项都设 name
document.querySelectorAll('.list-item').forEach((item) => {
item.style.viewTransitionName = `item-${item.dataset.id}`;
});
// 删除目标项
document.querySelector(`[data-id="${id}"]`)?.remove();
});
}
八、性能考量与渐进增强
1. 性能原理
View Transitions 的性能关键在于:截图和动画。
- 截图:浏览器将旧/新状态光栅化为位图,这个操作在合成线程执行,不阻塞主线程。
- 动画:伪元素层的动画在 GPU 合成,和 CSS transform/opacity 动画一样高效。
- DOM 更新:
updateCallback中的 DOM 操作在主线程,这是唯一可能卡顿的环节。
2. 性能优化建议
/* ❌ 避免对大区域做复杂动画 */
::view-transition-group(root) {
animation: 1s complex-animation; /* 整个页面都在动画,GPU 压力大 */
}
/* ✅ 只对需要的元素做过渡 */
.special-element {
view-transition-name: special; /* 只这一个元素动画 */
}
::view-transition-group(root) {
animation: none; /* root 不做动画 */
}
| 优化点 | 建议 |
|---|---|
| 过渡时长 | 200-400ms 最佳,超过 500ms 用户会感觉拖沓 |
| 同时过渡元素数 | 建议 <10 个独立 name,过多会增加截图和合成开销 |
| 大尺寸元素 | 全屏图片过渡代价高,考虑降级处理 |
| updateCallback | 尽量同步完成,避免在回调中做大量异步操作 |
| prefers-reduced-motion | 始终尊重用户的无障碍偏好 |
3. 渐进增强
/* 尊重用户的动画偏好 */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root),
::view-transition-group(*) {
animation-duration: 0.01ms !important;
}
}
/* JS 中的渐进增强 */
function updateWithTransition(updateFn) {
if (!document.startViewTransition) {
// 不支持时直接更新,无动画
updateFn();
return;
}
document.startViewTransition(updateFn);
}
九、常见陷阱与兼容性
陷阱 1:view-transition-name 不唯一
/* ❌ 两个可见元素同名的后果:过渡被跳过 */
.card:first-child { view-transition-name: card; }
.card:last-child { view-transition-name: card; }
/* ✅ 每个元素唯一 name */
.card { view-transition-name: var(--vt-name); }
// JS: card.style.setProperty('--vt-name', `card-${id}`);
陷阱 2:过渡期间 DOM 不可交互
过渡期间,伪元素层覆盖在真实 DOM 之上。用户点击按钮可能没反应。解决方案:
/* 短过渡通常无影响。如果确实需要交互: */
::view-transition-old(root),
::view-transition-new(root) {
pointer-events: auto; /* 允许点击穿透 */
}
/* 或者干脆缩短过渡时间 */
::view-transition-group(root) {
animation-duration: 150ms;
}
陷阱 3:React 中 startViewTransition 回调不生效
// ❌ React 18 自动批处理,DOM 可能不会立即更新
document.startViewTransition(() => {
setState(newValue); // 不会立即渲染
});
// ✅ 用 flushSync 强制同步渲染
import { flushSync } from 'react-dom';
document.startViewTransition(() => {
flushSync(() => {
setState(newValue); // 立即渲染,浏览器能截取新快照
});
});
陷阱 4:跨页面过渡时 name 未提前设置
// ❌ 页面加载后才设置 name,浏览器已经截取了快照
window.addEventListener('load', () => {
// 太晚了!
document.querySelector('.hero').style.viewTransitionName = 'hero';
});
// ✅ 在 CSS 中静态设置,或在 pagereveal 事件中动态设置
// CSS 方式:
.hero { view-transition-name: hero; }
// JS 方式:
document.addEventListener('pagereveal', (e) => {
if (e.viewTransition) {
document.querySelector('.hero').style.viewTransitionName = 'hero';
}
});
陷阱 5:忘记清理动态设置的 name
// ❌ 列表项切换后 name 残留
function showPage(page) {
// 旧页面的 name 还在,可能和新页面冲突
document.startViewTransition(() => {
renderNewPage(page);
});
}
// ✅ 过渡前清理旧 name,设置新 name
function showPage(page) {
// 先清理
document.querySelectorAll('[style*="view-transition-name"]')
.forEach(el => el.style.viewTransitionName = 'none');
document.startViewTransition(() => {
renderNewPage(page);
// 为新页面元素设置 name
document.querySelectorAll('.card').forEach(card => {
card.style.viewTransitionName = `card-${card.dataset.id}`;
});
});
}
兼容性速查(2026 年 6 月)
| 浏览器 | 同文档过渡 | 跨文档过渡 | 备注 |
|---|---|---|---|
| Chrome 111+ | ✅ | ✅(126+) | 完整支持 |
| Edge 111+ | ✅ | ✅(126+) | 同 Chrome |
| Firefox | ✅(126+) | 🚧 开发中 | 同文档过渡已支持 |
| Safari 18+ | ✅ | 🚧 开发中 | iOS 18+ 支持 |
| Samsung Internet | ✅ | ✅ | 跟随 Chrome |
十、速查表
| 场景 | 代码 | 要点 |
|---|---|---|
| 最简过渡 | document.startViewTransition(fn) |
自动淡入淡出 |
| 修改过渡时长 | ::view-transition-old/new(root) { animation-duration } |
默认 250ms |
| 元素独立过渡 | view-transition-name: xxx |
同一时刻必须唯一 |
| 禁用 root 过渡 | ::view-transition-old/new(root) { animation: none } |
只对特定元素过渡 |
| 跨页面过渡(MPA) | @view-transition { navigation: auto } |
两页面都需设置 |
| Hero Animation | 两页面设置相同 view-transition-name |
自动匹配位置/尺寸 |
| 列表重排 | 每项设 view-transition-name,禁用 old/new 动画 |
只保留 group 位移 |
| React 集成 | flushSync + startViewTransition |
强制同步渲染 |
| Vue 集成 | 路由守卫 + nextTick() |
等 DOM 更新完成 |
| 渐进增强 | if (!document.startViewTransition) fallback() |
不支持的浏览器直接更新 |
| 无障碍 | @media (prefers-reduced-motion: reduce) |
缩短或禁用动画 |
startViewTransition 一行代码触发过渡,view-transition-name 实现元素级自定义,@view-transition { navigation: auto } 开启跨页面过渡。渐进增强,不支持时静默降级——没有不用的理由。