我一直在想一个事:为什么原生 App 的页面切换可以做得那么自然——从列表滑进详情、从缩略图展开成大图——而网页上点一个链接,永远是一刀切的白屏?
不是没有解决方案,而是那些方案都太重了。
SPA 框架里写个页面过渡动画,你得:
- 用
<Transition>或<KeepAlive>包裹路由 - 写 CSS
enter/leave动画的四件套:enter-from、enter-active、enter-to、leave-from…… - 处理新旧 DOM 共存时的 z-index、层级、事件穿透
- 等动画结束手动清理旧 DOM
- 还要操心无障碍问题——屏幕阅读器会不会同时读两页内容?
代码量一上来,维护成本直线上升。而且这套方案只对 SPA 有效,传统多页面应用(MPA)根本用不了。
但浏览器中还存在着一个全新的答案:CSS View Transitions API。
什么是 View Transitions?
简单说:浏览器原生提供的页面状态过渡机制。
你在两个状态之间切换时(比如从列表页到详情页、从暗色模式到亮色模式),浏览器会自动截取"切换前"和"切换后"两张快照,然后在它们之间做动画过渡。
最基础的用法,真的就两行代码:
// 以前:直接改 DOM,画面"咔"一下
element.textContent = '新内容';
// 现在:用 startViewTransition 包一下,丝滑过渡
document.startViewTransition(() => {
element.textContent = '新内容';
});
没了。就这一个 API 调用,浏览器就自动给你做了个渐隐渐现的过渡效果。
我当时第一次看到这段代码的时候,反复确认了三遍——就这?不用装库?不用配路由?不用写四件套?
是的,就这。
先来个完整例子,感受一下
我们先做一个最简单的例子:点击按钮,切换一张图片和文字。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>View Transitions Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: system-ui, sans-serif;
background: #0f0f0f;
color: #fff;
}
.card {
width: 400px;
text-align: center;
}
.card img {
width: 100%;
border-radius: 12px;
margin-bottom: 16px;
}
.card h2 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.card p {
color: #aaa;
line-height: 1.6;
}
button {
margin-top: 24px;
padding: 10px 24px;
border: none;
border-radius: 8px;
background: #fff;
color: #000;
font-size: 1rem;
cursor: pointer;
}
</style>
</head>
<body>
<div class="card">
<img id="hero" src="https://picsum.photos/id/1015/400/300" alt="风景">
<h2 id="title">山间溪流</h2>
<p id="desc">清晨的阳光穿过树梢,溪水在石头间欢快地跳跃。</p>
</div>
<button onclick="switchSlide()">下一张 →</button>
<script>
const slides = [
{ img: 'https://picsum.photos/id/1015/400/300', title: '山间溪流', desc: '清晨的阳光穿过树梢,溪水在石头间欢快地跳跃。' },
{ img: 'https://picsum.photos/id/1018/400/300', title: '雾中湖泊', desc: '湖面像一面镜子,映着远处若隐若现的群山。' },
{ img: 'https://picsum.photos/id/1035/400/300', title: '夕阳海岸', desc: '最后一缕阳光把海面染成了橘红色,海风带着咸味。' },
];
let index = 0;
function switchSlide() {
index = (index + 1) % slides.length;
const slide = slides[index];
// ⭐ 就这一行!把 DOM 更新包进 startViewTransition
document.startViewTransition(() => {
document.getElementById('hero').src = slide.img;
document.getElementById('title').textContent = slide.title;
document.getElementById('desc').textContent = slide.desc;
});
}
</script>
</body>
</html>
把这段代码保存成 HTML 文件,在 Chrome 111+ 中打开,点一下"下一张"——你会看到图片和文字有一个非常自然的交叉淡入淡出效果。
如果没有 View Transitions,切换就是"咔"一下全变;有了它,就像有人在你眼前慢慢翻过一页。
让元素"独立运动":view-transition-name
默认的过渡是整个页面一起淡入淡出。但如果你想让不同的元素各自有各自的动画呢?
比如图片从左飞入、标题从下滑入、描述文字渐显——这就是 view-transition-name 登场的时候。
/* 给每个元素起一个名字 */
.hero-image {
view-transition-name: hero;
}
.title {
view-transition-name: title;
}
.description {
view-transition-name: desc;
}
加上 view-transition-name 之后,浏览器会为这些元素单独生成快照,你可以对它们分别写动画:
/* 图片:从左侧滑入 */
::view-transition-group(hero) {
animation: slide-in 0.4s ease-out;
}
/* 标题:从下方弹入 */
::view-transition-group(title) {
animation: bounce-up 0.5s ease-out;
}
/* 描述文字:简单淡入 */
::view-transition-group(desc) {
animation: fade-in 0.3s ease;
}
@keyframes slide-in {
from { transform: translateX(-50px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes bounce-up {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
效果:图片从左边飞进来,标题从下面弹上来,描述文字慢慢淡入——每个元素有自己的节奏,整个页面像一个精心编排的舞台剧。
我第一次在项目里用这个的时候,设计师看到效果直接愣住了,问我"这是原生的?不用 GSAP?不用 Framer Motion?"
对,原生的。
真正的杀手锏:跨文档过渡(MPA)
上面的例子都是 SPA——JavaScript 控制 DOM 变化。但 View Transitions 最让我兴奋的能力是:它能在多页面应用(MPA)中也能用。
什么意思?就是你从 index.html 点链接跳到 about.html,纯 HTML 跳转,没有任何 JavaScript 路由,也能有丝滑的过渡动画。
实现方式极其简单——在两个页面的 CSS 里各加一行:
/* index.html 和 about.html 都加上这个 */
@view-transition {
navigation: auto;
}
完了。浏览器就会在页面导航时自动触发过渡效果。
如果你想要更精细的控制,比如让某个元素在两个页面之间"连贯地运动"(就像 iOS 的相册点击放大效果),只需要在两个页面给同一个元素起相同的名字:
<!-- 列表页:index.html -->
<div class="thumbnail">
<img src="photo.jpg" style="view-transition-name: photo-expand;">
</div>
<!-- 详情页:detail.html -->
<div class="fullsize">
<img src="photo.jpg" style="view-transition-name: photo-expand;">
</div>
浏览器看到两个页面都有 view-transition-name: photo-expand 的元素,就会自动做位置和大小的补间动画——缩略图会"长"成大图,平滑地过渡过去。
这就是 iOS、Android 相册那种"点击放大"效果。以前在网页上实现这种效果,代码量是现在的 10 倍以上。
深入一点:伪元素体系
View Transitions 的底层是一套伪元素体系。了解这些,你才能真正"驯服"这个 API。
::view-transition ← 过渡遮罩层的根
└── ::view-transition-group(root) ← 整个页面的过渡组
│ ├── ::view-transition-image-pair(root)
│ │ ├── ::view-transition-old(root) ← 切换前的快照
│ │ └── ::view-transition-new(root) ← 切换后的内容
│ └── animation: ... ← 默认动画
│
└── ::view-transition-group(hero) ← 命名为 "hero" 的元素
├── ::view-transition-image-pair(hero)
│ ├── ::view-transition-old(hero)
│ └── ::view-transition-new(hero)
└── animation: ...
当 startViewTransition 被调用时:
- 浏览器暂停 DOM 更新
- 截取当前页面的快照 → 生成
::view-transition-old(root)和各命名元素的::view-transition-old(名字) - 执行 DOM 更新回调
- 截取更新后的快照 → 生成
::view-transition-new(root)和各命名元素的::view-transition-new(名字) - 把 old 和 new 放在各自的
::view-transition-group里 - 执行 CSS 动画
- 动画结束后,移除旧快照,恢复正常渲染
明白了这个流程,你就能理解为什么有些操作在 startViewTransition 回调里做不了(比如不能异步等待 fetch 结果再更新 DOM——浏览器需要同步截取"新"状态的快照)。
进阶技巧:类型控制与条件过渡
有时候你不想所有导航都用同一种过渡效果。比如前进用滑入,后退用滑出;或者从列表到详情用展开,从详情回列表用折叠。
View Transitions Level 2 提供了**类型(type)**机制:
// 带类型的过渡
document.startViewTransition({
update: () => {
// 更新 DOM
showDetailView();
},
types: ['forward', 'expand'] // 标记这是一次"前进+展开"
});
然后在 CSS 里根据类型应用不同的动画:
/* 前进时:从右边滑入 */
: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;
}
}
/* 后退时:从左边滑入 */
: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-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slide-out-left {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
/* ... 其他方向类似 */
这就跟 iOS 的 UINavigationController 一样的效果了——前进右滑入,后退左滑出。用 CSS 搞定,不需要任何 JavaScript 动画库。
那些"坑"和注意事项
说了这么多好处,也得聊聊实际使用中遇到的问题。
1. 异步操作的限制
startViewTransition 的回调必须是同步的。你不能在里面 await 一个 fetch 请求,因为浏览器需要立即截取新状态的快照。
如果你的数据是异步获取的,有两个思路:
// 思路一:先 fetch 再过渡
async function showDetail(id) {
const data = await fetch(`/api/detail/${id}`).then(r => r.json());
// 数据拿到了,再触发过渡
document.startViewTransition(() => {
renderDetail(data);
});
}
// 思路二:先过渡,再加载数据(骨架屏)
function showDetail(id) {
document.startViewTransition(() => {
renderSkeleton(); // 先显示骨架屏
});
// 异步加载真实数据
fetch(`/api/detail/${id}`).then(r => r.json()).then(data => {
document.startViewTransition(() => {
renderDetail(data); // 数据到了再过渡一次
});
});
}
思路二的体验其实很好——用户先看到一个丝滑的过渡到骨架屏,然后骨架屏再丝滑地变成真实内容。比"等数据→白屏→突然出现"要舒服得多。
2. 浏览器兼容性
截至 2026 年初:
| 特性 | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| SPA View Transitions (Level 1) | 111+ ✅ | 111+ ✅ | ❌ | 18+ ✅ |
| 跨文档 View Transitions (Level 2) | 126+ ✅ | 126+ ✅ | ❌ | ❌ |
Firefox 目前还没有支持,Safari 在 18 版本加入了 Level 1 但 Level 2 还没有。所以目前来说:
- SPA 场景:Chrome + Edge + Safari 可用,覆盖主流用户
- MPA 场景:目前只有 Chromium 系浏览器
不过好消息是,View Transitions 是渐进增强的——不支持的浏览器会直接跳过过渡,页面照常工作,只是没有动画而已。所以你完全可以放心地用上,不支持的用户也不会看到白屏或者报错。
3. 性能影响
view-transition-name 会让元素被单独截图生成图层。如果给太多元素加了 view-transition-name,会导致:
- 内存占用增加(每个命名元素一份快照)
- 合成层增多,GPU 负担加重
经验法则:页面里同时有 view-transition-name 的元素不超过 5-8 个。多了就考虑用 root 级别的过渡,或者 view-transition-class 来分组管理。
4. match-element 的魔法
如果你不想手动给每个元素命名,View Transitions Level 2 提供了一个特殊值:
.list-item {
view-transition-name: match-element;
}
match-element 会告诉浏览器:自动匹配新旧状态下位置和大小最接近的元素,并对它们做补间动画。
非常适合列表 → 详情的场景:列表项自动"长"成详情页的大卡片,不需要手动命名每个元素。
实战:做一个图片画廊
来一个稍微完整的例子,模拟一个图片画廊的展开效果:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片画廊 - View Transitions</title>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #111;
color: #fff;
}
/* 网格布局 */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
padding: 24px;
max-width: 1000px;
margin: 0 auto;
}
.gallery img {
width: 100%;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.gallery img:hover {
transform: scale(1.03);
}
/* 详情遮罩 */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 100;
}
.overlay img {
max-width: 90vw;
max-height: 90vh;
border-radius: 12px;
}
/* ⭐ 关键:让缩略图和大图共享同一个 transition name */
.gallery img.expandable {
view-transition-name: photo-expand;
}
.overlay img {
view-transition-name: photo-expand;
}
/* 自定义过渡动画 */
::view-transition-old(photo-expand) {
animation: none;
}
::view-transition-new(photo-expand) {
animation: none;
}
/* 让浏览器自动做位置和大小的补间 */
</style>
</head>
<body>
<div class="gallery" id="gallery">
<img class="expandable" src="https://picsum.photos/id/1015/200/200" onclick="expand(this)">
<img class="expandable" src="https://picsum.photos/id/1018/200/200" onclick="expand(this)">
<img class="expandable" src="https://picsum.photos/id/1035/200/200" onclick="expand(this)">
<img class="expandable" src="https://picsum.photos/id/1039/200/200" onclick="expand(this)">
<img class="expandable" src="https://picsum.photos/id/1043/200/200" onclick="expand(this)">
<img class="expandable" src="https://picsum.photos/id/1047/200/200" onclick="expand(this)">
</div>
<script>
function expand(thumb) {
const src = thumb.src.replace('/200/200', '/800/600');
document.startViewTransition(() => {
// 移除缩略图上的命名(避免冲突)
thumb.classList.remove('expandable');
// 创建详情遮罩
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.innerHTML = `<img src="${src}">`;
overlay.onclick = () => collapse(overlay, thumb);
document.body.appendChild(overlay);
});
}
function collapse(overlay, thumb) {
document.startViewTransition(() => {
overlay.remove();
thumb.classList.add('expandable');
});
}
</script>
</body>
</html>
保存成 HTML 文件在 Chrome 中打开——点击缩略图,它会平滑地"长大"成全屏大图;点击大图,又会平滑地"缩回"去变成缩略图。
整个过程不需要任何第三方库,纯原生 CSS + 几行 JavaScript。效果堪比 iOS 相册。
它对前端生态意味着什么?
聊完技术细节,我想说点"大"的。
View Transitions 的出现,意味着浏览器终于开始原生支持"应用级"的用户体验。
以前我们在网页上想要流畅的过渡效果,得依赖框架(Vue Router、React Router、Next.js)+ 动画库(Framer Motion、GSAP)+ 大量样板代码。这些工具没错,但它们本质上是在弥补浏览器的能力缺失。
现在浏览器直接把这些能力内置了。这对前端生态的影响可能是深远的:
- SPA 框架的过渡方案会被部分替代:以前 Vue Router 的
<Transition>组件是刚需,以后可能只是一个可选的封装 - MPA 架构会重新获得关注:以前选 SPA 很大一部分原因是为了页面切换的体验,现在 MPA 也能做到,那 SPA 的"存在感溢价"就降低了
- 设计师的话语权会变大:以前设计师说"我要一个展开动画",开发者说"实现不了"或"成本太高"。现在设计师可以更自由地提需求了,因为实现成本真的降了很多
当然,现在 Firefox 和 Safari 的支持还不够完整,短期内还不能作为唯一的方案。但方向已经很明确了——浏览器正在变成一个更好的应用平台。
总结
| 场景 | 以前的做法 | View Transitions |
|---|---|---|
| SPA 状态切换 | <Transition> + 四件套 CSS | startViewTransition(() => {...}) |
| MPA 页面跳转 | ❌ 没有好方案 | @view-transition { navigation: auto } |
| 元素位置补间 | JS 计算位置 + 手写动画 | view-transition-name + 浏览器自动补间 |
| 动画方向控制 | JS 判断路由方向 + 条件动画 | 类型机制 types: ['forward'] |
| 代码量 | 几十到几百行 | 几行到几十行 |
如果你的项目面向 Chrome/Edge 用户,现在就可以用起来了。 即使需要兼容 Firefox,也可以作为渐进增强——支持的浏览器有动画,不支持的照样能用。
不需要等所有浏览器都支持,不需要等框架更新,不需要装任何依赖。
是时候让你的网站告别那个"咔"一下的白屏了。
参考与致谢
本文在写作过程中参考了以下资料:
- View Transition API - MDN Web Docs — 官方 API 文档
- Smooth transitions with the View Transition API — Chrome 团队的深度教程
- CSS View Transitions Module Level 1 — W3C 规范草案
- CSS View Transitions Module Level 2 — 跨文档过渡规范
- HTTP 203 Playlist Demo — View Transitions 的经典演示项目
- Chrome DevRel View Transitions Demos — Google 官方示例集
- View Transitions API: Single Page Apps Without a Framework — DebugBear 的无框架实践
🙏 特别感谢 Google Chrome 团队的 Khushal Sagar 和 Jake Archibald,他们从设计到实现推动了 View Transitions API 的落地。这个 API 的代码示例和文档质量是我见过最好的之一。
下次你写页面过渡动画的时候,先试试 View Transitions。大概率你会发现:以前写了 200 行的东西,现在 5 行就够了。
往期精彩:
小米深夜放了个"核弹":三大自研 MiMo-V2模型,这次真要"为发烧而生"

评论区