956 lines
32 KiB
Markdown
956 lines
32 KiB
Markdown
---
|
||
|
||
# React View Transitions
|
||
|
||
**版本 1.0.0**
|
||
Vercel Engineering
|
||
2026 年 3 月
|
||
|
||
> **注意:**
|
||
> 本文档主要供代理和 LLM 在 React 应用中实现视图过渡时遵循。人类也可能觉得有用,
|
||
> 但这里的指导针对 AI 辅助工作流的自动化和一致性进行了优化。
|
||
|
||
---
|
||
|
||
## 摘要
|
||
|
||
使用 React 的 View Transition API 实现流畅、原生感动画的指南。涵盖 `<ViewTransition>` 组件、`addTransitionType`、CSS 视图过渡伪元素、共享元素过渡、Suspense 揭示、列表重排、定向导航和 Next.js 集成。包含逐步实现工作流、即用型 CSS 动画配方以及常见错误警告。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [核心参考](#when-to-animate)
|
||
- [何时使用动画](#when-to-animate)
|
||
- [可用性](#availability)
|
||
- [核心概念](#core-concepts)
|
||
- [使用视图过渡类设置样式](#styling-with-view-transition-classes)
|
||
- [过渡类型](#transition-types)
|
||
- [共享元素过渡](#shared-element-transitions)
|
||
- [常见模式](#common-patterns)
|
||
- [多个 VT 如何交互](#how-multiple-vts-interact)
|
||
- [Next.js 集成](#nextjs-integration)
|
||
- [无障碍](#accessibility)
|
||
2. [实现工作流](#implementation-workflow)
|
||
- [第一步:审计应用](#step-1-audit-the-app)
|
||
- [第二步:添加 CSS 配方](#step-2-add-css-recipes)
|
||
- [第三步:隔离持久元素](#step-3-isolate-persistent-elements)
|
||
- [第四步:添加定向页面过渡](#step-4-add-directional-page-transitions)
|
||
- [第五步:添加 Suspense 揭示](#step-5-add-suspense-reveals)
|
||
- [第六步:添加共享元素过渡](#step-6-add-shared-element-transitions)
|
||
- [第七步:验证每个导航路径](#step-7-verify-each-navigation-path)
|
||
- [常见错误](#common-mistakes)
|
||
3. [模式与指南](#patterns-and-guidelines)
|
||
4. [CSS 动画配方](#css-animation-recipes)
|
||
5. [Next.js 中的视图过渡](#view-transitions-in-nextjs)
|
||
|
||
---
|
||
|
||
使用浏览器的原生 `document.startViewTransition` 在 UI 状态之间进行动画切换。用 `<ViewTransition>` 声明*什么*,用 `startTransition` / `useDeferredValue` / `Suspense` 触发*何时*,用 CSS 类控制*如何*。不支持的浏览器会优雅地跳过动画。
|
||
|
||
## 何时使用动画
|
||
|
||
每个 `<ViewTransition>` 都应传达空间关系或连续性。如果你说不出它传达了什么,就不要添加。
|
||
|
||
按此顺序实现此列表中**所有**适用的模式:
|
||
|
||
| 优先级 | 模式 | 它所传达的含义 |
|
||
|----------|---------|---------------------|
|
||
| 1 | **共享元素**(`name`) | "同一个东西——更深入了" |
|
||
| 2 | **Suspense 揭示** | "数据已加载" |
|
||
| 3 | **列表标识**(逐项 `key`) | "同样的项目,新排列" |
|
||
| 4 | **状态变化**(`enter`/`exit`) | "某物出现/消失了" |
|
||
| 5 | **路由变化**(布局级别) | "要去一个新地方" |
|
||
|
||
这是一个实现顺序,而不是"选一个"的列表。实现应用中适用的每一种模式。只有应用没有该模式的用例时才跳过。
|
||
|
||
### 选择动画风格
|
||
|
||
| 上下文 | 动画 | 原因 |
|
||
|---------|-----------|-----|
|
||
| 层级导航(列表→详情) | 类型键控的 `nav-forward` / `nav-back` | 传达空间深度 |
|
||
| 横向导航(标签页间切换) | 裸 `<ViewTransition>`(淡入淡出)或 `default="none"` | 无需传达深度 |
|
||
| Suspense 揭示 | `enter`/`exit` 字符串属性 | 内容正在到达 |
|
||
| 重新验证/后台刷新 | `default="none"` | 静默——无需动画 |
|
||
|
||
将定向滑动保留给层级导航(列表→详情)和有序序列(上一张/下一张照片、轮播、分页结果)。对于有序序列,方向传达位置:"下一张"从右侧滑入,"上一张"从左侧滑入。横向/无序导航(标签页间切换)不应使用定向滑动——它会错误地暗示空间深度。
|
||
|
||
---
|
||
|
||
## 可用性
|
||
|
||
- **Next.js:** 请**不要**安装 `react@canary`——App Router 已在内部捆绑了 React canary。`ViewTransition` 开箱即用。`npm ls react` 可能显示一个看起来像稳定版的版本;这是预期的。
|
||
- **没有 Next.js:** 安装 `react@canary react-dom@canary`(`ViewTransition` 不在稳定的 React 中)。
|
||
- 浏览器支持:Chromium 111+、Firefox 144+、Safari 18.2+。优雅降级。
|
||
|
||
---
|
||
|
||
## 核心概念
|
||
|
||
### `<ViewTransition>` 组件
|
||
|
||
```jsx
|
||
import { ViewTransition } from 'react';
|
||
|
||
<ViewTransition>
|
||
<Component />
|
||
</ViewTransition>
|
||
```
|
||
|
||
React 会自动分配唯一的 `view-transition-name` 并在后台调用 `document.startViewTransition`。永远不要自己调用 `startViewTransition`。
|
||
|
||
### 动画触发器
|
||
|
||
| 触发器 | 触发时机 |
|
||
|---------|--------------|
|
||
| **enter** | VT 在过渡期间首次被插入 |
|
||
| **exit** | VT 在过渡期间首次被移除 |
|
||
| **update** | VT 内部的 DOM 发生变化。有嵌套 VT 时,变化应用于最内层的 VT |
|
||
| **share** | 命名 VT 卸载,同时在同一个过渡中另一个同名 VT 挂载 |
|
||
|
||
只有 `startTransition`、`useDeferredValue` 或 `Suspense` 能激活 VT。普通的 `setState` 不会产生动画。
|
||
|
||
### 关键放置规则
|
||
|
||
VT 只有在出现在**任何 DOM 节点之前**时才会激活 enter/exit:
|
||
|
||
```jsx
|
||
// 有效
|
||
<ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition>
|
||
|
||
// 无效——div 包裹了 VT
|
||
<div><ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition></div>
|
||
```
|
||
|
||
---
|
||
|
||
## 使用视图过渡类设置样式
|
||
|
||
值:`"auto"`(浏览器交叉淡入淡出)、`"none"`(禁用)、`"class-name"`(自定义 CSS),或 `{ [type]: value }` 用于特定类型的动画。
|
||
|
||
```jsx
|
||
<ViewTransition default="none" enter="slide-in" exit="slide-out" share="morph" />
|
||
```
|
||
|
||
如果 `default` 为 `"none"`,则所有触发器都关闭,除非显式列出。
|
||
|
||
### CSS 伪元素
|
||
|
||
- `::view-transition-old(.class)` — 离开的快照
|
||
- `::view-transition-new(.class)` — 进入的快照
|
||
- `::view-transition-group(.class)` — 容器
|
||
- `::view-transition-image-pair(.class)` — 旧 + 新配对
|
||
|
||
---
|
||
|
||
## 过渡类型
|
||
|
||
使用 `addTransitionType` 标记过渡,以便 VT 可以选择不同的动画。多次调用可以叠加类型——树中不同的 VT 对不同的类型做出响应:
|
||
|
||
```jsx
|
||
startTransition(() => {
|
||
addTransitionType('nav-forward');
|
||
addTransitionType('select-item');
|
||
router.push('/detail/1');
|
||
});
|
||
```
|
||
|
||
将类型映射到 CSS 类。适用于 `enter`、`exit`**以及**`share`:
|
||
|
||
```jsx
|
||
<ViewTransition
|
||
enter={{ 'nav-forward': 'slide-from-right', 'nav-back': 'slide-from-left', default: 'none' }}
|
||
exit={{ 'nav-forward': 'slide-to-left', 'nav-back': 'slide-to-right', default: 'none' }}
|
||
share={{ 'nav-forward': 'morph-forward', 'nav-back': 'morph-back', default: 'morph' }}
|
||
default="none"
|
||
>
|
||
<Page />
|
||
</ViewTransition>
|
||
```
|
||
|
||
`enter` 和 `exit` 不必对称。例如,淡入但定向滑出:
|
||
|
||
```jsx
|
||
<ViewTransition
|
||
enter={{ 'nav-forward': 'fade-in', 'nav-back': 'fade-in', default: 'none' }}
|
||
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
|
||
default="none"
|
||
>
|
||
```
|
||
|
||
**TypeScript:** `ViewTransitionClassPerType` 需要 `default` 键。
|
||
|
||
### `router.back()` 和浏览器返回按钮
|
||
|
||
`router.back()` 和浏览器的前进/后退按钮**不会**触发视图过渡(`popstate` 是同步的,与 `startViewTransition` 不兼容)。请改用带显式 URL 的 `router.push()`。
|
||
|
||
### 类型与 Suspense
|
||
|
||
类型在导航期间可用,但**不**在后续的 Suspense 揭示期间可用(这是单独的过渡,没有类型)。页面级别的 enter/exit 使用类型映射;Suspense 揭示使用简单的字符串属性。
|
||
|
||
---
|
||
|
||
## 共享元素过渡
|
||
|
||
两个 VT 使用相同的 `name`——一个卸载,一个挂载——会创建一个共享元素变形效果:
|
||
|
||
```jsx
|
||
<ViewTransition name="hero-image">
|
||
<img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
|
||
</ViewTransition>
|
||
|
||
// 另一个视图——同名
|
||
<ViewTransition name="hero-image">
|
||
<img src="/full.jpg" />
|
||
</ViewTransition>
|
||
```
|
||
|
||
- 同一时间只能挂载一个具有给定 `name` 的 VT——请使用唯一名称。注意可复用组件:如果一个带有命名 VT 的组件同时在模态框/弹出层*和*页面中渲染,两者会同时挂载并破坏变形效果。要么让名称成为条件式的(通过属性传入),要么将命名 VT 移出共享组件,放入特定的消费者中。
|
||
- `share` 优先于 `enter`/`exit`。仔细思考每个导航路径:当没有配对形成时,`enter`/`exit` 会替代触发。考虑元素是否需要对那些路径设置回退动画。
|
||
- 永远不要在包含共享变形效果的页面上使用淡出退出——应使用定向滑动。
|
||
|
||
---
|
||
|
||
## 常见模式
|
||
|
||
### Enter/Exit
|
||
|
||
```jsx
|
||
{show && (
|
||
<ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>
|
||
)}
|
||
```
|
||
|
||
### 列表重排
|
||
|
||
```jsx
|
||
{items.map(item => (
|
||
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
|
||
))}
|
||
```
|
||
|
||
在 `startTransition` 内部触发。避免在列表和 VT 之间使用包装 `<div>`。
|
||
|
||
### 组合共享元素与列表标识
|
||
|
||
共享元素和列表标识是独立的事项——不要将两者混淆。当列表项包含共享元素时,使用两个嵌套的 `<ViewTransition>` 边界:
|
||
|
||
```jsx
|
||
{items.map(item => (
|
||
<ViewTransition key={item.id}> {/* 列表标识 */}
|
||
<Link href={`/items/${item.id}`}>
|
||
<ViewTransition name={`item-image-${item.id}`} share="morph"> {/* 共享元素 */}
|
||
<Image src={item.image} />
|
||
</ViewTransition>
|
||
<p>{item.name}</p>
|
||
</Link>
|
||
</ViewTransition>
|
||
))}
|
||
```
|
||
|
||
外层的 VT 处理列表重排/进入。内层的 VT 处理跨路由的共享元素变形。缺少任何一层都意味着该动画静默地不会发生。
|
||
|
||
### 使用 `key` 强制重新进入
|
||
|
||
```jsx
|
||
<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">
|
||
<ResultsGrid />
|
||
</ViewTransition>
|
||
```
|
||
|
||
**注意:** 包裹 `<Suspense>` 并用 key 重新挂载边界会触发重新请求。
|
||
|
||
### Suspense 回退到内容
|
||
|
||
简单交叉淡入淡出:
|
||
```jsx
|
||
<ViewTransition>
|
||
<Suspense fallback={<Skeleton />}><Content /></Suspense>
|
||
</ViewTransition>
|
||
```
|
||
|
||
定向揭示:
|
||
```jsx
|
||
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
|
||
<ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
|
||
</Suspense>
|
||
```
|
||
|
||
---
|
||
|
||
## 多个 VT 如何交互
|
||
|
||
每个匹配触发器的 VT 在单个 `document.startViewTransition` 中同时触发。处于**不同**过渡中的 VT 不会相互竞争。
|
||
|
||
### 多使用 `default="none"`
|
||
|
||
不加 `default="none"`,每个 VT 会在**每次**过渡时触发浏览器的交叉淡入淡出。始终使用 `default="none"` 并显式启用仅需要的触发器。
|
||
|
||
### 两种模式并存
|
||
|
||
**模式 A——定向滑动:** 每个页面上类型键控的 VT,在导航期间触发。
|
||
**模式 B——Suspense 揭示:** 简单字符串属性,在数据加载时触发(没有类型)。
|
||
|
||
它们可以并存,因为它们在不同的时刻触发。两者都设置 `default="none"` 可以防止交叉干扰。始终将 `enter` 与 `exit` 配对。将定向 VT 放在页面组件中,而不是布局中。
|
||
|
||
### 嵌套 VT 的限制
|
||
|
||
当父级 VT 退出时,其内部的嵌套 VT **不会**触发它们自己的 enter/exit——只有最外层的 VT 会执行动画。页面导航期间无法实现逐项交错动画。参见 [react#36135](https://github.com/facebook/react/pull/36135) 了解实验性的可选修复方案。
|
||
|
||
---
|
||
|
||
## Next.js 集成
|
||
|
||
请参见下面的 [Next.js 中的视图过渡](#view-transitions-in-nextjs) 章节。
|
||
|
||
---
|
||
|
||
## 无障碍
|
||
|
||
始终在全局样式表中添加减少动效的 CSS:
|
||
|
||
```css
|
||
@media (prefers-reduced-motion: reduce) {
|
||
::view-transition-old(*),
|
||
::view-transition-new(*),
|
||
::view-transition-group(*) {
|
||
animation-duration: 0s !important;
|
||
animation-delay: 0s !important;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 实现工作流
|
||
|
||
**按顺序执行以下步骤。** 从审计开始——不要跳过它。从下面的 CSS 配方章节复制 CSS 配方——不要自己编写动画 CSS。
|
||
|
||
## 第一步:审计应用
|
||
|
||
在编写任何代码之前,彻底扫描代码库。搜索:
|
||
|
||
- **每个 `<Link>` 和 `router.push`** ——打开包含它们的每个文件
|
||
- **每个 `<Suspense>` 边界** ——检查其回退渲染了什么
|
||
- **每个页面/路由组件** ——每个都需要决定 VT 放置位置
|
||
- **持久元素**(页眉、导航栏、侧边栏)——需要 `viewTransitionName` 隔离
|
||
- **源视图和目标视图上的共享视觉元素**
|
||
- **骨架屏到内容的控件对** ——如果回退渲染了一个也在真实内容中存在的控件,两者都需要匹配的 `viewTransitionName`
|
||
|
||
然后对每个导航进行分类,生成导航映射表:
|
||
|
||
```
|
||
| 路由 | 导航到 | 方向 | VT 模式 |
|
||
|-----------------|----------------------|--------------|-----------------------|
|
||
| / | /detail/[id] | 向前 | 定向滑动 |
|
||
| /detail/[id] | / | 返回 | 定向滑动 |
|
||
| /detail/[id] | /detail/[其他] | 顺序 | 定向滑动(有序上一张/下一张)或 key+share 交叉淡入淡出 |
|
||
| /tab/[a] | /tab/[b] | 横向 | key+share 交叉淡入淡出 |
|
||
| (Suspense) | (内容加载) | — | 向上滑动揭示 |
|
||
```
|
||
|
||
对于每个共享元素(`name` 属性),记录配对在哪里形成、在哪里不形成——这决定了你是否需要 `enter`/`exit` 作为 `share` 的备用方案。
|
||
|
||
## 第二步:添加 CSS 配方
|
||
|
||
将下面 CSS 动画配方章节中的**完整** CSS 配方集复制到你的全局样式表中。不要自己编写——这些配方处理了交错时间、运动模糊和减少动效。
|
||
|
||
## 第三步:隔离持久元素
|
||
|
||
```jsx
|
||
<header style={{ viewTransitionName: "site-header" }}>...</header>
|
||
```
|
||
|
||
```css
|
||
::view-transition-group(site-header) {
|
||
animation: none;
|
||
z-index: 100;
|
||
}
|
||
```
|
||
|
||
对于 `backdrop-blur`/`backdrop-filter`,请改用 backdrop-blur 变通方案。
|
||
|
||
## 第四步:添加定向页面过渡
|
||
|
||
```jsx
|
||
startTransition(() => {
|
||
addTransitionType('nav-forward');
|
||
router.push('/detail/1');
|
||
});
|
||
```
|
||
|
||
将每个**页面组件**(而不是布局)包裹在类型键控的 VT 中:
|
||
|
||
```jsx
|
||
<ViewTransition
|
||
enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
|
||
exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
|
||
default="none"
|
||
>
|
||
<div>...页面内容...</div>
|
||
</ViewTransition>
|
||
```
|
||
|
||
提取为可复用的组件,这样每个页面就不必重复类型映射:
|
||
|
||
```jsx
|
||
export function DirectionalTransition({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<ViewTransition
|
||
enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
|
||
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
|
||
default="none"
|
||
>
|
||
{children}
|
||
</ViewTransition>
|
||
);
|
||
}
|
||
```
|
||
|
||
**规则:** 始终将 `enter` 与 `exit` 配对。始终包含 `default: "none"`。放在页面组件中,而不是布局中。仅对层级导航或有序序列(上一张/下一张)使用定向滑动。
|
||
|
||
## 第五步:添加 Suspense 揭示
|
||
|
||
```jsx
|
||
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
|
||
<ViewTransition enter="slide-up" default="none"><AsyncContent /></ViewTransition>
|
||
</Suspense>
|
||
```
|
||
|
||
对内容 VT 使用 `default="none"`。使用简单的字符串属性(不是类型映射)——Suspense 解析没有类型。
|
||
|
||
## 第六步:添加共享元素过渡
|
||
|
||
```jsx
|
||
// 源视图
|
||
<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
|
||
<Image src={photo.src} ... />
|
||
</ViewTransition>
|
||
|
||
// 目标视图——同名
|
||
<ViewTransition name={`photo-${photo.id}`} share="morph">
|
||
<Image src={photo.src} ... />
|
||
</ViewTransition>
|
||
```
|
||
|
||
当列表项包含共享元素时,组合两种模式——两个独立的层:
|
||
|
||
```jsx
|
||
{items.map(item => (
|
||
<ViewTransition key={item.id}> {/* 列表标识 */}
|
||
<Link href={`/detail/${item.id}`}>
|
||
<ViewTransition name={`item-${item.id}`} share="morph" default="none"> {/* 共享元素 */}
|
||
<Image src={item.image} ... />
|
||
</ViewTransition>
|
||
</Link>
|
||
</ViewTransition>
|
||
))}
|
||
```
|
||
|
||
外层的 VT 处理列表重排/进入。内层的 VT 处理跨路由的共享元素变形。缺少任何一层都意味着该动画静默地不会发生。
|
||
|
||
**规则:** 名称必须全局唯一。在列表侧的共享元素上添加 `default="none"`。
|
||
|
||
## 第七步:验证每个导航路径
|
||
|
||
逐行检查第一步导航映射表中的每一项:
|
||
|
||
- VT 是挂载/卸载,还是保持挂载(同一路由)?
|
||
- 对于命名 VT:是否形成了共享配对?如果没有,`enter`/`exit` 是否提供了备用方案?
|
||
- `default="none"` 是否阻止了你实际想要的动画?
|
||
- 持久元素是否保持静态?
|
||
- Suspense 揭示的动画是否独立于定向导航?
|
||
|
||
---
|
||
|
||
## 常见错误
|
||
|
||
- **没有 `default="none"` 的裸 VT** ——每次过渡都触发交叉淡入淡出
|
||
- **布局中的定向 VT** ——布局持续存在,enter/exit 不会在路由变化时触发
|
||
- **带共享变形效果的淡出退出** ——与变形冲突,应使用定向滑动
|
||
- **编写自定义动画 CSS** ——使用配方
|
||
- **类型键控对象中缺少 `default: "none"`** ——TypeScript 要求它,回退是 `"auto"`
|
||
- **Suspense 揭示上的类型映射** ——Suspense 解析没有类型,应使用字符串属性
|
||
- **使用原始 `viewTransitionName` CSS 触发动画** ——只有当 `<ViewTransition>` 组件在树中时,React 才会启动视图过渡。裸 `viewTransitionName` 用于隔离元素,而不是触发动画。
|
||
- **同一路由导航的 `update` 触发器** ——嵌套的 VT 会从父级抢走突变。应改用 `key` + `name` + `share`。
|
||
- **可复用组件中的命名 VT** ——如果一个带有命名 VT 的组件同时在模态框/弹出层*和*页面中渲染,两者会同时挂载并破坏变形效果。要么让名称成为条件式的,要么将其移到特定的消费者中。
|
||
- **使用 `router.back()` 进行返回导航** ——`router.back()` 触发同步的 `popstate`,与视图过渡不兼容。请使用带显式 URL 的 `router.push()`。
|
||
|
||
有关 Next.js 特定的步骤,请参见下面的 Next.js 章节。
|
||
|
||
---
|
||
|
||
# 模式与指南
|
||
|
||
## 使用 `useDeferredValue` 的可搜索网格
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';
|
||
|
||
export default function SearchableGrid({ itemsPromise }) {
|
||
const [search, setSearch] = useState('');
|
||
const deferredSearch = useDeferredValue(search);
|
||
|
||
return (
|
||
<>
|
||
<input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
|
||
<ViewTransition>
|
||
<Suspense fallback={<GridSkeleton />}>
|
||
<ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />
|
||
</Suspense>
|
||
</ViewTransition>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
在延迟列表中对每个项目使用命名 VT 会在每次按键时触发交叉淡入淡出。使用 `default="none"` 修复。
|
||
|
||
## 使用 `startTransition` 的卡片展开/折叠
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useState, useRef, startTransition, ViewTransition } from 'react';
|
||
|
||
export default function ItemGrid({ items }) {
|
||
const [expandedId, setExpandedId] = useState(null);
|
||
const scrollRef = useRef(0);
|
||
|
||
return expandedId ? (
|
||
<ViewTransition enter="slide-in" name={`item-${expandedId}`}>
|
||
<ItemDetail
|
||
item={items.find(i => i.id === expandedId)}
|
||
onClose={() => {
|
||
startTransition(() => {
|
||
setExpandedId(null);
|
||
setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);
|
||
});
|
||
}}
|
||
/>
|
||
</ViewTransition>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{items.map(item => (
|
||
<ViewTransition key={item.id} name={`item-${item.id}`}>
|
||
<ItemCard
|
||
item={item}
|
||
onSelect={() => {
|
||
scrollRef.current = window.scrollY;
|
||
startTransition(() => setExpandedId(item.id));
|
||
}}
|
||
/>
|
||
</ViewTransition>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
## 不重新挂载的交叉淡入淡出
|
||
|
||
省略 `key` 以触发 update(交叉淡入淡出)而不是 exit + enter。避免 Suspense 重新挂载:
|
||
|
||
```jsx
|
||
<ViewTransition><TabPanel tab={activeTab} /></ViewTransition>
|
||
```
|
||
|
||
## 将元素与父级动画隔离
|
||
|
||
持久元素会被捕获到页面的过渡快照中。使用 `viewTransitionName` 修复:
|
||
|
||
```jsx
|
||
<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
|
||
```
|
||
|
||
```css
|
||
::view-transition-group(persistent-nav) { animation: none; z-index: 100; }
|
||
```
|
||
|
||
浮动元素(弹出层、工具提示)同理。全局修复:`::view-transition-group(*) { z-index: 100; }`
|
||
|
||
## 骨架屏与内容之间的共享控件
|
||
|
||
为匹配的控件赋予相同的 `viewTransitionName`。不要在 `<ViewTransition>` 内部的根 DOM 节点上放置手动的 `viewTransitionName`。
|
||
|
||
## 可复用的动画折叠
|
||
|
||
```jsx
|
||
function AnimatedCollapse({ open, children }) {
|
||
if (!open) return null;
|
||
return <ViewTransition enter="expand-in" exit="collapse-out">{children}</ViewTransition>;
|
||
}
|
||
```
|
||
|
||
## 使用 Activity 保持状态
|
||
|
||
```jsx
|
||
<Activity mode={isVisible ? 'visible' : 'hidden'}>
|
||
<ViewTransition enter="slide-in" exit="slide-out"><Sidebar /></ViewTransition>
|
||
</Activity>
|
||
```
|
||
|
||
## 使用 `useOptimistic` 排除元素
|
||
|
||
`useOptimistic` 的值在快照之前更新,从而将它们排除在动画之外。控件使用此方法;动画内容使用已提交的状态。
|
||
|
||
---
|
||
|
||
## 视图过渡事件
|
||
|
||
通过 `onEnter`、`onExit`、`onUpdate`、`onShare` 进行命令式控制。始终返回清理函数。`onShare` 优先。
|
||
|
||
```jsx
|
||
<ViewTransition
|
||
onEnter={(instance, types) => {
|
||
const anim = instance.new.animate(
|
||
[{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
|
||
{ duration: 300, easing: 'ease-out' }
|
||
);
|
||
return () => anim.cancel();
|
||
}}
|
||
>
|
||
<Component />
|
||
</ViewTransition>
|
||
```
|
||
|
||
`instance`:`.old`、`.new`、`.group`、`.imagePair`、`.name`
|
||
|
||
---
|
||
|
||
## 动画时间
|
||
|
||
| 交互 | 时长 |
|
||
|------------|----------|
|
||
| 直接切换 | 100–200ms |
|
||
| 路由过渡 | 150–250ms |
|
||
| Suspense 揭示 | 200–400ms |
|
||
| 共享元素变形 | 300–500ms |
|
||
|
||
---
|
||
|
||
## 故障排查
|
||
|
||
**VT 未激活:** 确保 VT 出现在任何 DOM 节点之前。确保使用了 `startTransition`。
|
||
|
||
**"两个同名 VT":** 名称必须全局唯一。使用 ID。
|
||
|
||
**`router.back()` 和浏览器前进/后退跳过动画:** 请改用带显式 URL 的 `router.push()`。
|
||
|
||
**只有更新会动画:** 没有 `<Suspense>` 时,React 会将交换视为更新。条件式地渲染 VT 本身,或将其包裹在 `<Suspense>` 中。
|
||
|
||
**布局 VT 阻止页面 VT 动画:** 嵌套 VT 永远不会在父级 VT 内部触发 enter/exit。如果你的布局有一个包裹 `{children}` 的 VT,页面级别的 enter/exit 将静默地无法工作。移除布局 VT。
|
||
|
||
**TS 错误 "Property 'default' is missing":** 类型键控对象需要 `default` 键。
|
||
|
||
**Backdrop-blur 闪烁:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`。
|
||
|
||
**`border-radius` 丢失:** 直接将 `border-radius` 应用于被捕获的元素。
|
||
|
||
**批处理:** 动画期间的多次更新会被批处理(A→B→C→D 变成 B→D)。
|
||
|
||
---
|
||
|
||
# CSS 动画配方
|
||
|
||
用于 `<ViewTransition>` 属性的即用型 CSS。复制到全局样式表中。
|
||
|
||
## 时间变量
|
||
|
||
```css
|
||
:root {
|
||
--duration-exit: 150ms;
|
||
--duration-enter: 210ms;
|
||
--duration-move: 400ms;
|
||
}
|
||
```
|
||
|
||
### 共享关键帧
|
||
|
||
```css
|
||
@keyframes fade {
|
||
from { filter: blur(3px); opacity: 0; }
|
||
to { filter: blur(0); opacity: 1; }
|
||
}
|
||
|
||
@keyframes slide {
|
||
from { translate: var(--slide-offset); }
|
||
to { translate: 0; }
|
||
}
|
||
|
||
@keyframes slide-y {
|
||
from { transform: translateY(var(--slide-y-offset, 10px)); }
|
||
to { transform: translateY(0); }
|
||
}
|
||
```
|
||
|
||
## 淡入淡出
|
||
|
||
```css
|
||
::view-transition-old(.fade-out) {
|
||
animation: var(--duration-exit) ease-in fade reverse;
|
||
}
|
||
::view-transition-new(.fade-in) {
|
||
animation: var(--duration-enter) ease-out var(--duration-exit) both fade;
|
||
}
|
||
```
|
||
|
||
## 滑动(垂直)
|
||
|
||
```css
|
||
::view-transition-old(.slide-down) {
|
||
animation:
|
||
var(--duration-exit) ease-out both fade reverse,
|
||
var(--duration-exit) ease-out both slide-y reverse;
|
||
}
|
||
::view-transition-new(.slide-up) {
|
||
animation:
|
||
var(--duration-enter) ease-in var(--duration-exit) both fade,
|
||
var(--duration-move) ease-in both slide-y;
|
||
}
|
||
```
|
||
|
||
## 定向导航
|
||
|
||
### 单类方式
|
||
|
||
```css
|
||
::view-transition-old(.nav-forward) {
|
||
--slide-offset: -60px;
|
||
animation:
|
||
var(--duration-exit) ease-in both fade reverse,
|
||
var(--duration-move) ease-in-out both slide reverse;
|
||
}
|
||
::view-transition-new(.nav-forward) {
|
||
--slide-offset: 60px;
|
||
animation:
|
||
var(--duration-enter) ease-out var(--duration-exit) both fade,
|
||
var(--duration-move) ease-in-out both slide;
|
||
}
|
||
|
||
::view-transition-old(.nav-back) {
|
||
--slide-offset: 60px;
|
||
animation:
|
||
var(--duration-exit) ease-in both fade reverse,
|
||
var(--duration-move) ease-in-out both slide reverse;
|
||
}
|
||
::view-transition-new(.nav-back) {
|
||
--slide-offset: -60px;
|
||
animation:
|
||
var(--duration-enter) ease-out var(--duration-exit) both fade,
|
||
var(--duration-move) ease-in-out both slide;
|
||
}
|
||
```
|
||
|
||
### 独立的 Enter/Exit 类
|
||
|
||
```css
|
||
::view-transition-new(.slide-from-right) {
|
||
--slide-offset: 60px;
|
||
animation:
|
||
var(--duration-enter) ease-out var(--duration-exit) both fade,
|
||
var(--duration-move) ease-in-out both slide;
|
||
}
|
||
::view-transition-old(.slide-to-left) {
|
||
--slide-offset: -60px;
|
||
animation:
|
||
var(--duration-exit) ease-in both fade reverse,
|
||
var(--duration-move) ease-in-out both slide reverse;
|
||
}
|
||
|
||
::view-transition-new(.slide-from-left) {
|
||
--slide-offset: -60px;
|
||
animation:
|
||
var(--duration-enter) ease-out var(--duration-exit) both fade,
|
||
var(--duration-move) ease-in-out both slide;
|
||
}
|
||
::view-transition-old(.slide-to-right) {
|
||
--slide-offset: 60px;
|
||
animation:
|
||
var(--duration-exit) ease-in both fade reverse,
|
||
var(--duration-move) ease-in-out both slide reverse;
|
||
}
|
||
```
|
||
|
||
## 共享元素变形
|
||
|
||
```css
|
||
::view-transition-group(.morph) {
|
||
animation-duration: var(--duration-move);
|
||
}
|
||
::view-transition-image-pair(.morph) {
|
||
animation-name: via-blur;
|
||
}
|
||
@keyframes via-blur {
|
||
30% { filter: blur(3px); }
|
||
}
|
||
```
|
||
|
||
**注意:** 共享元素过渡使用光栅快照。对于尺寸差异显著的文本(例如 `<h3>` → `<h1>`),旧快照会被放大,产生可见的鬼影伪像。请对文本共享元素使用 `text-morph`。
|
||
|
||
## 文本变形
|
||
|
||
通过隐藏旧快照并以完整分辨率显示新文本来避免文本的光栅缩放伪像:
|
||
|
||
```css
|
||
::view-transition-group(.text-morph) {
|
||
animation-duration: var(--duration-move);
|
||
}
|
||
::view-transition-old(.text-morph) {
|
||
display: none;
|
||
}
|
||
::view-transition-new(.text-morph) {
|
||
animation: none;
|
||
object-fit: none;
|
||
object-position: left top;
|
||
}
|
||
```
|
||
|
||
## 缩放
|
||
|
||
```css
|
||
::view-transition-old(.scale-out) {
|
||
animation: var(--duration-exit) ease-in scale-down;
|
||
}
|
||
::view-transition-new(.scale-in) {
|
||
animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;
|
||
}
|
||
@keyframes scale-down {
|
||
from { transform: scale(1); opacity: 1; }
|
||
to { transform: scale(0.85); opacity: 0; }
|
||
}
|
||
@keyframes scale-up {
|
||
from { transform: scale(0.85); opacity: 0; }
|
||
to { transform: scale(1); opacity: 1; }
|
||
}
|
||
```
|
||
|
||
## 持久元素隔离
|
||
|
||
```css
|
||
::view-transition-group(persistent-nav) {
|
||
animation: none;
|
||
z-index: 100;
|
||
}
|
||
```
|
||
|
||
### Backdrop-Blur 变通方案
|
||
|
||
```css
|
||
::view-transition-old(persistent-nav) { display: none; }
|
||
::view-transition-new(persistent-nav) { animation: none; }
|
||
```
|
||
|
||
## 减少动效
|
||
|
||
```css
|
||
@media (prefers-reduced-motion: reduce) {
|
||
::view-transition-old(*),
|
||
::view-transition-new(*),
|
||
::view-transition-group(*) {
|
||
animation-duration: 0s !important;
|
||
animation-delay: 0s !important;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# Next.js 中的视图过渡
|
||
|
||
## 设置
|
||
|
||
```js
|
||
// next.config.js
|
||
experimental: { viewTransition: true }
|
||
```
|
||
|
||
将每个 `<Link>` 导航包裹在 `document.startViewTransition` 中。使用 `default="none"` 防止竞争动画。**不要**安装 `react@canary`——App Router 已将其捆绑。
|
||
|
||
## Next.js 实现补充
|
||
|
||
**第二步之后:** 启用实验性标志。
|
||
|
||
**第四步:** 在 `<Link>` 上使用 `transitionTypes`(如果可用——请参见下面的可用性说明):
|
||
```tsx
|
||
<Link href="/photo/1" transitionTypes={["nav-forward"]}>查看</Link>
|
||
<Link href="/" transitionTypes={["nav-back"]}>返回</Link>
|
||
```
|
||
|
||
**第六步之后:** 对于同一路由的动态段,使用 `key` + `name` + `share` 模式。
|
||
|
||
## 布局级别的 ViewTransition
|
||
|
||
如果页面有自己的 VT,不要在包裹 `{children}` 的布局中添加 VT——嵌套 VT 永远不会在父级 VT 内部触发 enter/exit,因此页面级别的 enter/exit 将静默地无法工作。完全移除布局 VT。只有当页面本身没有 VT 时,布局中的裸 VT 才有效。布局在导航间持续存在——不要在布局中使用类型键控映射。
|
||
|
||
## `transitionTypes` 属性
|
||
|
||
可在 Server Components 中使用,无需包装器:
|
||
```tsx
|
||
<Link href="/products/1" transitionTypes={['nav-forward']}>查看</Link>
|
||
```
|
||
|
||
**可用性:** 需要 `experimental.viewTransition: true`。在 Next.js 15+ canary 版本和 Next.js 16+ 中可用。如果不可用,请使用 `startTransition` + `addTransitionType` + `router.push()`。检查方法:`grep -r "transitionTypes" node_modules/next/dist/`。将手动的 `startTransition` 保留给非链接交互。
|
||
|
||
## `loading.tsx` 作为 Suspense 边界
|
||
|
||
Next.js 的 `loading.tsx` 文件是隐式的 `<Suspense>` 边界。在 `loading.tsx` 中将骨架屏包裹在 `<ViewTransition exit="...">` 中,在页面中将内容包裹在 `<ViewTransition enter="..." default="none">` 中。这是显式 `<Suspense fallback={...}>` 的 Next.js 惯用等价形式。规则相同:使用简单的字符串属性(不是类型映射),因为 Suspense 揭示在没有过渡类型的情况下触发。
|
||
|
||
## 使用 `router.replace` 的服务端筛选
|
||
|
||
对于通过 URL 参数在服务端重新渲染的搜索/排序/筛选,使用 `startTransition` + `router.replace`。VT 会激活,因为更新在 `startTransition` 内部。包裹在 `<ViewTransition key={item.id}>` 中的列表项会动画显示重排。这是客户端 `useDeferredValue` 模式的服务端组件替代方案。
|
||
|
||
## 双层模式(定向 + Suspense)
|
||
|
||
定向滑动 + Suspense 揭示可以并存,因为它们在不同的时刻触发。将定向 VT 放在**页面组件**中(而不是布局):
|
||
|
||
```tsx
|
||
<ViewTransition
|
||
enter={{ "nav-forward": "slide-from-right", default: "none" }}
|
||
exit={{ "nav-forward": "slide-to-left", default: "none" }}
|
||
default="none"
|
||
>
|
||
<div>
|
||
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
|
||
<ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
|
||
</Suspense>
|
||
</div>
|
||
</ViewTransition>
|
||
```
|
||
|
||
## 跨路由的共享元素
|
||
|
||
```tsx
|
||
// 列表页面
|
||
<Link href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
|
||
<ViewTransition name={`product-${product.id}`}>
|
||
<Image src={product.image} alt={product.name} width={400} height={300} />
|
||
</ViewTransition>
|
||
</Link>
|
||
|
||
// 详情页面——同名
|
||
<ViewTransition name={`product-${product.id}`}>
|
||
<Image src={product.image} alt={product.name} width={800} height={600} />
|
||
</ViewTransition>
|
||
```
|
||
|
||
## 同一路由动态段过渡
|
||
|
||
页面在动态段变化时保持挂载——enter/exit 永远不会触发。使用 `key` + `name` + `share`:
|
||
|
||
```tsx
|
||
<Suspense fallback={<Skeleton />}>
|
||
<ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
|
||
<Content slug={slug} />
|
||
</ViewTransition>
|
||
</Suspense>
|
||
```
|
||
|
||
## 服务端组件
|
||
|
||
- `<ViewTransition>` 可在服务端组件和客户端组件中使用
|
||
- `<Link transitionTypes>` 可在服务端组件中使用
|
||
- `addTransitionType` 和编程式导航需要客户端组件
|