View Transitions API:原生页面过渡动画的新时代

2932 字
15 分钟
View Transitions API:原生页面过渡动画的新时代

前言:页面切换的痛点#

长久以来,Web 页面之间的切换体验远不如原生应用。在 iOS 或 Android 中,页面间的过渡动画流畅自然——列表项点击后放大展开为详情页,返回时元素平滑缩回。而在 Web 中,传统的页面跳转(MPA)是”白屏闪烁”,SPA 中虽然可以用 JavaScript + CSS 实现过渡动画,但实现成本极高,代码复杂且容易出 bug。

View Transitions API 的出现改变了一切。它是浏览器原生提供的页面过渡动画机制,让你用极少的代码就能实现丝滑的页面切换效果,包括:

  • 同页面内的 DOM 状态变化过渡
  • SPA 路由切换过渡
  • 跨页面(MPA)的导航过渡

核心原理#

View Transitions API 的工作原理可以概括为四步:

  1. 快照(Snapshot):浏览器捕获当前页面的视觉快照(包括标记了 view-transition-name 的元素的单独快照)
  2. 更新(Update):执行 DOM 变更(你的回调函数)
  3. 新快照(New Snapshot):捕获更新后页面的视觉快照
  4. 动画(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 集成#

router.js
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 有一流支持:

Layout.astro
---
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>
PostCard.astro
---
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>
[slug].astro
---
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 年初:

特性ChromeFirefoxSafari
同文档 View Transitions111+ ✅🟡 Flag18+ ✅
跨文档 View Transitions126+ ✅
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 应用加上原生级别的过渡动画吧。用户会注意到那份丝滑的体验差异。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

View Transitions API:原生页面过渡动画的新时代
https://boke.hackerdream.xyz/posts/css-view-transitions/
作者
晴天
发布于
2026-02-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录