chore: import zh skill vercel-react-view-transitions
This commit is contained in:
@@ -0,0 +1,955 @@
|
||||
---
|
||||
|
||||
# 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` 和编程式导航需要客户端组件
|
||||
@@ -0,0 +1,42 @@
|
||||
# React View Transitions Skill
|
||||
|
||||
用于实现流畅、原生感动画的 Agent 技能,基于 React 的 View Transition API。
|
||||
|
||||
## 本技能涵盖的内容
|
||||
|
||||
- **`<ViewTransition>` 组件** — 动画触发器(enter、exit、update、share)、放置规则、View Transition 类
|
||||
- **`addTransitionType`** — 为方向性或上下文特定的动画标记过渡
|
||||
- **共享元素过渡** — 在不同视图之间变形元素
|
||||
- **View Transition 事件** — 通过 Web Animations API 执行命令式 JavaScript 动画
|
||||
- **CSS 伪元素** — `::view-transition-old`、`::view-transition-new`、`::view-transition-group`
|
||||
- **Next.js 集成** — `experimental.viewTransition`、`next/link` 上的 `transitionTypes` 属性、App Router 模式
|
||||
- **无障碍** — `prefers-reduced-motion` 处理
|
||||
- **可直接使用的 CSS 配方** — 淡入淡出、滑动、缩放、方向导航
|
||||
|
||||
## 技能结构
|
||||
|
||||
```
|
||||
react-view-transitions/
|
||||
├── SKILL.md # 核心技能(始终加载)
|
||||
├── AGENTS.md # 完整编译文档(所有引用已展开)
|
||||
└── references/
|
||||
├── implementation.md # 逐步实现工作流
|
||||
├── patterns.md # 实际模式、事件 API、故障排除
|
||||
├── nextjs.md # Next.js 特有模式
|
||||
└── css-recipes.md # 可直接复制的 CSS 动画
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
通过 [skills.sh](https://skills.sh) 安装:
|
||||
|
||||
```bash
|
||||
npx skills install https://github.com/vercel-labs/react-view-transitions-skill
|
||||
```
|
||||
|
||||
## 资源
|
||||
|
||||
- [React `<ViewTransition>` 文档](https://react.dev/reference/react/ViewTransition)
|
||||
- [React `addTransitionType` 文档](https://react.dev/reference/react/addTransitionType)
|
||||
- [Next.js `viewTransition` 配置](https://nextjs.org/docs/app/api-reference/config/next-config-js/viewTransition)
|
||||
- [Next.js App Router Playground(视图过渡)](https://github.com/vercel/next-app-router-playground/tree/main/app/view-transitions) — Vercel 的参考实现
|
||||
@@ -0,0 +1,9 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- Skill 名称:`vercel-react-view-transitions`
|
||||
- 中文类目:原生 View Transition 与页面转场动画
|
||||
- 上游仓库:`vercel-labs__agent-skills`
|
||||
- 上游路径:`skills/react-view-transitions/SKILL.md`
|
||||
- 上游链接:https://github.com/vercel-labs/agent-skills/blob/HEAD/skills/react-view-transitions/SKILL.md
|
||||
- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理
|
||||
- 原作者、版权和许可证信息以上游仓库为准
|
||||
@@ -0,0 +1,321 @@
|
||||
---
|
||||
name: vercel-react-view-transitions
|
||||
description: 使用 React 视图过渡 API(`<ViewTransition>` 组件、`addTransitionType` 和 CSS 视图过渡伪元素)实现流畅、原生感动画的指南。当用户希望添加页面过渡、动画化路由变化、创建共享元素动画、动画化组件进入/退出、动画化列表重排、实现方向性(前进/后退)导航动画,或在 Next.js 中集成视图过渡时,应使用此技能。当用户提及视图过渡、`startViewTransition`、`ViewTransition`、过渡类型,或询问如何在 React 中不使用第三方动画库来实现 UI 状态间的动画时,也应使用此技能。
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
|
||||
---
|
||||
|
||||
# React 视图过渡
|
||||
|
||||
使用浏览器原生 `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+。在不支持的浏览器上优雅降级。
|
||||
|
||||
---
|
||||
|
||||
## 实现工作流程
|
||||
|
||||
在为现有应用添加视图过渡时,**严格按照 `references/implementation.md` 的步骤进行操作。** 从审计开始——不要跳过。将 `references/css-recipes.md` 中的 CSS 配方复制到全局样式表中——不要自己编写动画 CSS。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### `<ViewTransition>` 组件
|
||||
|
||||
```jsx
|
||||
import { ViewTransition } from 'react';
|
||||
|
||||
<ViewTransition>
|
||||
<Component />
|
||||
</ViewTransition>
|
||||
```
|
||||
|
||||
React 会自动分配一个唯一的 `view-transition-name` 并在后台调用 `document.startViewTransition`。永远不要自己调用 `startViewTransition`。
|
||||
|
||||
### 动画触发器
|
||||
|
||||
| 触发器 | 触发时机 |
|
||||
|---------|--------------|
|
||||
| **enter** | 在过渡期间首次插入 `<ViewTransition>` |
|
||||
| **exit** | 在过渡期间首次移除 `<ViewTransition>` |
|
||||
| **update** | `<ViewTransition>` 内部的 DOM 发生变化。嵌套 VT 的情况下,变化应用于最内层 |
|
||||
| **share** | 命名 VT 卸载,且同一过渡中另一个同名 VT 挂载 |
|
||||
|
||||
只有 `startTransition`、`useDeferredValue` 或 `Suspense` 能激活 VT。普通的 `setState` 不会触发动画。
|
||||
|
||||
### 关键放置规则
|
||||
|
||||
`<ViewTransition>` 只有在**出现在任何 DOM 节点之前**才能激活 enter/exit:
|
||||
|
||||
```jsx
|
||||
// 有效
|
||||
<ViewTransition enter="auto" exit="auto">
|
||||
<div>内容</div>
|
||||
</ViewTransition>
|
||||
|
||||
// 无效——div 包裹了 VT,抑制了 enter/exit
|
||||
<div>
|
||||
<ViewTransition enter="auto" exit="auto">
|
||||
<div>内容</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)`——新旧配对
|
||||
|
||||
参见 `references/css-recipes.md` 获取可直接使用的动画配方。
|
||||
|
||||
---
|
||||
|
||||
## 过渡类型
|
||||
|
||||
使用 `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` 键。
|
||||
|
||||
对于有多个页面的应用,将类型键控 VT 提取为可复用的包装组件:
|
||||
|
||||
```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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### `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——请使用唯一名称(`photo-${id}`)。注意可复用组件:如果一个带有命名 VT 的组件同时渲染在模态框/弹出窗口*和*页面中,两者会同时挂载并破坏变形效果。要么使名称成为条件(通过属性传入),要么将命名 VT 移出共享组件,放入特定的消费者中。
|
||||
- `share` 优先于 `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>
|
||||
```
|
||||
|
||||
更多模式参见 `references/patterns.md`。
|
||||
|
||||
---
|
||||
|
||||
## 多个 VT 如何交互
|
||||
|
||||
每个匹配触发器的 VT 会在一次 `document.startViewTransition` 中同时触发。**不同**过渡(导航 vs 后续的 Suspense 解析)中的 VT 不会相互竞争。
|
||||
|
||||
### 广泛使用 `default="none"`
|
||||
|
||||
否则,每个 VT 都会在**每次**过渡上触发浏览器交叉淡入淡出——Suspense 解析、`useDeferredValue` 更新、后台重新验证。始终使用 `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 的设置(`experimental.viewTransition` 标志、`next/link` 上的 `transitionTypes` 属性、App Router 模式、服务器组件),参见 `references/nextjs.md`。
|
||||
|
||||
---
|
||||
|
||||
## 无障碍
|
||||
|
||||
始终将 `references/css-recipes.md` 中的减少动画 CSS 添加到你的全局样式表中。
|
||||
|
||||
---
|
||||
|
||||
## 参考文件
|
||||
|
||||
- **`references/implementation.md`**——逐步实现工作流程。
|
||||
- **`references/patterns.md`**——模式、动画时序、事件 API、故障排除。
|
||||
- **`references/css-recipes.md`**——可直接使用的 CSS 动画配方。
|
||||
- **`references/nextjs.md`**——Next.js App Router 模式和服务器组件详情。
|
||||
|
||||
## 完整编译文档
|
||||
|
||||
包含所有参考文件展开的完整指南:`AGENTS.md`
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"organization": "Vercel Engineering",
|
||||
"date": "March 2026",
|
||||
"abstract": "Guide for implementing smooth, native-feeling animations using React's View Transition API. Covers the <ViewTransition> component, addTransitionType, CSS view transition pseudo-elements, shared element transitions, JavaScript animations via Web Animations API, and Next.js integration including the transitionTypes prop on next/link. Includes ready-to-use CSS animation recipes and real-world patterns from production Next.js apps.",
|
||||
"references": [
|
||||
"https://react.dev/reference/react/ViewTransition",
|
||||
"https://react.dev/reference/react/addTransitionType",
|
||||
"https://nextjs.org/docs/app/api-reference/config/next-config-js/viewTransition",
|
||||
"https://github.com/vercel/next-app-router-playground/tree/main/app/view-transitions"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
# 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;
|
||||
}
|
||||
```
|
||||
|
||||
用法:`<ViewTransition enter="fade-in" exit="fade-out" />`
|
||||
|
||||
---
|
||||
|
||||
## 滑动(垂直方向)
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
用法:
|
||||
```jsx
|
||||
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
|
||||
<ViewTransition default="none" enter="slide-up"><Content /></ViewTransition>
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方向导航
|
||||
|
||||
### 分离进入/离开类
|
||||
|
||||
```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-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;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 共享元素形变
|
||||
|
||||
```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); }
|
||||
}
|
||||
```
|
||||
|
||||
用法:`<ViewTransition name={`product-${id}`} share="morph" />`
|
||||
|
||||
**注意:** 共享元素过渡会截取光栅快照。对于尺寸差异较大的文本(例如 `<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;
|
||||
}
|
||||
```
|
||||
|
||||
用法:`<ViewTransition name={`title-${id}`} share="text-morph" />`
|
||||
|
||||
---
|
||||
|
||||
## 缩放
|
||||
|
||||
```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; }
|
||||
}
|
||||
```
|
||||
|
||||
用法:`<ViewTransition enter="scale-in" exit="scale-out" />`
|
||||
|
||||
---
|
||||
|
||||
## 持久元素隔离
|
||||
|
||||
```css
|
||||
::view-transition-group(persistent-nav) {
|
||||
animation: none;
|
||||
z-index: 100;
|
||||
}
|
||||
```
|
||||
|
||||
### 背景模糊变通方案
|
||||
|
||||
对于使用了 `backdrop-filter` 的元素,隐藏旧快照以避免闪烁:
|
||||
|
||||
```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;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# 实施工作流
|
||||
|
||||
为应用添加视图过渡时,请按以下步骤顺序操作。每一步都建立在之前步骤的基础之上。
|
||||
|
||||
## 步骤 1:审查应用
|
||||
|
||||
在编写任何代码之前,彻底扫描代码库。搜索:
|
||||
|
||||
- **每一个 `<Link>` 和 `router.push`** —— 这些是你的导航触发器。打开包含它们的每个文件。
|
||||
- **每一个 `<Suspense>` 边界** —— 每一个都是展示动画的候选对象。检查其 fallback 渲染了什么。
|
||||
- **每一个页面/路由组件** —— 将它们全部列出。每个页面都需要一个 VT 放置决策。
|
||||
- **持久化元素** —— 在导航间保持停留在屏幕上的 header、navbar、sidebar、粘性控件。这些需要 `viewTransitionName` 隔离。
|
||||
- **共享视觉元素** —— 在源视图和目标视图中都出现的图像、卡片或头像(例如列表中的缩略图和详情页上的同一张图片)。
|
||||
- **骨架屏与内容控件对** —— 如果 Suspense fallback 渲染了一个控件(搜索输入框、标签栏),而实际内容中也有同样的控件,那么两者都需要一个匹配的 `viewTransitionName`。
|
||||
|
||||
然后对每个导航进行分类,并生成一份导航映射表:
|
||||
|
||||
```
|
||||
| 路由 | 导航至 | 方向 | VT 模式 |
|
||||
|---------------------|----------------------|-------------|------------------------|
|
||||
| / | /detail/[id] | 前进 | 定向滑动 (directional slide) |
|
||||
| /detail/[id] | / | 后退 | 定向滑动 (directional slide) |
|
||||
| /detail/[id] | /detail/[other] | 顺序切换 | 定向滑动 (有序 prev/next) 或 key+share 交叉淡变 |
|
||||
| /tab/[a] | /tab/[b] | 横向切换 | key+share 交叉淡变 |
|
||||
| (Suspense) | (内容加载) | — | 上滑展示 (slide-up reveal) |
|
||||
```
|
||||
|
||||
对于每个共享元素(`name` 属性),记录哪些导航会形成配对、哪些不会——这决定了你是否需要将 `enter`/`exit` 作为 `share` 的备用方案。
|
||||
|
||||
## 步骤 2:添加 CSS 配方
|
||||
|
||||
将 `css-recipes.md` 中的**完整** CSS 配方集复制到你的全局样式表中。这包括计时变量、共享关键帧、淡入淡出、滑动(垂直)、定向导航(前进/后退)、共享元素变形、持久化元素隔离以及减弱动效。
|
||||
|
||||
不要自行编写动画 CSS——这些配方已经处理了错开计时、变形时的运动模糊以及容易出错的减弱动效。你可以在初始设置之后自定义计时变量(`--duration-exit`、`--duration-enter`、`--duration-move`)。
|
||||
|
||||
## 步骤 3:隔离持久化元素
|
||||
|
||||
对于步骤 1 中识别出的每个持久化元素,添加 `viewTransitionName` 样式以将其从页面内容的过渡快照中提取出来:
|
||||
|
||||
```jsx
|
||||
<header style={{ viewTransitionName: "site-header" }}>...</header>
|
||||
```
|
||||
|
||||
然后添加 `css-recipes.md` 中的持久化元素隔离 CSS(防止该元素在页面过渡期间产生动画)。如果该元素使用了 `backdrop-blur` 或 `backdrop-filter`,请改用 `css-recipes.md` 中的 backdrop-blur 变通方案。
|
||||
|
||||
如果 Suspense fallback 镜像了一个持久化控件(例如骨架搜索输入框),请为实际控件和骨架屏设置相同的 `viewTransitionName`,使它们在原位变形。
|
||||
|
||||
## 步骤 4:添加定向页面过渡
|
||||
|
||||
对于步骤 1 中识别出的层级导航,使用 `startTransition` 内部的 `addTransitionType` 标记导航方向:
|
||||
|
||||
```jsx
|
||||
startTransition(() => {
|
||||
addTransitionType('nav-forward');
|
||||
router.push('/detail/1');
|
||||
});
|
||||
```
|
||||
|
||||
然后将每个**页面组件**(而非布局)包裹在类型键控的 `<ViewTransition>` 中:
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
`css-recipes.md` 中的 `nav-forward` 和 `nav-back` CSS 类会产生水平滑动效果。对于不需要定向动效的简单应用,一个空的 `<ViewTransition default="none">` 包裹搭配 `enter="fade-in"` / `exit="fade-out"` 也同样可行。
|
||||
|
||||
将其提取为可复用组件,这样每个页面就不必重复冗长的类型映射:
|
||||
|
||||
```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"`,并在组件上设置 `default="none"`——否则它会在每次过渡时触发。
|
||||
- 将定向的 `<ViewTransition>` 放置在**每个页面组件**中,而不是布局中。布局在导航间持久存在,永远不会触发 enter/exit。
|
||||
- 仅对层级导航或有序序列(prev/next)使用定向滑动。横向/同级导航(tab 到 tab)应使用空的 `<ViewTransition>`(交叉淡变)或 `default="none"`。
|
||||
|
||||
## 步骤 5:添加 Suspense 展示
|
||||
|
||||
对于步骤 1 中识别出的每个 `<Suspense>` 边界,将 fallback 和内容分别包裹在各自的 `<ViewTransition>` 中:
|
||||
|
||||
```jsx
|
||||
<Suspense
|
||||
fallback={
|
||||
<ViewTransition exit="slide-down">
|
||||
<Skeleton />
|
||||
</ViewTransition>
|
||||
}
|
||||
>
|
||||
<ViewTransition enter="slide-up" default="none">
|
||||
<AsyncContent />
|
||||
</ViewTransition>
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
此示例使用 `slide-down` / `slide-up` 实现定向垂直动效。对于更简单的展示,一个包裹 `<Suspense>` 的空 `<ViewTransition>` 即可实现零配置的交叉淡变。根据空间含义进行选择——请查阅主技能文件中的"选择正确的动画样式"表格。
|
||||
|
||||
**规则:**
|
||||
- 始终在内容 `<ViewTransition>` 上使用 `default="none"`,以防止在重新验证或不相关的过渡中重新触发动画。
|
||||
- 在 Suspense `<ViewTransition>` 上使用简单的字符串属性(而非类型映射)——Suspense 的解析作为独立的、没有类型的过渡触发,因此类型键控属性无法匹配。
|
||||
|
||||
## 步骤 6:添加共享元素过渡
|
||||
|
||||
对于步骤 1 中识别出的每个共享视觉元素,在源视图和目标视图上添加匹配的命名 `<ViewTransition>` 包裹:
|
||||
|
||||
```jsx
|
||||
// 在源视图上(例如列表/网格页面)
|
||||
<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
|
||||
<Image src={photo.src} ... />
|
||||
</ViewTransition>
|
||||
|
||||
// 在目标视图上(例如详情页)——相同的 name
|
||||
<ViewTransition name={`photo-${photo.id}`} share="morph">
|
||||
<Image src={photo.src} ... />
|
||||
</ViewTransition>
|
||||
```
|
||||
|
||||
`share="morph"` 类使用 `css-recipes.md` 中的变形配方(受控时长 + 运动模糊)。对于更简单的交叉淡变,使用 `share="auto"`(浏览器默认行为)。
|
||||
|
||||
当列表项包含共享元素时,使用两个嵌套的 `<ViewTransition>` 层组合两种模式——请参阅 `SKILL.md` 中的"将共享元素与列表标识组合使用"。
|
||||
|
||||
**规则:**
|
||||
- 名称必须是全局唯一的——使用 `photo-${id}` 等前缀。
|
||||
- 在列表侧的共享元素上添加 `default="none"`,以防止在过滤/搜索更新时每个项目都产生交叉淡变。
|
||||
|
||||
## 步骤 7:验证每个导航路径
|
||||
|
||||
对照步骤 1 中的导航映射表,逐行确认:
|
||||
|
||||
- VT 在此导航中是挂载/卸载,还是保持挂载状态(相同路由)?
|
||||
- 对于命名 VT:是否形成了共享配对?如果没有,`enter`/`exit` 是否提供了备用方案?
|
||||
- `default="none"` 是否阻止了你实际想要的动画?
|
||||
- 持久化元素是否保持静态(不随页面内容滑动)?
|
||||
- Suspense 展示动画是否独立于定向导航?
|
||||
|
||||
如果任何路径没有产生动画或产生了冲突的动画,请重新审视相关步骤。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
- **不带属性的空 `<ViewTransition>`** ——没有 `default="none"`,它会在每次过渡(每次导航、每次 Suspense 解析、每次重新验证)时触发浏览器的默认交叉淡变。始终设置 `default="none"` 并仅显式启用你想要的触发器。
|
||||
- **在布局中使用定向 `<ViewTransition>`** ——布局在导航间持久存在,永远不会卸载/重新挂载。`enter`/`exit` 属性不会在路由变化时触发。将外层的类型键控 `<ViewTransition>` 放置在**每个页面组件**中。
|
||||
- **淡出退出与共享元素变形共存** ——页面溶解与变形冲突。请改用定向滑动退出。
|
||||
- **自行编写自定义动画 CSS** ——`css-recipes.md` 中的配方已经处理了错开计时、变形时的运动模糊以及减弱动效。直接复制它们,不要重新发明。
|
||||
- **类型键控对象中缺少 `default: "none"`** ——TypeScript 要求有 `default` 键,没有它时回退值为 `"auto"`,会在每次过渡时触发。
|
||||
- **在 Suspense 展示中使用类型映射** ——Suspense 的解析作为独立的、没有类型的过渡触发。类型键控属性无法匹配——请改用简单的字符串属性。
|
||||
- **使用原生的 `viewTransitionName` CSS 触发动画** ——只有当 `<ViewTransition>` 组件存在于渲染树中时,React 才会调用 `document.startViewTransition`。单纯的 `viewTransitionName` 样式用于将元素从父级快照中隔离出来,而非用于触发动画。
|
||||
- **同路由导航的 `update` 触发器** ——内容内部嵌套的 VT 会劫持父级的突变,因此 `update` 永远不会在外层 VT 上触发。请改用 `key` + `name` + `share`。
|
||||
- **在可复用组件中使用命名 VT** ——如果一个带有命名 VT 的组件同时在模态框/弹出层**和**页面中渲染,两者会同时挂载,破坏变形效果。请使 name 变为条件性,或将其移至具体的消费者处。
|
||||
- **使用 `router.back()` 进行后退导航** ——`router.back()` 会触发同步的 `popstate`,与视图过渡不兼容。请使用带显式 URL 的 `router.push()`。
|
||||
|
||||
---
|
||||
|
||||
关于 Next.js 特定的实施步骤(配置标志、`<Link>` 上的 `transitionTypes`、同路由动态段),请参阅 `nextjs.md`。
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: nextjs-view-transitions
|
||||
description: Next.js 中 View Transitions API 的使用指南
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
# Next.js 中的 View Transitions
|
||||
|
||||
## 设置
|
||||
|
||||
`<ViewTransition>` 开箱即用,支持 `startTransition`/`Suspense` 更新。若要对 `<Link>` 导航也启用动画:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
const nextConfig = {
|
||||
experimental: { viewTransition: true },
|
||||
};
|
||||
module.exports = nextConfig;
|
||||
```
|
||||
|
||||
这会为每个 `<Link>` 导航包裹 `document.startViewTransition`。任何 `default="auto"` 的 VT 会在**每次**链接点击时触发——请使用 `default="none"` 来防止动画冲突。
|
||||
|
||||
**不要**安装 `react@canary`——详见 SKILL.md 的"可用性"章节。
|
||||
|
||||
---
|
||||
|
||||
## Next.js 实现补充
|
||||
|
||||
在遵循 `implementation.md` 时,应用以下补充内容:
|
||||
|
||||
**步骤 2 之后:** 启用上述实验性标志。
|
||||
|
||||
**步骤 4:** 在 `<Link>` 上使用 `transitionTypes`——详见下文"`transitionTypes` Prop"章节的用法和可用性说明。
|
||||
|
||||
**步骤 6 之后:** 对于相同路由的动态段(例如 `/collection/[slug]`),使用 `key` + `name` + `share` 模式——详见下文"相同路由动态段过渡"章节。
|
||||
|
||||
---
|
||||
|
||||
## 布局级别的 ViewTransition
|
||||
|
||||
**如果页面有自己的 VT,不要在布局中添加包裹 `{children}` 的布局级 VT。** 嵌套 VT 在父级 VT 内部时,enter/exit 永远不会触发——页面级的 enter/exit 会静默失效。请完全移除布局级 VT。
|
||||
|
||||
一个裸露的 `<ViewTransition>` 在布局中只有在页面**没有**自己的 VT 时才能正常工作。
|
||||
|
||||
**布局在导航间持久存在**——`enter`/`exit` 仅在初始挂载时触发,路由变化时不触发。不要在布局中使用类型键映射。
|
||||
|
||||
---
|
||||
|
||||
## `next/link` 上的 `transitionTypes` Prop
|
||||
|
||||
无需包装组件,在服务端组件中即可工作:
|
||||
|
||||
```tsx
|
||||
<Link href="/products/1" transitionTypes={['transition-to-detail']}>查看产品</Link>
|
||||
```
|
||||
|
||||
这替代了手动 `onNavigate` + `startTransition` + `addTransitionType` + `router.push()` 的模式。仅在非链接交互(按钮、表单)时保留手动 `startTransition`。
|
||||
|
||||
**可用性:** `transitionTypes` 需要启用 `experimental.viewTransition: true`,在 Next.js 15+ canary 构建版本和 Next.js 16+ 中可用。如果不可用,请使用 `startTransition` + `addTransitionType` + `router.push()`(参见下面的"程序化导航"章节)。检查方法:`grep -r "transitionTypes" node_modules/next/dist/`——如果没有结果,则回退到程序化导航。
|
||||
|
||||
---
|
||||
|
||||
## 程序化导航
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { startTransition, addTransitionType } from 'react';
|
||||
|
||||
function handleNavigate(href: string) {
|
||||
const router = useRouter();
|
||||
startTransition(() => {
|
||||
addTransitionType('nav-forward');
|
||||
router.push(href);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用 `router.replace` 实现服务端筛选
|
||||
|
||||
对于通过 URL 参数在服务端重新渲染的搜索/排序/筛选,使用 `startTransition` + `router.replace`。VT 会生效,因为状态更新位于 `startTransition` 内部:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { startTransition } from 'react';
|
||||
|
||||
function handleSort(sort: string) {
|
||||
const router = useRouter();
|
||||
startTransition(() => {
|
||||
router.replace(`?sort=${sort}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
包裹在 `<ViewTransition key={item.id}>` 中的列表项会以动画方式重新排序。这是 `patterns.md` 中客户端 `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"><骨架屏 /></ViewTransition>}>
|
||||
<ViewTransition enter="slide-up" default="none"><内容 /></ViewTransition>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ViewTransition>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 将 `loading.tsx` 作为 Suspense 边界
|
||||
|
||||
Next.js 的 `loading.tsx` 是一个隐式的 `<Suspense>` 边界。在 `loading.tsx` 中,将骨架屏包裹在 `<ViewTransition exit="...">` 中;在页面中,将内容包裹在 `<ViewTransition enter="..." default="none">` 中:
|
||||
|
||||
```tsx
|
||||
// loading.tsx
|
||||
<ViewTransition exit="slide-down"><PhotoGridSkeleton /></ViewTransition>
|
||||
|
||||
// page.tsx
|
||||
<ViewTransition enter="slide-up" default="none"><PhotoGrid photos={photos} /></ViewTransition>
|
||||
```
|
||||
|
||||
与显式 `<Suspense>` 规则相同:使用简单字符串 prop(而非类型映射),因为 Suspense 展现动画触发时不带过渡类型。
|
||||
|
||||
---
|
||||
|
||||
## 跨路由共享元素
|
||||
|
||||
```tsx
|
||||
// 列表页
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} 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>
|
||||
))}
|
||||
|
||||
// 详情页——相同的 name
|
||||
<ViewTransition name={`product-${product.id}`}>
|
||||
<Image src={product.image} alt={product.name} width={800} height={600} />
|
||||
</ViewTransition>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相同路由动态段过渡
|
||||
|
||||
当在同一路由的不同动态段之间导航时(例如 `/collection/[slug]`),页面保持挂载状态——enter/exit 永远不会触发。请使用 `key` + `name` + `share`:
|
||||
|
||||
```tsx
|
||||
<Suspense fallback={<骨架屏 />}>
|
||||
<ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
|
||||
<Content slug={slug} />
|
||||
</ViewTransition>
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
- `key={slug}` 在变化时强制卸载/重新挂载
|
||||
- `name` + `share="auto"` 创建共享元素的交叉淡入淡出效果
|
||||
- 位于 `<Suspense>` 内部的 VT(不对 Suspense 设置 key)在加载期间保持旧内容可见
|
||||
|
||||
---
|
||||
|
||||
## 服务端组件
|
||||
|
||||
- `<ViewTransition>` 在服务端组件和客户端组件中均可使用
|
||||
- `<Link transitionTypes>` 在服务端组件中可用——无需 `'use client'`
|
||||
- 程序化导航的 `addTransitionType` 和 `startTransition` 需要客户端组件
|
||||
@@ -0,0 +1,282 @@
|
||||
# 模式与指南
|
||||
|
||||
## 使用 `useDeferredValue` 的可搜索网格
|
||||
|
||||
`useDeferredValue` 将筛选更新转变为一次过渡,从而激活 `<ViewTransition>`:
|
||||
|
||||
```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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
在延迟列表内部,为每个条目添加 `<ViewTransition name={...}>` 会在每次按键时触发交叉淡入淡出。使用 `default="none"` 修复:
|
||||
|
||||
```tsx
|
||||
{filteredItems.map(item => (
|
||||
<ViewTransition key={item.id} name={`item-${item.id}`} share="morph" default="none">
|
||||
<ItemCard item={item} />
|
||||
</ViewTransition>
|
||||
))}
|
||||
```
|
||||
|
||||
## 使用 `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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 类型安全的过渡辅助函数
|
||||
|
||||
使用 `as const` 数组和派生类型防止 ID 冲突:
|
||||
|
||||
```tsx
|
||||
const transitionTypes = ['default', 'transition-to-detail', 'transition-to-list'] as const;
|
||||
const animationTypes = ['auto', 'none', 'animate-slide-from-left', 'animate-slide-from-right'] as const;
|
||||
|
||||
type TransitionType = (typeof transitionTypes)[number];
|
||||
type AnimationType = (typeof animationTypes)[number];
|
||||
type TransitionMap = { default: AnimationType } & Partial<Record<Exclude<TransitionType, 'default'>, AnimationType>>;
|
||||
|
||||
export function HorizontalTransition({ children, enter, exit }: {
|
||||
children: React.ReactNode;
|
||||
enter: TransitionMap;
|
||||
exit: TransitionMap;
|
||||
}) {
|
||||
return <ViewTransition enter={enter} exit={exit}>{children}</ViewTransition>;
|
||||
}
|
||||
```
|
||||
|
||||
## 无重挂载的交叉淡入淡出
|
||||
|
||||
省略 `key` 可触发更新(交叉淡入淡出),而非退出+进入。避免 Suspense 重挂载/重新请求:
|
||||
|
||||
```jsx
|
||||
<ViewTransition>
|
||||
<TabPanel tab={activeTab} />
|
||||
</ViewTransition>
|
||||
```
|
||||
|
||||
当内容身份发生变化(状态重置)时使用 `key`;对于交叉淡入淡出(标签页、面板、轮播)则省略。
|
||||
|
||||
## 将元素与父级动画隔离
|
||||
|
||||
### 持久布局元素
|
||||
|
||||
持久元素(头部、导航栏、侧边栏)会被捕获到页面的过渡快照中。使用 `viewTransitionName` 修复:
|
||||
|
||||
```jsx
|
||||
<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
|
||||
```
|
||||
|
||||
然后从 `css-recipes.md` 添加持久元素隔离 CSS。对于 `backdrop-blur`/`backdrop-filter`,使用 `css-recipes.md` 中的 backdrop-blur 变通方案。
|
||||
|
||||
### 浮动元素
|
||||
|
||||
为弹出框/工具提示赋予它们自己的 `viewTransitionName`:
|
||||
|
||||
```jsx
|
||||
<SelectPopover style={{ viewTransitionName: 'popover' }}>{options}</SelectPopover>
|
||||
```
|
||||
|
||||
全局修复方案:请参见 `css-recipes.md` 中的持久元素隔离。
|
||||
|
||||
## 骨架屏与内容之间的共享控件
|
||||
|
||||
为后备内容与正式内容中相匹配的控件赋予相同的 `viewTransitionName`:
|
||||
|
||||
```jsx
|
||||
// 后备内容
|
||||
<input disabled placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
|
||||
// 正式内容
|
||||
<input placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
|
||||
```
|
||||
|
||||
不要在 `<ViewTransition>` 内部的根 DOM 节点上设置手动的 `viewTransitionName`——React 自动生成的名称会覆盖它。
|
||||
|
||||
## 可复用的带动画折叠
|
||||
|
||||
```jsx
|
||||
function AnimatedCollapse({ open, children }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<ViewTransition enter="expand-in" exit="collapse-out">
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用方式:通过 startTransition 切换
|
||||
<button onClick={() => startTransition(() => setOpen(o => !o))}>Toggle</button>
|
||||
<AnimatedCollapse open={open}><SectionContent /></AnimatedCollapse>
|
||||
```
|
||||
|
||||
## 使用 Activity 保留状态
|
||||
|
||||
```jsx
|
||||
<Activity mode={isVisible ? 'visible' : 'hidden'}>
|
||||
<ViewTransition enter="slide-in" exit="slide-out">
|
||||
<Sidebar />
|
||||
</ViewTransition>
|
||||
</Activity>
|
||||
```
|
||||
|
||||
## 使用 `useOptimistic` 排除元素
|
||||
|
||||
`useOptimistic` 的值在过渡快照之前更新,从而将其排除在动画之外。适用于控件(标签);动画内容应使用已提交的状态:
|
||||
|
||||
```tsx
|
||||
const [sort, setSort] = useState('newest');
|
||||
const [optimisticSort, setOptimisticSort] = useOptimistic(sort);
|
||||
|
||||
function cycleSort() {
|
||||
const nextSort = getNextSort(optimisticSort);
|
||||
startTransition(() => {
|
||||
setOptimisticSort(nextSort); // 在快照之前——无动画
|
||||
setSort(nextSort); // 在快照之间——有动画
|
||||
});
|
||||
}
|
||||
|
||||
<button>Sort: {LABELS[optimisticSort]}</button>
|
||||
{items.sort(comparators[sort]).map(item => (
|
||||
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
|
||||
))}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 视图过渡事件
|
||||
|
||||
通过 `onEnter`、`onExit`、`onUpdate`、`onShare` 进行命令式控制。始终返回清理函数。`onShare` 优先级高于 `onEnter`/`onExit`。
|
||||
|
||||
```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` 对象:`instance.old`、`instance.new`、`instance.group`、`instance.imagePair`、`instance.name`。
|
||||
|
||||
`types` 数组(第二个参数)允许你根据过渡类型变化动画效果。
|
||||
|
||||
---
|
||||
|
||||
## 动画时长
|
||||
|
||||
| 交互类型 | 时长 |
|
||||
|---------|------|
|
||||
| 直接切换(展开/折叠) | 100–200ms |
|
||||
| 路由过渡(滑动) | 150–250ms |
|
||||
| Suspense 揭示(骨架屏 → 内容) | 200–400ms |
|
||||
| 共享元素变形 | 300–500ms |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
**VT 未激活:** 确保 `<ViewTransition>` 出现在任何 DOM 节点之前。确保状态更新位于 `startTransition` 内部。
|
||||
|
||||
**"Two ViewTransition components with the same name":** 名称必须在全局唯一。使用 ID:`name={`hero-${item.id}`}`。
|
||||
|
||||
**`router.back()` 及浏览器后退/前进跳过动画:** 改用带显式 URL 的 `router.push()`。请参见 SKILL.md 中的 "router.back() and Browser Back Button"。
|
||||
|
||||
**`flushSync` 跳过动画:** 改用 `startTransition`。
|
||||
|
||||
**只有更新有动画(没有进入/退出):** 如果没有 `<Suspense>`,React 会将切换视为更新。改为条件渲染 VT 本身,或将其包裹在 `<Suspense>` 中。
|
||||
|
||||
**布局 VT 阻止页面 VT 动画:** 嵌套的 VT 在父级 VT 内部永远不会触发进入/退出。如果布局中有一个包裹 `{children}` 的 VT,页面级别的进入/退出将静默失效。移除布局 VT。
|
||||
|
||||
**使用 `useOptimistic` 时列表重新排序没有动画:** 乐观值在快照之前解析。列表顺序应使用已提交的状态。
|
||||
|
||||
**TS 错误 "Property 'default' is missing":** 以类型为键的对象需要一个 `default` 键。
|
||||
|
||||
**哈希片段导致滚动跳跃:** 不使用哈希进行导航;导航后通过编程方式滚动。
|
||||
|
||||
**Backdrop-blur 闪烁:** 使用 `css-recipes.md` 中的 backdrop-blur 变通方案。
|
||||
|
||||
**`border-radius` 在过渡期间丢失:** 直接将 `border-radius` 应用于被捕获的元素。
|
||||
|
||||
**骨架屏控件滑走:** 为相匹配的控件赋予相同的 `viewTransitionName`。
|
||||
|
||||
**批处理:** 动画期间的多次更新会被批处理。A→B→C→D 变为 B→D。
|
||||
|
||||
<details>
|
||||
<summary>可用工具列表(点击展开)</summary>
|
||||
|
||||
- **Agent** – 启动子代理处理复杂/多步任务
|
||||
- **Bash** – 执行 bash 命令
|
||||
- **CronCreate/CronDelete/CronList** – 管理定时任务
|
||||
- **Edit** – 精确字符串替换编辑文件
|
||||
- **EnterWorktree/ExitWorktree** – 管理 git worktree
|
||||
- **NotebookEdit** – 编辑 Jupyter notebook 单元格
|
||||
- **Read** – 读取文件内容
|
||||
- **ReportFindings** – 报告代码审查发现
|
||||
- **ScheduleWakeup** – 调度 /loop 唤醒
|
||||
- **SendMessage** – 向其他代理发送消息
|
||||
- **Skill** – 调用技能
|
||||
- **TaskCreate/TaskGet/TaskList/TaskOutput/TaskStop/TaskUpdate** – 任务管理
|
||||
- **WebFetch/WebSearch** – 获取 URL 内容 / 搜索网络
|
||||
- **Workflow** – 执行多代理工作流脚本
|
||||
- **Write** – 写入文件
|
||||
</details>
|
||||
Reference in New Issue
Block a user