起因
要做"列表页 → 详情页"的过渡动画——卡片放大变成详情头图。
传统做法:装 Framer Motion / GSAP 写 layoutId 切换,几十行 JS。
View Transitions API 是 2024 年 Chrome 全面支持的浏览器原生功能,
两行 CSS + 一行 JS 就能让任何 DOM 切换变成 morph 动画。
解决方案
1. 最简单的同页面状态切换
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
</style>
<button id="toggle">切换</button>
<div id="content">Hello</div>
<script>
document.getElementById('toggle').addEventListener('click', () => {
if (!document.startViewTransition) {
// fallback: 不支持的浏览器直接改
document.getElementById('content').textContent =
Math.random().toString(36).slice(2, 7)
return
}
document.startViewTransition(() => {
document.getElementById('content').textContent =
Math.random().toString(36).slice(2, 7)
})
})
</script>
效果:内容用平滑 crossfade 切换,而不是瞬间替换。
默认动画时长 0.25s。
2. 给特定元素命名 → 跨页面 morph
<style>
.product-card img {
view-transition-name: product-hero;
}
.product-detail .hero {
view-transition-name: product-hero;
}
</style>
列表页的卡片图和详情页的头图都叫 product-hero。
切换页面时(用 startViewTransition 包裹)浏览器自动 morph 一个元素到另一个。
// 列表页点击跳详情时
function navigate(href) {
if (!document.startViewTransition) {
location.href = href
return
}
document.startViewTransition(() => {
// 这里切换 DOM;SPA 用 router push
router.push(href)
})
}
效果:卡片图无缝放大到详情页大图位置,浏览器算 FLIP 动画。
不需要 Framer Motion 的 layoutId。
3. SPA 路由集成
React Router 6.4+
import { useNavigate } from 'react-router-dom'
import { flushSync } from 'react-dom'
function MyLink({ to, children }) {
const navigate = useNavigate()
return (
<a
href={to}
onClick={(e) => {
e.preventDefault()
if (!document.startViewTransition) {
navigate(to)
return
}
document.startViewTransition(() => {
flushSync(() => { navigate(to) })
// flushSync 让 React 立刻渲染,view transition 才能比较新旧 DOM
})
}}
>
{children}
</a>
)
}
SvelteKit
+layout.svelte 加一行:
<script>
import { beforeNavigate } from '$app/navigation'
beforeNavigate(({ complete }) => {
if (document.startViewTransition) {
const transition = document.startViewTransition(() => complete)
}
})
</script>
Astro
直接 <ViewTransitions /> 标签开启全站,无需自己 hook:
---
import { ViewTransitions } from 'astro:transitions'
---
<head>
<ViewTransitions />
</head>
4. 自定义动画
::view-transition-old(product-hero) {
animation: 0.3s ease-out fade-out;
}
::view-transition-new(product-hero) {
animation: 0.4s ease-in fade-in;
}
@keyframes fade-out {
to { opacity: 0; transform: translateY(-20px); }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
}
每个命名元素独立设动画。无名的全局用 ::view-transition-old(root)。
5. 同时多个 view-transition-name
详情页同时 morph 头图 + 标题 + 按钮:
.list .card .img { view-transition-name: hero-img; }
.list .card .title { view-transition-name: hero-title; }
.detail .hero { view-transition-name: hero-img; }
.detail h1 { view-transition-name: hero-title; }
浏览器同时为 hero-img 和 hero-title 做 morph,看起来三个元素
"飞到"详情页位置。
注意:同一时间 viewport 内每个 view-transition-name 只能有一个元素。
列表页和详情页的元素不会同时存在,但列表页有两张卡片都叫 hero-img
就出错。要给每张卡片不同 name:
.card-1 .img { view-transition-name: hero-1; }
.card-2 .img { view-transition-name: hero-2; }
或者用 CSS variable:
.card .img {
view-transition-name: var(--vt-name);
}
<div class="card" style={{ '--vt-name': `card-${id}` }}>
6. 检测支持
if ('startViewTransition' in document) {
// 支持
} else {
// 不支持,立刻切换(无动画)
}
Chrome / Edge 111+ 全支持;Safari 18+ 支持单文档;跨文档(MPA)navigation
还要 Chrome 126+。Firefox 还在开发。
不支持时优雅降级:动画不出现,但不影响功能。
实战 demo
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; font-family: sans-serif; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; }
.card { background: white; border-radius: 8px; overflow: hidden;
cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.card img { width: 100%; height: 200px; object-fit: cover; }
.detail { display: none; padding: 24px; }
.detail.show { display: block; }
.detail .hero { width: 100%; height: 400px; object-fit: cover; border-radius: 12px; }
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
</style>
</head>
<body>
<div class="grid" id="grid">
<div class="card" data-id="1">
<img src="https://picsum.photos/seed/1/800/600" style="view-transition-name: hero-1">
<p>Card 1</p>
</div>
<div class="card" data-id="2">
<img src="https://picsum.photos/seed/2/800/600" style="view-transition-name: hero-2">
<p>Card 2</p>
</div>
</div>
<div class="detail" id="detail">
<img class="hero" id="hero-img">
<button onclick="back()">← back</button>
</div>
<script>
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id
const img = card.querySelector('img')
// 详情页用同样的 view-transition-name
const detailImg = document.getElementById('hero-img')
detailImg.style.viewTransitionName = `hero-${id}`
detailImg.src = img.src.replace('800/600', '1600/800')
document.startViewTransition(() => {
document.getElementById('grid').style.display = 'none'
document.getElementById('detail').classList.add('show')
})
})
})
function back() {
document.startViewTransition(() => {
document.getElementById('detail').classList.remove('show')
document.getElementById('grid').style.display = 'grid'
})
}
</script>
</body>
</html>
打开 Chrome 看,点 card 图片"飞"成大图 + 反向退出。零依赖。
效果
- 列表 → 详情过渡从"瞬间切换"变"流畅 morph",体验提升明显
- bundle 减去 ~30KB(不装 Framer Motion)
- 与现有 framework 集成只需 5-10 行 hook 代码
- 不支持的浏览器 graceful fallback 到无动画
跨文档 transitions(MPA)
Chrome 126+ 支持多页面 navigation 时的 view transition,
完全不需要 SPA。在两个 HTML 页面里 CSS 加:
@view-transition {
navigation: auto;
}
浏览器 navigate 时自动应用 view transition。SSR / 静态站也能有
SPA 般的过渡。
踩过的坑
-
viewport 外的元素不参与:如果列表卡片在视口外,detail 元素
morph 起点错误。让点击的卡片先 scrollIntoView 再触发 transition。 -
同名元素冲突:CSS 报"view-transition-name must be unique"。
不要在 React map 里所有 item 用同 name。 -
transition 期间 click 失效:transition 时整页冻结。复杂交互前
注意 transition duration 别太长。 -
iOS Safari 18 之前不支持:移动端覆盖度 2024 末期才到 80%+。
渐进增强是必须的。 -
图片 src 切换 + view transition 并发:浏览器对新 image 还没
下载完时 transition 显示空白。<img>加decoding="sync"或
pre-cache 大图。
登录后参与评论。