View Transitions API:原生页面过渡动画的新时代
前言:页面切换的痛点
长久以来,Web 页面之间的切换体验远不如原生应用。在 iOS 或 Android 中,页面间的过渡动画流畅自然——列表项点击后放大展开为详情页,返回时元素平滑缩回。而在 Web 中,传统的页面跳转(MPA)是”白屏闪烁”,SPA 中虽然可以用 JavaScript + CSS 实现过渡动画,但实现成本极高,代码复杂且容易出 bug。
View Transitions API 的出现改变了一切。它是浏览器原生提供的页面过渡动画机制,让你用极少的代码就能实现丝滑的页面切换效果,包括:
- 同页面内的 DOM 状态变化过渡
- SPA 路由切换过渡
- 跨页面(MPA)的导航过渡
核心原理
View Transitions API 的工作原理可以概括为四步:
- 快照(Snapshot):浏览器捕获当前页面的视觉快照(包括标记了
view-transition-name的元素的单独快照) - 更新(Update):执行 DOM 变更(你的回调函数)
- 新快照(New Snapshot):捕获更新后页面的视觉快照
- 动画(Animate):在旧快照和新快照之间执行交叉淡入淡出(cross-fade)动画
整个过程中,浏览器创建了一棵伪元素树来承载动画:
::view-transition├── ::view-transition-group(root)│ └── ::view-transition-image-pair(root)│ ├── ::view-transition-old(root) ← 旧快照│ └── ::view-transition-new(root) ← 新快照├── ::view-transition-group(card-hero)│ └── ::view-transition-image-pair(card-hero)│ ├── ::view-transition-old(card-hero)│ └── ::view-transition-new(card-hero)└── ...这意味着你可以用标准的 CSS 动画属性来自定义过渡效果。
同文档 View Transitions(SPA)
document.startViewTransition()
最基本的用法是 document.startViewTransition(),它接受一个回调函数来执行 DOM 更新:
// 最简用法document.startViewTransition(() => { // 在这里更新 DOM updateTheDom();});回调函数可以返回一个 Promise(支持异步操作):
document.startViewTransition(async () => { // 异步获取新数据 const data = await fetchNewContent(); // 更新 DOM container.innerHTML = renderContent(data);});返回值:ViewTransition 对象
startViewTransition() 返回一个 ViewTransition 对象,包含多个 Promise 供你精确控制时机:
const transition = document.startViewTransition(updateCallback);
// 旧快照已捕获,DOM 更新即将开始transition.ready.then(() => { console.log('伪元素树已创建,动画即将开始'); // 可以在这里用 Web Animations API 自定义动画});
// DOM 更新已完成(回调 resolve 后)transition.updateCallbackDone.then(() => { console.log('DOM 更新完成');});
// 整个过渡动画结束transition.finished.then(() => { console.log('过渡完成,伪元素已清理');});
// 跳过/取消过渡transition.skipTransition();一个完整的主题切换示例
<button id="theme-toggle">切换主题</button>const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', (event) => { // 检查 API 支持 if (!document.startViewTransition) { toggleTheme(); return; }
const transition = document.startViewTransition(() => { toggleTheme(); });
// 自定义动画:从点击位置扩散的圆形遮罩 transition.ready.then(() => { const { clientX, clientY } = event; const endRadius = Math.hypot( Math.max(clientX, window.innerWidth - clientX), Math.max(clientY, window.innerHeight - clientY) );
document.documentElement.animate( { clipPath: [ `circle(0px at ${clientX}px ${clientY}px)`, `circle(${endRadius}px at ${clientX}px ${clientY}px)`, ], }, { duration: 500, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)', } ); });});
function toggleTheme() { document.documentElement.classList.toggle('dark');}/* 禁用默认的交叉淡入淡出 */::view-transition-old(root),::view-transition-new(root) { animation: none; mix-blend-mode: normal;}
/* 确保新视图在上层 */::view-transition-new(root) { z-index: 1;}效果:点击按钮后,新主题从点击位置以圆形扩散的方式覆盖旧主题,就像 Android 的涟漪效果。
view-transition-name:元素级过渡
view-transition-name 是让 View Transitions 真正强大的关键。通过给元素命名,浏览器会自动计算该元素在过渡前后的位置、大小差异,并生成平滑的插值动画。
/* 列表页的卡片图片 */.card-image { view-transition-name: hero-image;}
/* 详情页的大图 —— 使用相同的名字 */.detail-hero { view-transition-name: hero-image;}关键规则:在同一时刻,页面上不能有两个元素使用相同的 view-transition-name。否则过渡会失败。
动态分配 view-transition-name
在列表场景中,你不能给每个列表项都写死相同的名字。解决方案是在点击时动态分配:
// 列表项点击处理function handleItemClick(item, id) { // 只给被点击的项分配名称 item.style.viewTransitionName = 'hero-card';
const transition = document.startViewTransition(async () => { await navigateToDetail(id); });
transition.finished.then(() => { // 过渡结束后清理 item.style.viewTransitionName = ''; });}或者使用 CSS 动态生成唯一名称(Chrome 125+ 支持):
/* 每个卡片使用基于自定义属性的唯一名称 */.card { view-transition-name: var(--card-name);}<div class="card" style="--card-name: card-1">...</div><div class="card" style="--card-name: card-2">...</div><div class="card" style="--card-name: card-3">...</div>自定义过渡动画
使用 CSS 自定义
默认的交叉淡入淡出很好,但你可以完全控制动画:
/* 自定义过渡时长和缓动 */::view-transition-group(*) { animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);}
/* 旧视图:向左滑出 */::view-transition-old(root) { animation: slide-out-left 0.3s ease-in forwards;}
/* 新视图:从右滑入 */::view-transition-new(root) { animation: slide-in-right 0.3s ease-out forwards;}
@keyframes slide-out-left { to { transform: translateX(-100%); opacity: 0; }}
@keyframes slide-in-right { from { transform: translateX(100%); opacity: 0; }}不同元素不同动画
/* 页面整体:淡入淡出 */::view-transition-old(root) { animation: fade-out 0.25s ease-out;}::view-transition-new(root) { animation: fade-in 0.25s ease-in;}
/* 英雄图片:平滑移动和缩放(浏览器自动处理) */::view-transition-group(hero-image) { animation-duration: 0.5s; animation-timing-function: cubic-bezier(0.2, 0, 0, 1);}
/* 标题:从下方滑入 */::view-transition-new(page-title) { animation: slide-up 0.4s ease-out;}::view-transition-old(page-title) { animation: slide-down 0.3s ease-in;}
@keyframes fade-out { to { opacity: 0; } }@keyframes fade-in { from { opacity: 0; } }@keyframes slide-up { from { transform: translateY(30px); opacity: 0; } }@keyframes slide-down { to { transform: translateY(-30px); opacity: 0; } }基于过渡类型的条件动画
你可以根据导航方向应用不同动画。通过 transition.types 指定过渡类型:
function navigate(url, direction) { const transition = document.startViewTransition({ update: () => updatePage(url), types: [direction], // 'forward' 或 'backward' });}
// 向前导航navigate('/page-2', 'forward');// 向后导航navigate('/page-1', 'backward');/* 向前:新页面从右滑入 */html:active-view-transition-type(forward) { &::view-transition-old(root) { animation: slide-out-left 0.3s ease-in; } &::view-transition-new(root) { animation: slide-in-right 0.3s ease-out; }}
/* 向后:新页面从左滑入 */html:active-view-transition-type(backward) { &::view-transition-old(root) { animation: slide-out-right 0.3s ease-in; } &::view-transition-new(root) { animation: slide-in-left 0.3s ease-out; }}
@keyframes slide-out-left { to { transform: translateX(-30%); opacity: 0; } }@keyframes slide-in-right { from { transform: translateX(30%); opacity: 0; } }@keyframes slide-out-right { to { transform: translateX(30%); opacity: 0; } }@keyframes slide-in-left { from { transform: translateX(-30%); opacity: 0; } }跨文档 View Transitions(MPA)
对于传统多页面应用(MPA),Chrome 126+ 支持跨文档的 View Transitions。无需 JavaScript,只需 CSS 就能实现页面间的过渡动画。
启用跨文档过渡
在两个页面的 CSS 中都加入:
@view-transition { navigation: auto;}就这么简单!浏览器会在同源导航时自动应用默认的交叉淡入淡出过渡。
配合 view-transition-name
/* 列表页 list.html */@view-transition { navigation: auto;}
.product-image { view-transition-name: product-hero;}
.page-header { view-transition-name: header;}
/* 详情页 detail.html */@view-transition { navigation: auto;}
.detail-image { view-transition-name: product-hero; /* 相同的名字 */}
.page-header { view-transition-name: header; /* 相同的名字 */}当用户从列表页点击进入详情页时,.product-image 会平滑地过渡到 .detail-image 的位置和大小——浏览器自动处理中间的插值动画。
使用 pageswap 和 pagereveal 事件
对于更精细的控制,可以监听这两个事件:
// 在旧页面触发(即将离开)window.addEventListener('pageswap', (event) => { if (event.viewTransition) { const url = new URL(event.activation.entry.url);
// 根据目标 URL 动态设置 view-transition-name if (url.pathname.startsWith('/product/')) { const id = url.pathname.split('/').pop(); const card = document.querySelector(`[data-product-id="${id}"]`); if (card) { card.querySelector('img').style.viewTransitionName = 'product-hero'; } } }});
// 在新页面触发(即将展示)window.addEventListener('pagereveal', (event) => { if (event.viewTransition) { // 可以在这里调整新页面的过渡设置 const hero = document.querySelector('.detail-hero'); if (hero) { hero.style.viewTransitionName = 'product-hero'; } }});配合 SPA 路由
与 Vue Router 集成
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('./views/Home.vue') }, { path: '/about', component: () => import('./views/About.vue') }, { path: '/post/:id', component: () => import('./views/Post.vue') }, ],});
// 使用 View Transitions API 包装路由切换router.beforeResolve(async (to, from) => { // 检查 API 支持 if (!document.startViewTransition) return;
// 判断导航方向 const direction = getNavigationDirection(to, from);
await new Promise((resolve) => { const transition = document.startViewTransition({ update: resolve, types: [direction], }); });});
function getNavigationDirection(to, from) { const routeOrder = ['/', '/about', '/post']; const toIndex = routeOrder.findIndex((r) => to.path.startsWith(r)); const fromIndex = routeOrder.findIndex((r) => from.path.startsWith(r)); return toIndex >= fromIndex ? 'forward' : 'backward';}
export default router;与 Astro 集成
Astro 对 View Transitions 有一流支持:
---import { ViewTransitions } from 'astro:transitions';---
<html> <head> <ViewTransitions /> </head> <body> <header transition:name="header" transition:animate="none"> <nav>...</nav> </header> <main> <slot /> </main> </body></html>---const { post } = Astro.props;---
<article> <img src={post.cover} alt={post.title} transition:name={`post-image-${post.slug}`} /> <h2 transition:name={`post-title-${post.slug}`}> {post.title} </h2></article>---import { getEntry } from 'astro:content';const { slug } = Astro.params;const post = await getEntry('posts', slug);---
<img src={post.data.cover} alt={post.data.title} transition:name={`post-image-${slug}`}/><h1 transition:name={`post-title-${slug}`}> {post.data.title}</h1>Astro 会自动处理 view-transition-name 的分配和清理,配合其内置的 SPA 模式(client-side navigation),实现无缝过渡。
性能考量与最佳实践
1. 尊重用户偏好
/* 用户开启了减少动画偏好 */@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation-duration: 0.01ms !important; }}2. 保持过渡时间短
/* ✅ 推荐:200-500ms */::view-transition-group(*) { animation-duration: 300ms;}
/* ❌ 避免:过长的动画让用户等待 */::view-transition-group(*) { animation-duration: 2s;}3. 避免过渡过多元素
每个 view-transition-name 都会创建独立的快照和伪元素。过多的命名元素会影响性能:
/* ❌ 不要给列表中的每一项都加过渡名 */.list-item:nth-child(1) { view-transition-name: item-1; }.list-item:nth-child(2) { view-transition-name: item-2; }/* ... 50 个 */
/* ✅ 只给关键元素加过渡名 */.hero-image { view-transition-name: hero; }.page-title { view-transition-name: title; }4. 优雅降级
// 始终做特性检测function navigateWithTransition(url, updateFn) { if (!document.startViewTransition) { updateFn(); return; }
document.startViewTransition(updateFn);}5. 处理异步内容
如果新页面内容需要加载(图片、数据),在过渡回调中等待加载完成:
document.startViewTransition(async () => { // 更新 DOM container.innerHTML = newContent;
// 等待关键图片加载 const images = container.querySelectorAll('img[data-critical]'); await Promise.all( Array.from(images).map( (img) => img.complete ? Promise.resolve() : new Promise((r) => { img.onload = r; img.onerror = r; }) ) );});实战:构建一个完整的列表-详情过渡
下面是一个完整的实战案例,展示从列表页到详情页的过渡效果:
<!-- 列表视图 --><div id="app"> <div id="list-view"> <h1 class="page-title" style="view-transition-name: title">文章列表</h1> <div class="card-grid"> <article class="card" data-id="1" onclick="openDetail(this, '1')"> <img src="img1.jpg" class="card__img" /> <h2 class="card__title">深入理解 CSS Grid</h2> <p class="card__excerpt">CSS Grid 是二维布局的终极方案...</p> </article> <!-- 更多卡片 --> </div> </div>
<div id="detail-view" hidden> <button onclick="goBack()">← 返回</button> <img class="detail__hero" /> <h1 class="detail__title" style="view-transition-name: title"></h1> <div class="detail__content"></div> </div></div>let currentCard = null;
async function openDetail(card, id) { currentCard = card;
// 动态分配 view-transition-name card.querySelector('.card__img').style.viewTransitionName = 'hero-image'; card.querySelector('.card__title').style.viewTransitionName = 'card-title';
const transition = document.startViewTransition({ update: async () => { // 获取文章数据 const data = await getArticle(id);
// 更新详情视图 const detailView = document.getElementById('detail-view'); detailView.querySelector('.detail__hero').src = data.image; detailView.querySelector('.detail__hero').style.viewTransitionName = 'hero-image'; detailView.querySelector('.detail__title').textContent = data.title; detailView.querySelector('.detail__title').style.viewTransitionName = 'card-title'; detailView.querySelector('.detail__content').innerHTML = data.content;
// 切换视图 document.getElementById('list-view').hidden = true; detailView.hidden = false; }, types: ['forward'], });
transition.finished.then(() => { // 清理列表卡片上的 view-transition-name card.querySelector('.card__img').style.viewTransitionName = ''; card.querySelector('.card__title').style.viewTransitionName = ''; });}
async function goBack() { if (!currentCard) return;
// 重新分配名称 const detailView = document.getElementById('detail-view'); detailView.querySelector('.detail__hero').style.viewTransitionName = 'hero-image'; detailView.querySelector('.detail__title').style.viewTransitionName = 'card-title';
currentCard.querySelector('.card__img').style.viewTransitionName = 'hero-image'; currentCard.querySelector('.card__title').style.viewTransitionName = 'card-title';
const transition = document.startViewTransition({ update: () => { detailView.hidden = true; document.getElementById('list-view').hidden = false; }, types: ['backward'], });
transition.finished.then(() => { detailView.querySelector('.detail__hero').style.viewTransitionName = ''; detailView.querySelector('.detail__title').style.viewTransitionName = ''; currentCard.querySelector('.card__img').style.viewTransitionName = ''; currentCard.querySelector('.card__title').style.viewTransitionName = ''; currentCard = null; });}/* 基础过渡 */::view-transition-group(hero-image) { animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);}
::view-transition-group(card-title) { animation-duration: 0.35s; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);}
/* 前进:页面内容淡入 */html:active-view-transition-type(forward) ::view-transition-new(root) { animation: fade-slide-in 0.3s ease-out;}
html:active-view-transition-type(forward) ::view-transition-old(root) { animation: fade-out 0.2s ease-in;}
/* 后退:页面内容淡出 */html:active-view-transition-type(backward) ::view-transition-new(root) { animation: fade-in 0.3s ease-out;}
html:active-view-transition-type(backward) ::view-transition-old(root) { animation: fade-slide-out 0.2s ease-in;}
@keyframes fade-slide-in { from { opacity: 0; transform: translateY(20px); }}
@keyframes fade-slide-out { to { opacity: 0; transform: translateY(20px); }}
@keyframes fade-in { from { opacity: 0; } }@keyframes fade-out { to { opacity: 0; } }浏览器兼容性
截至 2026 年初:
| 特性 | Chrome | Firefox | Safari |
|---|---|---|---|
| 同文档 View Transitions | 111+ ✅ | 🟡 Flag | 18+ ✅ |
| 跨文档 View Transitions | 126+ ✅ | ❌ | ❌ |
types 参数 | 125+ ✅ | ❌ | ❌ |
同文档的 View Transitions 已有良好支持,跨文档的仍在推进中。建议始终做特性检测,将 View Transitions 作为渐进增强。
总结
View Transitions API 是 Web 平台近年来最令人兴奋的特性之一。它让原本需要大量 JavaScript 和复杂状态管理才能实现的页面过渡动画,变得像写 CSS 一样简单。
关键要点:
document.startViewTransition()是同文档过渡的入口view-transition-name让元素在新旧状态间平滑过渡- 伪元素树让你用纯 CSS 控制动画细节
@view-transition { navigation: auto }开启跨文档过渡- 始终做特性检测,优雅降级
- 保持动画简短(200-500ms),尊重
prefers-reduced-motion
从今天开始,给你的 Web 应用加上原生级别的过渡动画吧。用户会注意到那份丝滑的体验差异。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!