列表→详情页的过渡动画不用手写坐标了:view-transition-name 动态命名实战
页面里一堆卡片,点击其中一个跳到详情页,过渡动画怎么做?
以前这个需求靠两个办法:一是整个页面一起淡入淡出,毫无细节;二是自己写 JS 算位置、拼动画,代码比业务逻辑还多。
View Transitions API 解决了单页应用里的过渡,但跨文档跳转(MPA)这件事,浏览器一直没吭声。直到 pageswap 和 pagereveal 这两个事件出来,局面才彻底变了。
**这篇文章解决的问题是:怎么让「点击卡片 A」到「详情页卡片 A」的过渡动画,用几行代码接起来,而不是手写坐标计算。**
—
## view-transition-name 是什么
每个参与过渡动画的 DOM 元素,都有一个 view-transition-name 属性。设了同一个名字的元素,在过渡期间会被当作「同一个东西」的前后状态来处理——浏览器自动捕捉旧位置的快照,然后把它「飞」到新位置去。
“`css
/* 给卡片上的标题设一个名字 */
.card-title {
view-transition-name: product-title;
}
“`
只要详情页里对应的标题也设了同一个名字,浏览器就知道它们是「同一个东西」,自动生成从卡片位置飞到详情顶部的过渡动画。整个过程零 JS 动画代码。
—
## 问题是:列表里的每个卡片不能写死同一个名字
列表页一般长这样:
“`html
iPhone 16
MacBook Pro
AirPods Pro
“`
如果每个 .card-title 都写死 view-transition-name: product-title,浏览器会一脸懵——三个元素叫同一个名字,过渡的时候不知道该用哪个,动画就乱套了。
**解决方案:用 JS 动态设置,每个元素按需取唯一的名字,用完再清掉。**
—
## 方案一:动态绑定 view-transition-name
核心思路:在点击卡片时,给被点击的那个元素设置 view-transition-name,跳转到详情页后,详情页也对应设置同名属性。动画结束后,两个地方都清掉名字,避免影响 BFCache(后退缓存)里的页面状态。
列表页的点击逻辑:
“`js
document.querySelectorAll(‘.card’).forEach(card => {
card.addEventListener(‘click’, async (e) => {
const title = card.querySelector(‘.card-title’);
const uniqueName = `card-title-${Date.now()}`;
// 设一个临时唯一名字
title.style.viewTransitionName = uniqueName;
// 导航
const targetUrl = card.dataset.href;
await fetch(targetUrl, { navigationAPI: true });
// 动画结束后清理
// (navigationAPI 方式需要在 startViewTransition 里包装)
});
});
“`
但这个写法有个问题:导航发出后,旧页面的 view-transition-name 还在,如果用户按了后退键回到列表页,那个名字还挂着,卡片再点就会出问题。
正确做法是**等 viewTransition.finished 再清**:
“`js
document.querySelectorAll(‘.card’).forEach(card => {
card.addEventListener(‘click’, async (e) => {
e.preventDefault();
const title = card.querySelector(‘.card-title’);
const uniqueName = `card-title-${Date.now()}`;
title.style.viewTransitionName = uniqueName;
const targetUrl = card.dataset.href;
// 导航并包装进 View Transition
const transition = document.startViewTransition(() => {
window.location.href = targetUrl;
});
// 等动画做完,清掉名字
await transition.finished;
title.style.viewTransitionName = ‘none’;
});
});
“`
—
## 方案二:用 pageswap 事件在旧页面动态设名字
如果你不想在点击时污染 JS 逻辑,可以用 pageswap 事件,在**即将离开旧页面时**才设置名字。这样点击逻辑和动画逻辑完全解耦:
“`js
window.addEventListener(‘pageswap’, async (e) => {
if (!e.viewTransition) return;
const from = e.activation.from;
if (!from || !from.url) return;
// 找到旧页面上被点击的元素
const clickedCard = document.querySelector(‘.card:focus-within’)
|| document.querySelector(‘.card.clicked’);
if (clickedCard) {
const title = clickedCard.querySelector(‘.card-title’);
const uniqueName = `card-title-${Date.now()}`;
title.style.viewTransitionName = uniqueName;
// 存一份到 sessionStorage,详情页要用
sessionStorage.setItem(‘vtName’, uniqueName);
sessionStorage.setItem(‘vtFrom’, ‘list’);
}
});
“`
详情页(目标页)用 pagereveal 来接:
“`js
window.addEventListener(‘pagereveal’, async (e) => {
if (!e.viewTransition) return;
const vtName = sessionStorage.getItem(‘vtName’);
if (!vtName) return;
// 等 transition ready 后给详情页对应元素设上同样的名字
await e.viewTransition.ready;
const detailTitle = document.querySelector(‘.detail-title’);
if (detailTitle) {
detailTitle.style.viewTransitionName = vtName;
}
// 清理
sessionStorage.removeItem(‘vtName’);
});
“`
这套玩法的精髓在于:**名字的设置和清理都在页面生命周期里自然发生**,不需要在点击时协同两个页面的代码。
—
## 踩过的两个坑
**坑一:BFCache 里的名字没清干净**
如果用户按了后退键回到列表页,页面是从 BFCache 恢复的,之前设的 view-transition-name 还挂着。后续再点同一张卡片,浏览器会把两个状态都当作「同一个」来处理,动画直接抽搐。
解法:列表页初始化时主动清所有 .card-title 的 view-transition-name:
“`js
document.querySelectorAll(‘.card-title’).forEach(el => {
el.style.viewTransitionName = ‘none’;
});
“`
把这个放在 script 同步执行,不要等 DOMContentLoaded,否则 BFCache 恢复时可能会抢在清名字之前就触发动画。
**坑二:uniqueName 用 Date.now() 有小概率碰撞**
如果用户手速快,两次点击在同一个毫秒发出,名字会重复。实际场景几乎不可能发生,但如果要严谨,可以用 crypto.randomUUID():
“`js
const uniqueName = crypto.randomUUID();
“`
生成的结果类似 8a3f4c2d-1b6e-4f9a-8c7d-2e5f1a3b4c5d,碰撞概率约等于零。
—
## 效果预览
假设有这样的布局:
“`html
iPhone 16
iPhone 16

“`
对应的 CSS:
“`css
/* 列表页 */
.card-title { view-transition-name: none; }
/* 详情页 */
.detail-title {
view-transition-name: var(–vt-name);
}
.detail-img {
view-transition-name: product-image;
}
“`
注意 .detail-img 用的是固定名字 product-image,因为每张详情页只展示一个产品,不会像列表页那样有多个同名元素。
—
## 结论
view-transition-name 动态设置的核心逻辑就三步:
1. **点击时或 pageswap 事件里**:给旧页面被点击的元素设一个唯一名字
2. **pagereveal 事件里**:给详情页对应元素设上同样的名字
3. **动画结束后**:两边都清成 none
不需要任何动画库,不需要算坐标,浏览器原生搞定。如果你的项目还没开始用 View Transitions API,建议从详情页过渡这个场景入手——它是用户感知最明显的场景,也是这套方案最干净的实现入口。
—
**下一步**:把列表页和详情页的 opt-in @view-transition 加上(两个页面都需要),然后把上面的 pageswap/pagereveal 代码粘进去,跑一遍点击,看看浏览器给你「飞」出来的效果。
Chrome 126+、Safari 18+ 支持 cross-document View Transitions。如果你的用户里 Safari 占比不低,需要做个 feature detect:
“`js
if (!document.startViewTransition) {
// fallback:直接跳转,不做过渡
window.location.href = targetUrl;
return;
}
“`
评论区
登录后可评论。