Next.js React ViewTransition 实战:为商品图库实现共享元素变形、方向性导航与无动画降级
2026/9/7 7:49:15 网站建设 项目流程

Next.js React ViewTransition 实战:为商品图库实现共享元素变形、方向性导航与无动画降级

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

本文以 Next.js 官方评测用例agent-043-view-transitions为蓝本,讲解如何在一个商品图库应用中完整落地 React<ViewTransition>:从缩略图到详情大图的位置/尺寸变形(共享元素 morph)、前进/后退方向性滑动、Suspense 骨架屏到真实内容的平滑切换,以及如何用 CSS 满足prefers-reduced-motion。读完你可以掌握官方 View Transitions 指南 中的四类核心模式,并理解 评测断言 所定义的验收标准。

任务需求:四条硬性要求

原始需求文档 PROMPT.md 只有一句话,但信息量完整:

Add view transitions to this product gallery app. When navigating from the grid to a product detail page, the product image should smoothly morph between the thumbnail and the large detail image using a shared element transition. Page navigations should slide directionally. Suspense loading states should transition smoothly from skeleton to content. All animations must respect the user's prefers-reduced-motion preference.

拆解为四个可验证的功能点:

  1. 共享元素变形:网格页商品缩略图与详情页大图之间平滑 morph;
  2. 方向性页面滑动:前进/后退导航有不同的滑动方向;
  3. Suspense 加载状态过渡:骨架屏(skeleton)到内容的切换要平滑;
  4. 无障碍:所有动画必须尊重prefers-reduced-motion

起点应用的结构

该评测内置了一个极简商品图库,代码结构如下(路径均相对evals/evals/agent-043-view-transitions/):

  • app/page.tsx:首页,用<Suspense fallback={<ProductGridSkeleton />}>包裹ProductGrid
  • app/ProductGrid.tsx:渲染商品网格,每个商品是一个<Link href={/product/${product.slug}}>,内含一个className="product-image"的色块占位图;
  • app/ProductSkeleton.tsx:骨架屏组件;
  • app/product/[slug]/page.tsx:详情页,含product-hero大图色块、"← Back to products" 返回链接,以及一个内部延迟 100ms 的异步组件ProductDetails(同样被<Suspense>包裹,fallback 为DetailsSkeleton);
  • lib/products.ts:4 个商品的静态数据;
  • app/globals.css:仅含网格布局与 skeleton 的pulse动画,尚无任何 view transition 规则。

关键观察:起点应用中没有任何ViewTransitiontransitionTypesprefers-reduced-motion代码,且网格图与详情图都是纯 CSS 色块(product.color),因此"同一商品"的视觉连续性只能靠共享元素变形来表达——这正是该用例要考察的核心。

无需配置:View Transition 开箱即用

评测源码 头部注释明确指出:View transitions 不需要任何next.config开关——experimental.viewTransition曾是空操作(inert)并已在 PR #96098 中移除,文档现在写明"works with no configuration"。这一点与官方指南 view-transitions.mdx 一致:

View transitions work in the App Router with no configuration. The App Router uses React canary releases, which contain all stable React 19 changes as well as newer features likeViewTransition.

React 的<ViewTransition>组件集成了浏览器 View Transitions API:你只需给应保持一致性的元素命名,浏览器自动在旧、新位置之间做动画。指南同时注明浏览器兼容边界:React 的集成使用了较新的 API 特性(transition types 与view-transition-class),可用版本为 Chromium 125+ 及较新的 Safari/Firefox;无浏览器支持时应用照常工作,只是不播放动画——这是一个优雅的降级特性。

模式一:共享元素变形(Thumbnail → Hero Morph)

官方指南将其定义为最重要的过渡模式:"when an object persists across a cut, it communicates continuity"——对象跨路由保持存在,向用户传达"这是同一件东西"。

实现方式:两侧使用相同的name

网格侧,把 ProductGrid.tsx 中的.product-image色块用<ViewTransition name={...}>包裹:

import { ViewTransition } from 'react' import Link from 'next/link' import { products } from '@/lib/products' export function ProductGrid() { return ( <div className="product-grid"> {products.map((product) => ( <Link key={product.slug} href={`/product/${product.slug}`} className="product-card" transitionTypes={['nav-forward']} > <ViewTransition name={`product-${product.slug}`}> <div className="product-image" style={{ backgroundColor: product.color }} /> </ViewTransition> <h2>{product.name}</h2> <p>${product.price}</p> </Link> ))} </div> ) }

详情页侧,把 product/[slug]/page.tsx 中的.product-hero用同一个name包裹:

<ViewTransition name={`product-${product.slug}`} default="none"> <div className="product-hero" style={{ backgroundColor: product.color }} /> </ViewTransition>

nameprop 创造"身份"(identity):React 找到新旧两页中同名元素,自动在它们的位置与尺寸之间做动画,变形本身不需要额外 prop。点击缩略图时,色块从网格单元平滑缩放并平移到详情大图位置;返回时反向播放——用户看到的是"一个对象在移动",而不是"两个对象在交换"。

