vercel-react-view-transitions by vercel-labs/agent-skills
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-view-transitionsReact 的视图过渡 API 让你能够使用浏览器原生的 document.startViewTransition 在 UI 状态之间进行动画过渡。用 <ViewTransition> 声明要动画化什么,用 startTransition / useDeferredValue / Suspense 触发何时动画化,并用 CSS 类或 Web Animations API 控制如何动画化。不支持的浏览器会跳过动画并立即应用 DOM 更改。
每个 <ViewTransition> 都应该回答:这个动画向用户传达了什么样的空间关系或连续性? 如果你无法清晰表述,就不要添加它。
从最高价值到最低价值——从顶部开始,只有当你的应用在该级别还没有动画时才向下移动:
| 优先级 | 模式 |
|---|
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 传达的信息 |
|---|
| 示例 |
|---|
| 1 | 共享元素 (name) | "这是同一个东西——我正在深入查看" | 列表缩略图变形为详情页主图 |
| 2 | Suspense 内容显示 | "数据已加载,这是真实内容" | 骨架屏淡入淡出到已加载页面 |
| 3 | 列表项身份 (每个项的 key) | "相同的项目,新的排列" | 排序/筛选时卡片重新排序 |
| 4 | 状态变化 (enter/exit) | "某些内容出现或消失" | 面板在切换时滑入 |
| 5 | 路由变更 (布局级别) | "前往一个新地方" | 页面间淡入淡出 |
路由级别的过渡 (#5) 优先级最低,因为 URL 变更已经表示上下文切换。每次导航都使用通用的淡入淡出没有传达任何信息——这只是视觉噪音。优先使用具体、有意的动画 (#1–#4),而不是环境性的页面过渡。
经验法则: 在任何给定时刻,树中只有一个层级应该进行视觉过渡。如果你的页面已经管理自己的 Suspense 内容显示或共享元素变形,再在其上添加布局级别的路由过渡会产生双重动画,两个层级会争夺注意力。
并非所有内容都应该滑动。动画应与空间关系匹配:
| 上下文 | 动画 | 原因 |
|---|---|---|
| 详情页主要内容 | enter="slide-up" | 显示用户深入查看的"更深层"内容 |
| 详情页外部包装器 | 为 nav-forward 设置 enter/exit 类型映射 | 向前导航——水平方向 |
| 列表 / 概览页面 | 裸 <ViewTransition> (淡入淡出) 或 default="none" | 横向导航——没有要传达的空间深度 |
| 页面标题 / 面包屑 | 裸 <ViewTransition> (淡入淡出) | 小型、快速加载的元数据——滑动显得过度 |
| 同一页面上的次要部分 | enter="slide-up" | 标题之后流式传输的第二个 Suspense 边界 |
| 重新验证 / 后台刷新 | default="none" | 数据静默刷新——动画会分散注意力 |
不确定时,使用裸 <ViewTransition> (默认淡入淡出) 或 default="none"。只有当方向性运动 (slide-up, slide-from-right) 传达空间意义时才添加。
<ViewTransition> 和 addTransitionType 需要 react@canary 或 react@experimental。它们不在稳定的 React 中 (包括 19.x)。在实现之前,请验证项目使用的是 canary 版本——检查 package.json 中的 "react": "canary" 或运行 npm ls react。如果使用的是稳定版,请安装 canary:npm install react@canary react-dom@canary。<ViewTransition> 组件包装你想要动画化的元素:
import { ViewTransition } from 'react';
<ViewTransition>
<Component />
</ViewTransition>
React 会自动为每个 <ViewTransition> 内部最近的 DOM 节点分配一个唯一的 view-transition-name,并在幕后调用 document.startViewTransition。永远不要自己调用 startViewTransition——React 会协调所有视图过渡,并会中断外部的过渡。
React 根据发生的变化决定运行哪种类型的动画:
| 触发器 | 触发时机 |
|---|---|
| enter | 在过渡期间首次插入 <ViewTransition> |
| exit | 在过渡期间首次移除 <ViewTransition> |
| update | DOM 突变发生在 <ViewTransition> 内部,或者边界由于紧邻的兄弟元素而改变大小/位置 |
| share | 一个命名的 <ViewTransition> 卸载,而另一个具有相同 name 的 <ViewTransition> 在同一过渡中挂载 (共享元素过渡) |
只有包装在 startTransition、useDeferredValue 或 Suspense 中的更新才会激活 <ViewTransition>。常规的 setState 会立即更新且不会产生动画。
<ViewTransition> 只有在组件树中出现在任何 DOM 节点之前时才会激活 enter/exit:
// 有效 —— ViewTransition 在 DOM 节点之前
function Item() {
return (
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
);
}
// 无效 —— <div> 包装了 ViewTransition,阻止了 enter/exit
function Item() {
return (
<div>
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
</div>
);
}
每个属性控制不同的动画触发器。值可以是:
"auto" —— 使用浏览器默认的淡入淡出
"none" —— 禁用此动画类型
"my-class-name" —— 自定义 CSS 类
一个对象 { [transitionType]: value },用于特定类型的动画 (见下面的过渡类型)
<ViewTransition default="none" // 禁用未明确列出的所有内容 enter="slide-in" // 进入动画的 CSS 类 exit="slide-out" // 退出动画的 CSS 类 update="cross-fade" // 更新动画的 CSS 类 share="morph" // 共享元素动画的 CSS 类 />
如果 default 是 "none",则所有触发器都将关闭,除非明确列出。
使用视图过渡伪元素选择器与类名:
::view-transition-old(.slide-in) {
animation: 300ms ease-out slide-out-to-left;
}
::view-transition-new(.slide-in) {
animation: 300ms ease-out slide-in-from-right;
}
@keyframes slide-out-to-left {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-from-right {
from { transform: translateX(100%); opacity: 0; }
}
可用的伪元素有:
::view-transition-group(.class) —— 过渡的容器::view-transition-image-pair(.class) —— 包含旧的和新的快照::view-transition-old(.class) —— 离开的快照::view-transition-new(.class) —— 进入的快照addTransitionType 的过渡类型addTransitionType 允许你用字符串标签标记一个过渡,以便 <ViewTransition> 可以根据导致变化的原因选择不同的动画。这对于方向性导航 (前进 vs. 后退) 或区分用户操作 (点击 vs. 滑动 vs. 键盘) 至关重要。
import { startTransition, addTransitionType } from 'react';
function navigate(url, direction) {
startTransition(() => {
addTransitionType(`navigation-${direction}`); // "navigation-forward" 或 "navigation-back"
setCurrentPage(url);
});
}
你可以向单个过渡添加多个类型,如果多个过渡被批处理,所有类型都会被收集。
向任何激活属性传递一个对象而不是字符串。键是过渡类型字符串,值是 CSS 类名:
<ViewTransition
enter={{
'navigation-forward': 'slide-in-from-right',
'navigation-back': 'slide-in-from-left',
default: 'fade-in',
}}
exit={{
'navigation-forward': 'slide-out-to-left',
'navigation-back': 'slide-out-to-right',
default: 'fade-out',
}}
>
<Page />
</ViewTransition>
对象内部的 default 键是当没有类型匹配时的回退值。如果任何类型的值是 "none",则该触发器的 ViewTransition 将被禁用。
:active-view-transition-type() 中使用类型React 将过渡类型添加为浏览器视图过渡类型,从而可以通过 :root:active-view-transition-type(type-name) 实现纯 CSS 作用域。注意: ::view-transition-old(*) / ::view-transition-new(*) 匹配所有命名元素——通配符可能会覆盖基于类的特定动画。对于每个组件的动画,优先使用基于类的属性;将 :active-view-transition-type() 保留给全局规则。
types 数组也可在事件回调 (onEnter、onExit 等) 的第二个参数中使用——参见 references/patterns.md。
当带有 transitionTypes 的 <Link> 触发导航时,过渡类型对在该导航期间进入/退出的所有 <ViewTransition> 都可用。一个带有类型映射的外部页面级 <ViewTransition> 会看到该类型并做出响应。具有简单字符串属性的内部 <ViewTransition> 也会进入——类型对它们无关紧要,因为简单字符串无论类型如何都会触发。
后续的 Suspense 内容显示——当流式数据在导航完成后加载时——是没有类型的单独过渡。这意味着在 Suspense 内容上使用类型键控的属性不起作用:
// 这在 Suspense 内容显示时不会动画化——那时类型已经消失
<ViewTransition enter={{ "nav-forward": "slide-up", default: "none" }} default="none">
<AsyncContent />
</ViewTransition>
当 Suspense 稍后解析时,会触发一个新的没有类型的过渡——因此 default: "none" 生效,没有任何动画。
对随导航直接进入/退出的 <ViewTransition> 使用类型映射。对 Suspense 内容显示使用简单的字符串属性。 有关完整示例,请参见下面"两种模式——可以通过适当的隔离共存"中的两层模式。
为两个 <ViewTransition> 组件分配相同的 name——一个在卸载的树中,一个在挂载的树中——以在它们之间进行动画过渡,就好像它们是同一个元素:
const HERO_IMAGE = 'hero-image';
function ListView({ onSelect }) {
return (
<ViewTransition name={HERO_IMAGE}>
<img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
</ViewTransition>
);
}
function DetailView() {
return (
<ViewTransition name={HERO_IMAGE}>
<img src="/full.jpg" />
</ViewTransition>
);
}
共享元素过渡的规则:
name 的 <ViewTransition>——使用全局唯一的名称 (用前缀或模块常量命名空间)。关于使用 onEnter、onExit、onUpdate、onShare 回调以及 instance 对象 (.old、.new、.group、.imagePair、.name) 进行命令式控制,请参见 references/patterns.md。始终从事件处理程序返回一个清理函数。每个 <ViewTransition> 在每个过渡中只触发一个事件——onShare 优先于 onEnter/onExit。
有条件地渲染 <ViewTransition> 本身——用 startTransition 切换:
{show && (
<ViewTransition enter="fade-in" exit="fade-out">
<Panel />
</ViewTransition>
)}
用稳定的 key 包装每个项目 (而不是包装器 div):
{items.map(item => (
<ViewTransition key={item.id}>
<ItemCard item={item} />
</ViewTransition>
))}
在 startTransition 内部触发重新排序将平滑地将每个项目动画化到其新位置。避免在列表和 <ViewTransition> 之间使用包装器 <div>——它们会阻止重新排序动画。
工作原理: startTransition 不需要异步工作来产生动画。视图过渡 API 捕获 DOM 的"之前"快照,然后 React 应用状态更新,API 捕获"之后"快照。只要项目在快照之间改变位置,动画就会运行——即使是像排序这样的纯同步本地状态更改。
key 强制重新进入在 <ViewTransition> 上使用 key 属性,当值改变时强制进行 enter/exit 动画——即使组件本身没有卸载:
<ViewTransition key={searchParams.toString()} enter="slide-up" exit="slide-down" default="none">
<ResultsGrid results={results} />
</ViewTransition>
当 key 改变时,React 会卸载并重新挂载 <ViewTransition>,这会在旧实例上触发 exit,在新实例上触发 enter。这对于动画化由 URL 参数、标签页切换或任何内容身份改变但组件类型保持相同的状态更改驱动的内容交换很有用。
Suspense 注意事项: 如果 <ViewTransition> 包装了一个 <Suspense>,更改 key 会重新挂载整个 Suspense 边界,重新触发数据获取。只在 Suspense 外部的 <ViewTransition> 上使用 key,或者接受重新获取。
最简单的方法:用单个 <ViewTransition> 包装 <Suspense>,实现从骨架屏到内容的零配置淡入淡出:
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Content />
</Suspense>
</ViewTransition>
对于方向性运动,给回退和内容分别使用单独的 <ViewTransition>。在内容上使用 default="none" 以防止在重新验证时重新动画化:
<Suspense
fallback={
<ViewTransition exit="slide-down">
<Skeleton />
</ViewTransition>
}
>
<ViewTransition default="none" enter="slide-up">
<AsyncContent />
</ViewTransition>
</Suspense>
为什么在回退上使用 exit 而在内容上使用 enter? 当 Suspense 解析时,两件事在一个过渡中同时发生:回退卸载 (exit) 和内容挂载 (enter)。回退向下滑动并淡出,而内容向上滑动并淡入——创建平滑的交接。交错的 CSS 时序 (enter 延迟 exit 的持续时间) 确保骨架屏在新内容到达之前离开。
用 <ViewTransition update="none"> 包装子元素,以防止它们在父元素更改时动画化:
<ViewTransition>
<div className={theme}>
<ViewTransition update="none">
{children}
</ViewTransition>
</div>
</ViewTransition>
有关更多模式 (隔离持久/浮动元素、可重用的动画折叠、使用 <Activity> 保留状态、使用 useOptimistic 排除元素),请参见 references/patterns.md。
<ViewTransition> 如何交互当过渡触发时,树中每个匹配触发器的 <ViewTransition> 都会同时参与。每个都有自己的 view-transition-name,浏览器会在单个 document.startViewTransition 调用中动画化所有它们。它们并行运行,而不是顺序运行。
这意味着在同一过渡期间触发的多个 <ViewTransition> 都会同时动画化。布局级别的淡入淡出 + 页面级别的向上滑动 + 每个项目的重新排序都在同一个 document.startViewTransition 中运行,会产生相互竞争的动画。但在不同过渡中触发的 <ViewTransition> (例如,导航 vs. 稍后的 Suspense 解析) 不会竞争——它们在不同的时刻动画化。
default="none"通过在只应为特定类型触发的 ViewTransitions 上禁用默认触发器来防止意外的动画:
// 仅当存在 'navigation-forward' 或 'navigation-back' 类型时动画化。
// 在所有其他过渡上静默 (Suspense 内容显示、状态更改等)
<ViewTransition
default="none"
enter={{
'navigation-forward': 'slide-in-from-right',
'navigation-back': 'slide-in-from-left',
default: 'none',
}}
exit={{
'navigation-forward': 'slide-out-to-left',
'navigation-back': 'slide-out-to-right',
default: 'none',
}}
>
{children}
</ViewTransition>
TypeScript 注意: 当向 enter/exit 传递对象时,ViewTransitionClassPerType 类型需要一个 default 键。始终在对象中包含 default: 'none' (或 'auto')——即使组件级别的 default 属性已设置,省略它也会导致类型错误。
没有 default="none",具有 default="auto" (隐式默认值) 的 <ViewTransition> 会在每次过渡时触发浏览器的淡入淡出——包括由子 Suspense 边界、useDeferredValue 更新或页面内的 startTransition 调用触发的过渡。
Next.js 重新验证: 这在 Next.js 中尤其重要——当 revalidateTag() 触发时 (来自 Server Action、webhook 或轮询),页面会重新渲染。没有 default="none",树中的每个 <ViewTransition> 都会重新动画化:内容再次向上滑动,东西闪烁。始终在内容 <ViewTransition> 上使用 default="none",并仅显式启用特定触发器 (enter, exit)。
有两种不同的视图过渡模式:
模式 A —— 方向性页面滑动 (例如,左/右导航):
<Link> 或 addTransitionType 上使用 transitionTypes 来标记导航方向<ViewTransition> 将类型映射到滑动类,并设置 default="none"模式 B —— Suspense 内容显示 (例如,流式数据):
transitionTypes<ViewTransition> 上使用简单的 enter="slide-up" / exit="slide-down"default="none" 防止在重新验证时重新动画化当它们在不同时刻触发时,这些模式可以共存。 导航滑动在导航过渡期间触发 (带有类型);Suspense 内容显示在数据流式传输时稍后触发 (没有类型)。在两个层级上使用 default="none" 可以防止交叉干扰——导航 VT 忽略 Suspense 解析,而 Suspense VT 忽略导航:
<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>
在方向性过渡上始终配对 enter 和 exit。 没有退出动画,旧页面会立即消失,而新页面在滚动位置 0 处滑入——这是一种令人不适的跳跃。退出滑动在过渡快照内掩盖了滚动变化,因为旧内容同时动画化退出。
当它们确实冲突时: 如果两个层级都使用 default="auto",或者如果布局级别的 <ViewTransition> 在与页面级别的向上滑动相同的过渡期间触发淡入淡出,它们会同时动画化并争夺注意力。冲突是关于同一时刻的动画,而不是关于在同一页面上使用两种模式。
将外部的方向性 <ViewTransition> 放在每个页面组件中——而不是放在布局中 (布局会持久存在且不会触发 enter/exit)。每个页面的包装器是最简洁的方法。
共享元素过渡 (name 属性) 可以与任一模式一起工作,因为 share 触发器优先于 enter/exit。
Next.js 支持 React 视图过渡。<ViewTransition> 开箱即用地支持由 startTransition 和 Suspense 触发的更新——无需配置。
要同时为 <Link> 导航添加动画,请在 next.config.js (或 next.config.ts) 中启用实验性标志:
const nextConfig = {
experimental: {
viewTransition: true,
},
};
module.exports = nextConfig;
此标志的作用: 它将每个 <Link> 导航包装在 document.startViewTransition 中,因此所有已挂载的 <ViewTransition> 组件都会参与每次链接点击。没有此标志,只有由 startTransition/Suspense 触发的过渡会动画化。这使得"多个 <ViewTransition> 如何交互"中的组合规则尤其重要:在布局级别的 <ViewTransition> 上使用 default="none" 以避免竞争动画。
有关包括 App Router 模式和 Server Component 注意事项的详细指南,请参见 references/nextjs.md。
要点:
<ViewTransition> 组件直接从 react 导入——无需 Next.js 特定的导入。startTransition + router.push() 配合使用,用于编程式导航。next/link 上的 transitionTypes 属性next/link 支持原生的 transitionTypes 属性——直接传递字符串数组,无需 'use client' 或包装器组件:
<Link href="/products/1" transitionTypes={['transition-to-detail']}>View Product</Link>
有关共享元素过渡和方向性动画的完整示例,请参见 references/nextjs.md。
始终尊重 prefers-reduced-motion。React 不会为此偏好自动禁用动画。将此添加到你的全局 CSS 中:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
或者通过检查媒体查询在 JavaScript 事件中有条件地禁用特定动画。
references/patterns.md —— 真实世界模式 (可搜索网格、展开/折叠、类型安全助手)、动画时序、视图过渡事件 (JavaScript Animations API) 和故障排除。references/css-recipes.md —— 即用型 CSS 动画配方 (滑动、淡入淡出、缩放、方向性导航和组合模式)。references/nextjs.md —— 详细的 Next.js 集成指南,包含 App Router 模式和 Server Component 注意事项。每周安装量
88
仓库
GitHub 星标数
24.1K
首次出现
今天
安全审计
安装于
cursor79
opencode79
antigravity78
gemini-cli78
codex78
deepagents77
React's View Transition API lets you animate between UI states using the browser's native document.startViewTransition under the hood. Declare what to animate with <ViewTransition>, trigger when with startTransition / useDeferredValue / Suspense, and control how with CSS classes or the Web Animations API. Unsupported browsers skip the animation and apply the DOM change instantly.
Every <ViewTransition> should answer: what spatial relationship or continuity does this animation communicate to the user? If you can't articulate it, don't add it.
From highest value to lowest — start from the top and only move down if your app doesn't already have animations at that level:
| Priority | Pattern | What it communicates | Example |
|---|---|---|---|
| 1 | Shared element (name) | "This is the same thing — I'm going deeper" | List thumbnail morphs into detail hero |
| 2 | Suspense reveal | "Data loaded, here's the real content" | Skeleton cross-fades into loaded page |
| 3 | List identity (per-item key) | "Same items, new arrangement" | Cards reorder during sort/filter |
| 4 | State change (enter/exit) | "Something appeared or disappeared" | Panel slides in on toggle |
| 5 | Route change (layout-level) |
Route-level transitions (#5) are the lowest priority because the URL change already signals a context switch. A blanket cross-fade on every navigation says nothing — it's visual noise. Prefer specific, intentional animations (#1–#4) over ambient page transitions.
Rule of thumb: at any given moment, only one level of the tree should be visually transitioning. If your pages already manage their own Suspense reveals or shared element morphs, adding a layout-level route transition on top produces double-animation where both levels fight for attention.
Not everything should slide. Match the animation to the spatial relationship:
| Context | Animation | Why |
|---|---|---|
| Detail page main content | enter="slide-up" | Reveals "deeper" content the user drilled into |
| Detail page outer wrapper | enter/exit type map for nav-forward | Navigating forward — horizontal direction |
| List / overview pages | Bare <ViewTransition> (fade) or default="none" | Lateral navigation — no spatial depth to communicate |
| Page headers / breadcrumbs |
When in doubt, use a bare <ViewTransition> (default cross-fade) or default="none". Only add directional motion (slide-up, slide-from-right) when it communicates spatial meaning.
<ViewTransition> and addTransitionType require react@canary or react@experimental. They are not in stable React (including 19.x). Before implementing, verify the project uses canary — check package.json for "react": "canary" or run npm ls react. If on stable, install canary: npm install react@canary react-dom@canary.<ViewTransition> ComponentWrap the elements you want to animate:
import { ViewTransition } from 'react';
<ViewTransition>
<Component />
</ViewTransition>
React automatically assigns a unique view-transition-name to the nearest DOM node inside each <ViewTransition>, and calls document.startViewTransition behind the scenes. Never call startViewTransition yourself — React coordinates all view transitions and will interrupt external ones.
React decides which type of animation to run based on what changed:
| Trigger | When it fires |
|---|---|
| enter | A <ViewTransition> is first inserted during a Transition |
| exit | A <ViewTransition> is first removed during a Transition |
| update | DOM mutations happen inside a <ViewTransition>, or the boundary changes size/position due to an immediate sibling |
| share | A named <ViewTransition> unmounts and another with the same name mounts in the same Transition (shared element transition) |
Only updates wrapped in startTransition, useDeferredValue, or Suspense activate <ViewTransition>. Regular setState updates immediately and does not animate.
<ViewTransition> only activates enter/exit if it appears before any DOM nodes in the component tree:
// Works — ViewTransition is before the DOM node
function Item() {
return (
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
);
}
// Broken — a <div> wraps the ViewTransition, preventing enter/exit
function Item() {
return (
<div>
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
</div>
);
}
Each prop controls a different animation trigger. Values can be:
"auto" — use the browser default cross-fade
"none" — disable this animation type
"my-class-name" — a custom CSS class
An object { [transitionType]: value } for type-specific animations (see Transition Types below)
<ViewTransition default="none" // disable everything not explicitly listed enter="slide-in" // CSS class for enter animations exit="slide-out" // CSS class for exit animations update="cross-fade" // CSS class for update animations share="morph" // CSS class for shared element animations />
If default is "none", all triggers are off unless explicitly listed.
Use the view transition pseudo-element selectors with the class name:
::view-transition-old(.slide-in) {
animation: 300ms ease-out slide-out-to-left;
}
::view-transition-new(.slide-in) {
animation: 300ms ease-out slide-in-from-right;
}
@keyframes slide-out-to-left {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-from-right {
from { transform: translateX(100%); opacity: 0; }
}
The pseudo-elements available are:
::view-transition-group(.class) — the container for the transition::view-transition-image-pair(.class) — contains old and new snapshots::view-transition-old(.class) — the outgoing snapshot::view-transition-new(.class) — the incoming snapshotaddTransitionTypeaddTransitionType lets you tag a transition with a string label so <ViewTransition> can pick different animations based on what caused the change. This is essential for directional navigation (forward vs. back) or distinguishing user actions (click vs. swipe vs. keyboard).
import { startTransition, addTransitionType } from 'react';
function navigate(url, direction) {
startTransition(() => {
addTransitionType(`navigation-${direction}`); // "navigation-forward" or "navigation-back"
setCurrentPage(url);
});
}
You can add multiple types to a single transition, and if multiple transitions are batched, all types are collected.
Pass an object instead of a string to any activation prop. Keys are transition type strings, values are CSS class names:
<ViewTransition
enter={{
'navigation-forward': 'slide-in-from-right',
'navigation-back': 'slide-in-from-left',
default: 'fade-in',
}}
exit={{
'navigation-forward': 'slide-out-to-left',
'navigation-back': 'slide-out-to-right',
default: 'fade-out',
}}
>
<Page />
</ViewTransition>
The default key inside the object is the fallback when no type matches. If any type has the value "none", the ViewTransition is disabled for that trigger.
:active-view-transition-type()React adds transition types as browser view transition types, enabling pure CSS scoping with :root:active-view-transition-type(type-name). Caveat: ::view-transition-old(*) / ::view-transition-new(*) match all named elements — the wildcard can override specific class-based animations. Prefer class-based props for per-component animations; reserve :active-view-transition-type() for global rules.
The types array is also available as the second argument in event callbacks (onEnter, onExit, etc.) — see references/patterns.md.
When a <Link> with transitionTypes triggers navigation, the transition type is available to all<ViewTransition>s that enter/exit during that navigation. An outer page-level <ViewTransition> with a type map sees the type and responds. Inner <ViewTransition>s with simple string props also enter — the type is irrelevant to them because simple strings fire regardless of type.
Subsequent Suspense reveals — when streamed data loads after navigation completes — are separate transitions with no type. This means type-keyed props on Suspense content don't work:
// This does NOT animate on Suspense reveal — the type is gone by then
<ViewTransition enter={{ "nav-forward": "slide-up", default: "none" }} default="none">
<AsyncContent />
</ViewTransition>
When Suspense resolves later, a new transition fires with no type — so default: "none" applies and nothing animates.
Use type maps for<ViewTransition>s that enter/exit directly with the navigation. Use simple string props for Suspense reveals. See the two-layer pattern in "Two Patterns — Can Coexist with Proper Isolation" below for a complete example.
Assign the same name to two <ViewTransition> components — one in the unmounting tree and one in the mounting tree — to animate between them as if they're the same element:
const HERO_IMAGE = 'hero-image';
function ListView({ onSelect }) {
return (
<ViewTransition name={HERO_IMAGE}>
<img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
</ViewTransition>
);
}
function DetailView() {
return (
<ViewTransition name={HERO_IMAGE}>
<img src="/full.jpg" />
</ViewTransition>
);
}
Rules for shared element transitions:
<ViewTransition> with a given name can be mounted at a time — use globally unique names (namespace with a prefix or module constant).For imperative control with onEnter, onExit, onUpdate, onShare callbacks and the instance object (.old, .new, .group, .imagePair, .name), see references/patterns.md. Always return a cleanup function from event handlers. Only one event fires per <ViewTransition> per Transition — takes precedence over /.
Conditionally render the <ViewTransition> itself — toggle with startTransition:
{show && (
<ViewTransition enter="fade-in" exit="fade-out">
<Panel />
</ViewTransition>
)}
Wrap each item (not a wrapper div) in <ViewTransition> with a stable key:
{items.map(item => (
<ViewTransition key={item.id}>
<ItemCard item={item} />
</ViewTransition>
))}
Triggering the reorder inside startTransition will smoothly animate each item to its new position. Avoid wrapper <div>s between the list and <ViewTransition> — they block the reorder animation.
How it works: startTransition doesn't need async work to animate. The View Transition API captures a "before" snapshot of the DOM, then React applies the state update, and the API captures an "after" snapshot. As long as items change position between snapshots, the animation runs — even for purely synchronous local state changes like sorting.
keyUse a key prop on <ViewTransition> to force an enter/exit animation when a value changes — even if the component itself doesn't unmount:
<ViewTransition key={searchParams.toString()} enter="slide-up" exit="slide-down" default="none">
<ResultsGrid results={results} />
</ViewTransition>
When the key changes, React unmounts and remounts the <ViewTransition>, which triggers exit on the old instance and enter on the new one. This is useful for animating content swaps driven by URL parameters, tab switches, or any state change where the content identity changes but the component type stays the same.
Caution with Suspense: If the <ViewTransition> wraps a <Suspense>, changing the key remounts the entire Suspense boundary, re-triggering the data fetch. Only use key on <ViewTransition> outside of Suspense, or accept the refetch.
The simplest approach: wrap <Suspense> in a single <ViewTransition> for a zero-config cross-fade from skeleton to content:
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Content />
</Suspense>
</ViewTransition>
For directional motion, give the fallback and content separate <ViewTransition>s. Use default="none" on the content to prevent re-animation on revalidation:
<Suspense
fallback={
<ViewTransition exit="slide-down">
<Skeleton />
</ViewTransition>
}
>
<ViewTransition default="none" enter="slide-up">
<AsyncContent />
</ViewTransition>
</Suspense>
Whyexit on the fallback and enter on the content? When Suspense resolves, two things happen simultaneously in one transition: the fallback unmounts (exit) and the content mounts (enter). The fallback slides down and fades out while the content slides up and fades in — creating a smooth handoff. The staggered CSS timing (enter delays by the exit duration) ensures the skeleton leaves before new content arrives.
Wrap children in <ViewTransition update="none"> to prevent them from animating when a parent changes:
<ViewTransition>
<div className={theme}>
<ViewTransition update="none">
{children}
</ViewTransition>
</div>
</ViewTransition>
For more patterns (isolate persistent/floating elements, reusable animated collapse, preserve state with <Activity>, exclude elements with useOptimistic), see references/patterns.md.
<ViewTransition>s InteractWhen a transition fires, every <ViewTransition> in the tree that matches the trigger participates simultaneously. Each gets its own view-transition-name, and the browser animates all of them inside a single document.startViewTransition call. They run in parallel, not sequentially.
This means multiple <ViewTransition>s that fire during the same transition all animate at once. A layout-level cross-fade + a page-level slide-up + per-item reorder all running in the same document.startViewTransition produces competing animations. But <ViewTransition>s that fire in different transitions (e.g., navigation vs. a later Suspense resolve) don't compete — they animate at different moments.
default="none" LiberallyPrevent unintended animations by disabling the default trigger on ViewTransitions that should only fire for specific types:
// Only animates when 'navigation-forward' or 'navigation-back' types are present.
// Silent on all other transitions (Suspense reveals, state changes, etc.)
<ViewTransition
default="none"
enter={{
'navigation-forward': 'slide-in-from-right',
'navigation-back': 'slide-in-from-left',
default: 'none',
}}
exit={{
'navigation-forward': 'slide-out-to-left',
'navigation-back': 'slide-out-to-right',
default: 'none',
}}
>
{children}
</ViewTransition>
TypeScript note: When passing an object to enter/exit, the ViewTransitionClassPerType type requires a default key. Always include default: 'none' (or 'auto') in the object — omitting it causes a type error even if the component-level default prop is set.
Without default="none", a <ViewTransition> with default="auto" (the implicit default) fires the browser's cross-fade on every transition — including ones triggered by child Suspense boundaries, useDeferredValue updates, or startTransition calls within the page.
Next.js revalidation: This is especially important in Next.js — when revalidateTag() fires (from a Server Action, webhook, or polling), the page re-renders. Without default="none", every <ViewTransition> in the tree re-animates: content slides up again, things flash. Always use default="none" on content <ViewTransition>s and only enable specific triggers (enter, exit) explicitly.
There are two distinct view transition patterns:
Pattern A — Directional page slides (e.g., left/right navigation):
transitionTypes on <Link> or addTransitionType to tag navigation direction<ViewTransition> on the page maps types to slide classes with default="none"Pattern B — Suspense content reveals (e.g., streaming data):
transitionTypes neededenter="slide-up" / exit="slide-down" on <ViewTransition>s around Suspense boundariesdefault="none" prevents re-animation on revalidationThese coexist when they fire at different moments. The nav slide fires during the navigation transition (with the type); the Suspense reveal fires later when data streams in (no type). default="none" on both layers prevents cross-interference — the nav VT ignores Suspense resolves, and the Suspense VT ignores navigations:
<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>
Always pairenter with exit on directional transitions. Without an exit animation, the old page disappears instantly while the new one slides in at scroll position 0 — a jarring jump. The exit slide masks the scroll change within the transition snapshot because the old content animates out simultaneously.
When they DO conflict: If both layers use default="auto", or if a layout-level <ViewTransition> fires a cross-fade during the same transition as a page-level slide-up, they animate simultaneously and fight for attention. The conflict is about same-moment animations, not about using both patterns on the same page.
Place the outer directional <ViewTransition> in each page component — not in a layout (layouts persist and don't trigger enter/exit). Per-page wrappers are the cleanest approach.
Shared element transitions (name prop) work alongside either pattern because the share trigger takes precedence over enter/exit.
Next.js supports React View Transitions. <ViewTransition> works out of the box for startTransition- and Suspense-triggered updates — no config needed.
To also animate <Link> navigations, enable the experimental flag in next.config.js (or next.config.ts):
const nextConfig = {
experimental: {
viewTransition: true,
},
};
module.exports = nextConfig;
What this flag does: It wraps every <Link> navigation in document.startViewTransition, so all mounted <ViewTransition> components participate in every link click. Without this flag, only startTransition/Suspense-triggered transitions animate. This makes the composition rules in "How Multiple <ViewTransition>s Interact" especially important: use default="none" on layout-level <ViewTransition>s to avoid competing animations.
For a detailed guide including App Router patterns and Server Component considerations, see references/nextjs.md.
Key points:
<ViewTransition> component is imported from react directly — no Next.js-specific import.startTransition + router.push() for programmatic navigation.transitionTypes prop on next/linknext/link supports a native transitionTypes prop — pass an array of strings directly, no 'use client' or wrapper component needed:
<Link href="/products/1" transitionTypes={['transition-to-detail']}>View Product</Link>
For full examples with shared element transitions and directional animations, see references/nextjs.md.
Always respect prefers-reduced-motion. React does not disable animations automatically for this preference. Add this to your global CSS:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
Or disable specific animations conditionally in JavaScript events by checking the media query.
references/patterns.md — Real-world patterns (searchable grids, expand/collapse, type-safe helpers), animation timing, view transition events (JavaScript Animations API), and troubleshooting.references/css-recipes.md — Ready-to-use CSS animation recipes (slide, fade, scale, directional nav, and combined patterns).references/nextjs.md — Detailed Next.js integration guide with App Router patterns and Server Component considerations.Weekly Installs
88
Repository
GitHub Stars
24.1K
First Seen
Today
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
cursor79
opencode79
antigravity78
gemini-cli78
codex78
deepagents77
agentation - AI智能体可视化UI反馈工具,连接人眼与代码的桥梁
5,400 周安装
| "Going to a new place" |
| Cross-fade between pages |
Bare <ViewTransition> (fade) |
| Small, fast-loading metadata — slide feels excessive |
| Secondary section on same page | enter="slide-up" | Second Suspense boundary streaming in after the header |
| Revalidation / background refresh | default="none" | Data refreshed silently — animation would be distracting |
onShareonEnteronExit