一个重要的时序细节

指南特别指出:morph 只在目标内容于导航同一 commit 中渲染(通常是预取过的缓存页)时才会配对播放。若目标页先挂起(suspends)到 fallback,就不会形成新旧配对,内容到达时改走它自己的 enter 动画。对这个评测应用来说意味着:详情页首屏若先显示DetailsSkeleton,hero 色块不在 Suspense 边界内(它在 fallback 之外立即渲染),因此配对成立;但如果把 hero 也移进异步组件,morph 就会退化为 enter 动画——这是调试"为什么我的 morph 没生效"时的首要排查点。

可选的自定义:share="morph"+default="none"

变形无需任何 CSS 即可工作。要自定义它(例如加 blur 柔化插值过程),配合两个 prop:

<ViewTransition name={`product-${product.slug}`} share="morph" default="none">
  • share="morph"会给 transition 打上morph类,可用伪元素精确命中:
::view-transition-group(.morph) { animation-duration: 400ms; } ::view-transition-image-pair(.morph) { animation-name: via-blur; } @keyframes via-blur { 30% { filter: blur(3px); } }
  • default="none"是关键防御:不加它,页面上任意一次 transition 都会让每个命名元素各自跑一遍默认的交叉淡入淡出(crossfade)。官方指南警告:给命名对加了default="none"之后必须保留显式的share,否则"the pair silently stops morphing"——这是静默失效,没有任何报错。

模式二:方向性导航(Directional Slides)

transitionTypes给导航打标

前进与后退导航若不加以区分,用户无法从动画判断自己走深了还是退回来了。官方指南的做法是在<Link>上打类型标签(该 prop 的 API 说明见 link 组件文档,useRouter 的push()/replace()同样支持):

// 网格 → 详情:前进 <Link href={`/product/${product.slug}`} transitionTypes={['nav-forward']}> // 详情 → 网格:返回 <Link href="/" transitionTypes={['nav-back']} className="back-link"> ← Back to products </Link>

对应到本例:ProductGrid.tsx 的链接标nav-forward,详情页 顶部已有的back-linknav-back。注意:类型不是自动推断的,需要根据应用自身的导航层级手动决定哪些链接是 forward、哪些是 back。

把类型映射为动画:enter/exit对象

在两个页面的内容外层包一个映射 transition type → 动画类的ViewTransition

<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" > {/* page content */} </ViewTransition>

导航携带nav-forward类型时,旧内容向左滑出、新内容从右侧滑入;nav-back则相反。default: 'none'保证未携带类型的 transition(浏览器返回键、router.refresh()、Suspense reveal)不触发方向性动画。

指南强调了一个易错点:包装器必须放在page.tsx而不是 layout 里——layout 在导航间持续存在,其 enter/exit 永远不会触发。本例的 layout.tsx 是纯 RootLayout,天然不适合。

方向性 CSS

::view-transition-old(.nav-forward) { --slide-offset: -60px; animation: 150ms ease-in both fade reverse, 400ms ease-in-out both slide reverse; } ::view-transition-new(.nav-forward) { --slide-offset: 60px; animation: 210ms ease-out 150ms both fade, 400ms ease-in-out both slide; } ::view-transition-old(.nav-back) { --slide-offset: 60px; animation: 150ms ease-in both fade reverse, 400ms ease-in-out both slide reverse; } ::view-transition-new(.nav-back) { --slide-offset: -60px; animation: 210ms ease-out 150ms both fade, 400ms ease-in-out both slide; } @keyframes slide { from { translate: var(--slide-offset); } to { translate: 0; } }

60px 的位移足够传达方向,又不至于让用户追着快速移动的元素看。另外注意:浏览器触发的返回(后退键/滑动手势)不携带 transition type,方向性滑动不会播放——但模式一的共享元素 morph 仍然生效,因为 morph 靠name配对,不依赖类型。

模式三:Suspense 骨架屏的 Reveal 过渡

起点应用的两个 Suspense 边界(app/page.tsx 的ProductGridSkeleton、详情页 的DetailsSkeleton)目前都是"瞬时替换":骨架消失、内容闪现。指南的语义是"垂直方向编码层级——上滑传达到达,下滑传达离开",两者构成一次交接(handoff)。

写法:fallback 用exit动画,内容用enter动画

import { Suspense, ViewTransition } from 'react' <Suspense fallback={ <ViewTransition exit="slide-down" default="none"> <ProductGridSkeleton /> </ViewTransition> } > <ViewTransition enter="slide-up" default="none"> <ProductGrid /> </ViewTransition> </Suspense>

详情页的<Suspense fallback={<DetailsSkeleton />}>同理处理。default="none"防止这对元素在无关 transition(如共享元素 morph)中误触动画。

配套的 CSS 采用非对称时序——这是指南刻意设计的节奏:

:root { --duration-exit: 150ms; --duration-enter: 210ms; --duration-move: 400ms; } ::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; } @keyframes fade { from { filter: blur(3px); opacity: 0; } to { filter: blur(0); opacity: 1; } } @keyframes slide-y { from { transform: translateY(10px); } to { transform: translateY(0); } }

设计意图:旧内容(骨架屏)要快速离开(150ms)以免与新内容争抢注意力;新内容更平缓地到达,且 enter 的淡入动画延迟了var(--duration-exit)毫秒——即旧内容完全退场后新内容才可见。本例 globals.css 中骨架屏现有的pulse无限循环动画不受影响,它与 view transition 规则正交。

模式四:尊重 prefers-reduced-motion

PROMPT.md 的第四条要求,也是 EVAL.ts 中唯一检查 CSS 而非 TSX 的测试。评测注释点破了一个关键误区:"React does NOT disable animations automatically"——prefers-reduced-motion完全由你的 CSS 负责处理。方向性滑动是模拟跨越视口的物理位移,是最常见的 motion-sickness 触发源,必须降级。

指南给出的最简方案是归零所有动画时长:

@media (prefers-reduced-motion: reduce) { ::view-transition-old(*), ::view-transition-new(*), ::view-transition-group(*) { animation-duration: 0s !important; animation-delay: 0s !important; } }

无动画时内容瞬间切换,这正是浏览器的默认行为。指南也提到更精细的做法:保留 crossfade 与 opacity 过渡、只移除位置类位移(对应其"Next steps"章节引用的外部讨论,主题即"无运动并不总等于 prefers-reduced-motion")。

评测如何验收:从 EVAL.ts 看完整检查清单

EVAL.ts 用 7 个 vitest 用例对applibcomponentssrc目录下的 TS/TSX 源码(剥离注释后做正则匹配)和全部 CSS 文件做静态检查。它既是验收标准,也是"常见错误清单":

断言校验点对应本文章节
ViewTransition is imported from react必须import { ViewTransition } from 'react',而非手动调用document.startViewTransition或从第三方库导入前置条件
Shared element transitions use named ViewTransition源码中至少 2 处<ViewTransition name=...>(网格侧 + 详情侧成对出现)模式一
Link uses transitionTypes for directional navigation源码出现transitionTypes模式二
Suspense content uses ViewTransition with enter or exit存在enter=exit=用法模式三
default="none" prevents unintended animations存在default="none",防止页面每次 transition 都整页 crossfade模式一/三
CSS handles prefers-reduced-motionCSS 中出现prefers-reduced-motion模式四
CSS defines view transition animationsCSS 使用::view-transition-old/new/group伪元素模式一~四

评测头部注释还点名了 Agent 的典型翻车方式,值得逐条对照:

  • 绕过 React 组件:直接调document.startViewTransition而不是声明式<ViewTransition>
  • 错误的导入来源:从第三方库而非'react'导入;
  • 不知道next/linktransitionTypesprop:方向性导航无从谈起;
  • 忘记default="none":结果是页面上每次 transition 都让所有命名元素交叉淡入淡出,视觉上"全页面在闪";
  • 跳过 reduced-motion:如前所述,React 不会替你禁用动画。

四种模式与用户心智模型

官方指南结尾用一张表总结每种模式回答的用户问题,对本例完全适用:

模式传达的信号本例落点
Shared element (morph)"Same thing, going deeper"网格.product-image↔ 详情.product-hero
Suspense reveal"Data loaded"两处骨架屏 → 真实内容
Directional slide"Going forward / coming back"nav-forward/nav-back标签
Same-route crossfade"Same place, different content"本例未涉及(可参考指南 Step 4 的key+share="auto"模式)

另有两个通用细节来自指南,落地时值得采纳:其一,方向性滑动期间::view-transition覆盖层会吞掉指针事件,加一条::view-transition { pointer-events: none; }可让动画期间页面保持可点击(named 参与者的命中测试在动画期间仍会被跳过,因此保持过渡短小);其二,若有跨导航的固定 header,给它viewTransitionName并在 CSS 中animation: none+z-index提权,避免滑动时 header 跟着走而破坏空间锚点——本例 layout.tsx 没有 header,可跳过。

小结

  • 零配置:App Router 下 React View Transition 无需next.config开关;不支持的浏览器中应用照常运行。
  • name建立身份:同名ViewTransition在新旧页面间自动 morph;自定义时share+default="none"必须成对出现。
  • transitionTypes声明方向:手动在<Link>上打nav-forward/nav-back标签,enter/exit对象把类型映射到 CSS 类;包装器放page.tsx而非 layout。
  • Suspense reveal 用非对称时序:exit 150ms 快退场,enter 延迟一个 exit 时长再淡入,传达"交接"。
  • 无障碍必须手写@media (prefers-reduced-motion: reduce)下将::view-transition-*animation-duration/delay归零。
  • 验收标准可机器检查:EVAL.ts 的 7 条正则断言可直接作为自测清单。

延伸阅读仓库内的官方指南全文:docs/01-app/02-guides/view-transitions.mdx。

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询