geneseed 4.9.3 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +14 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/claude-code/README.md +1 -1
  5. package/adapters/opencode/plugins/geneseed-guard.js +20 -1
  6. package/docs/README.md +1 -1
  7. package/docs/concepts/collaboration.md +1 -1
  8. package/docs/concepts/enforcement-table.md +2 -2
  9. package/docs/concepts/foreman-mode.md +1 -1
  10. package/docs/concepts/hooks.md +3 -2
  11. package/docs/concepts/kits.md +79 -0
  12. package/docs/concepts/loop-branch.md +1 -1
  13. package/docs/concepts/loop-bricks.md +1 -1
  14. package/docs/concepts/loop-contracts.md +1 -1
  15. package/docs/concepts/loop-gates.md +1 -1
  16. package/docs/concepts/loop-notes.md +1 -1
  17. package/docs/concepts/loop-risk.md +1 -1
  18. package/docs/concepts/loop-templates.md +1 -1
  19. package/docs/concepts/loops.md +1 -1
  20. package/docs/concepts/lsp.md +1 -1
  21. package/docs/extending.md +108 -4
  22. package/docs/guides/install-scripted.md +1 -1
  23. package/docs/reference/glossary.md +1 -0
  24. package/docs/reference/plugin-guard.md +1 -1
  25. package/docs/understand/enforced-vs-asked.md +1 -1
  26. package/docs/understand/machine-claude.md +1 -1
  27. package/docs/web-ui.md +4 -0
  28. package/harness.config.json +1 -1
  29. package/js/build/args.mjs +27 -6
  30. package/js/build/driver.mjs +29 -8
  31. package/js/build/emit-claude.mjs +3 -1
  32. package/js/build/generate.mjs +10 -5
  33. package/js/build/render.mjs +94 -11
  34. package/js/build/source.mjs +117 -4
  35. package/js/cli-table.json +15 -0
  36. package/js/hosts/hooks.mjs +2 -1
  37. package/js/hosts/installs.mjs +35 -2
  38. package/js/hosts/loss.mjs +23 -2
  39. package/js/hosts/native.mjs +14 -4
  40. package/js/inspect/checks-authoring.mjs +6 -3
  41. package/js/inspect/checks-build.mjs +7 -1
  42. package/js/inspect/checks-counts.mjs +153 -6
  43. package/js/inspect/checks-repo.mjs +95 -20
  44. package/js/inspect/cost.mjs +1 -1
  45. package/js/inspect/diff.mjs +3 -1
  46. package/js/inspect/inventory.mjs +1 -0
  47. package/js/inspect/status.mjs +44 -3
  48. package/js/inspect/validate.mjs +3 -3
  49. package/js/maintain/setup.mjs +30 -6
  50. package/js/maintain/update.mjs +1 -1
  51. package/js/web/actions.mjs +30 -6
  52. package/js/web/api.mjs +40 -3
  53. package/js/web/routes.mjs +2 -1
  54. package/package.json +1 -1
  55. package/registry.json +63 -0
  56. package/src/AGENT.md.tmpl +2 -2
  57. package/src/agents/devops-engineer.md +45 -0
  58. package/src/agents/docker-expert.md +45 -0
  59. package/src/agents/kubernetes-specialist.md +45 -0
  60. package/src/agents/reviewer.md +2 -0
  61. package/src/agents/spring-boot-engineer.md +48 -0
  62. package/src/agents/spring-reviewer.md +49 -0
  63. package/src/agents/spring-security.md +50 -0
  64. package/src/kits/spring-boot/rules.md +10 -0
  65. package/src/kits/spring-boot.json +23 -0
  66. package/src/laws/universal.md +13 -13
  67. package/src/licenses/mattpocock-skills.txt +21 -0
  68. package/src/licenses/piomin-spring-boot.txt +190 -0
  69. package/src/licenses/superpowers.txt +21 -0
  70. package/src/licenses/vibe-wise.txt +21 -0
  71. package/src/skills/brainstorm/builder-check.md +2 -0
  72. package/src/skills/brainstorm.md +4 -0
  73. package/src/skills/codebase-design.md +4 -0
  74. package/src/skills/daydream/VENDOR.md +7 -7
  75. package/src/skills/debug.md +5 -1
  76. package/src/skills/develop.md +2 -0
  77. package/src/skills/geneseed-code-review.md +6 -2
  78. package/src/skills/git-history.md +2 -0
  79. package/src/skills/handoff.md +2 -0
  80. package/src/skills/learn-mode.md +3 -2
  81. package/src/skills/parallel-agents.md +2 -0
  82. package/src/skills/pipeline.md +2 -0
  83. package/src/skills/plan.md +4 -0
  84. package/src/skills/prototype.md +2 -0
  85. package/src/skills/react-view-transitions/AGENTS.md +646 -221
  86. package/src/skills/react-view-transitions/SKILL.md +28 -16
  87. package/src/skills/react-view-transitions/VENDOR.md +10 -8
  88. package/src/skills/react-view-transitions/references/css-recipes.md +72 -2
  89. package/src/skills/react-view-transitions/references/implementation.md +14 -26
  90. package/src/skills/react-view-transitions/references/nextjs.md +94 -37
  91. package/src/skills/react-view-transitions/references/patterns.md +60 -48
  92. package/src/skills/react-view-transitions/references/troubleshooting.md +47 -0
  93. package/src/skills/research.md +2 -0
  94. package/src/skills/review-response.md +2 -0
  95. package/src/skills/rule.md +2 -0
  96. package/src/skills/ship.md +2 -0
  97. package/src/skills/skill-forge.md +1 -0
  98. package/src/skills/spring-boot/cloud.md +103 -0
  99. package/src/skills/spring-boot/java-modern.md +71 -0
  100. package/src/skills/spring-boot/logging.md +89 -0
  101. package/src/skills/spring-boot/security.md +112 -0
  102. package/src/skills/spring-boot/web.md +112 -0
  103. package/src/skills/spring-boot.md +57 -0
  104. package/src/skills/spring-data-jpa/patterns.md +68 -0
  105. package/src/skills/spring-data-jpa.md +81 -0
  106. package/src/skills/spring-testing.md +93 -0
  107. package/src/skills/wayfinder.md +2 -0
  108. package/src/upstreams.json +59 -0
  109. package/themes/_TEMPLATE.json +9 -0
  110. package/themes/biker.json +9 -0
  111. package/themes/commentator.json +9 -0
  112. package/themes/cyberpunk.json +9 -0
  113. package/themes/gamer.json +9 -0
  114. package/themes/imperial.json +9 -0
  115. package/themes/joker.json +9 -0
  116. package/themes/marvin.json +9 -0
  117. package/themes/mean.json +9 -0
  118. package/themes/military.json +9 -0
  119. package/themes/neutral.json +9 -0
  120. package/themes/pirate.json +9 -0
  121. package/themes/sports.json +9 -0
  122. package/themes/verstappen.json +9 -0
  123. package/themes/wizard.json +9 -0
  124. package/web/dist/assets/{Activity-C8sZVIUU.js → Activity-BjyJaf8e.js} +1 -1
  125. package/web/dist/assets/{ActivityDetail-Cc6vWUS4.js → ActivityDetail-Bo9nRDNV.js} +1 -1
  126. package/web/dist/assets/{Dashboard-C5ATa3ow.js → Dashboard-BQJGU7vE.js} +2 -2
  127. package/web/dist/assets/{Docs-tDW7i7mo.js → Docs-DLVckkew.js} +1 -1
  128. package/web/dist/assets/{GreenhouseView-zk2Uzc3A.js → GreenhouseView--DML7CW3.js} +1 -1
  129. package/web/dist/assets/{Installs-CB_rvbBP.js → Installs-CxCiln1F.js} +2 -2
  130. package/web/dist/assets/{JournalView-Bfj27XHC.js → JournalView-ymgZleHi.js} +1 -1
  131. package/web/dist/assets/{Laws-BA3CzUf5.js → Laws-BkKnZOBI.js} +1 -1
  132. package/web/dist/assets/Library-C8ercIZv.js +1 -0
  133. package/web/dist/assets/{Loops-DaPk-ckL.js → Loops-C95zhbFl.js} +1 -1
  134. package/web/dist/assets/{OperatorHudView-CyiGhiHk.js → OperatorHudView-B9qwc3w3.js} +1 -1
  135. package/web/dist/assets/{Personal-Bwd27CmT.js → Personal-DyR72AF-.js} +1 -1
  136. package/web/dist/assets/{Tabs-BOD7Kaz1.js → Tabs-ChCr_HqV.js} +1 -1
  137. package/web/dist/assets/api-BfpZmUMI.js +1 -0
  138. package/web/dist/assets/{drawable-CyLe1gtW.js → drawable-CgYi0Dtg.js} +1 -1
  139. package/web/dist/assets/index-B32EvfDT.js +2 -0
  140. package/web/dist/index.html +2 -2
  141. package/web/dist/assets/Library-BWwiy9Vb.js +0 -1
  142. package/web/dist/assets/api-CiXlkbe0.js +0 -1
  143. package/web/dist/assets/index-DQXG_mqo.js +0 -2
@@ -2,47 +2,11 @@
2
2
 
3
3
  **Version 1.0.0**
4
4
  Vercel Engineering
5
- March 2026
6
5
 
7
6
  > **Note:**
8
- > This document is mainly for agents and LLMs to follow when implementing
9
- > view transitions in React applications. Humans may also find it useful,
10
- > but guidance here is optimized for automation and consistency by
11
- > AI-assisted workflows.
12
-
13
- ---
14
-
15
- ## Abstract
16
-
17
- 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, Suspense reveals, list reorder, directional navigation, and Next.js integration. Includes a step-by-step implementation workflow, ready-to-use CSS animation recipes, and common mistake warnings.
18
-
19
- ---
20
-
21
- ## Table of Contents
22
-
23
- 1. [Core Reference](#when-to-animate)
24
- - [When to Animate](#when-to-animate)
25
- - [Availability](#availability)
26
- - [Core Concepts](#core-concepts)
27
- - [Styling with View Transition Classes](#styling-with-view-transition-classes)
28
- - [Transition Types](#transition-types)
29
- - [Shared Element Transitions](#shared-element-transitions)
30
- - [Common Patterns](#common-patterns)
31
- - [How Multiple VTs Interact](#how-multiple-vts-interact)
32
- - [Next.js Integration](#nextjs-integration)
33
- - [Accessibility](#accessibility)
34
- 2. [Implementation Workflow](#implementation-workflow)
35
- - [Step 1: Audit the App](#step-1-audit-the-app)
36
- - [Step 2: Add CSS Recipes](#step-2-add-css-recipes)
37
- - [Step 3: Isolate Persistent Elements](#step-3-isolate-persistent-elements)
38
- - [Step 4: Add Directional Page Transitions](#step-4-add-directional-page-transitions)
39
- - [Step 5: Add Suspense Reveals](#step-5-add-suspense-reveals)
40
- - [Step 6: Add Shared Element Transitions](#step-6-add-shared-element-transitions)
41
- - [Step 7: Verify Each Navigation Path](#step-7-verify-each-navigation-path)
42
- - [Common Mistakes](#common-mistakes)
43
- 3. [Patterns and Guidelines](#patterns-and-guidelines)
44
- 4. [CSS Animation Recipes](#css-animation-recipes)
45
- 5. [View Transitions in Next.js](#view-transitions-in-nextjs)
7
+ > This standalone guide is compiled from `SKILL.md` and its reference files
8
+ > for agents that consume a single `AGENTS.md` document. Edit the source
9
+ > files, not this compiled copy.
46
10
 
47
11
  ---
48
12
 
@@ -60,7 +24,7 @@ Implement **all** applicable patterns from this list, in this order:
60
24
  | 2 | **Suspense reveal** | "Data loaded" |
61
25
  | 3 | **List identity** (per-item `key`) | "Same items, new arrangement" |
62
26
  | 4 | **State change** (`enter`/`exit`) | "Something appeared/disappeared" |
63
- | 5 | **Route change** (layout-level) | "Going to a new place" |
27
+ | 5 | **Route change** (page-level) | "Going to a new place" |
64
28
 
65
29
  This is an implementation order, not a "pick one" list. Implement every pattern that fits the app. Only skip a pattern if the app has no use case for it.
66
30
 
@@ -81,7 +45,13 @@ Reserve directional slides for hierarchical navigation (list → detail) and ord
81
45
 
82
46
  - **Next.js:** Do **not** install `react@canary` — the App Router already bundles React canary internally. `ViewTransition` works out of the box. `npm ls react` may show a stable-looking version; this is expected.
83
47
  - **Without Next.js:** Install `react@canary react-dom@canary` (`ViewTransition` is not in stable React).
84
- - Browser support: Chromium 111+, Firefox 144+, Safari 18.2+. Graceful degradation.
48
+ - Browser support: Chromium 125+ (React needs the v2 object form of `startViewTransition`), Firefox 144+, Safari 18.2+. Graceful degradation on unsupported browsers.
49
+
50
+ ---
51
+
52
+ ## Implementation Workflow
53
+
54
+ When adding view transitions to an existing app, **follow [references/implementation.md](references/implementation.md) step by step.** Start with the audit — do not skip it. Use [references/css-recipes.md](references/css-recipes.md) for the applicable CSS and adapt it to the app.
85
55
 
86
56
  ---
87
57
 
@@ -103,29 +73,37 @@ React auto-assigns a unique `view-transition-name` and calls `document.startView
103
73
 
104
74
  | Trigger | When it fires |
105
75
  |---------|--------------|
106
- | **enter** | VT first inserted during a Transition |
107
- | **exit** | VT first removed during a Transition |
108
- | **update** | DOM mutations inside a VT. With nested VTs, mutation applies to the innermost one |
109
- | **share** | Named VT unmounts and another with same `name` mounts in same Transition |
76
+ | **enter** | `<ViewTransition>` first inserted during a Transition |
77
+ | **exit** | `<ViewTransition>` first removed during a Transition |
78
+ | **update** | DOM mutations inside a `<ViewTransition>`, or the boundary itself changing size/position due to an immediate sibling. With nested VTs, mutation applies to the innermost one |
79
+ | **share** | Named VT unmounts and another with same `name` mounts in the same Transition |
110
80
 
111
81
  Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate.
112
82
 
113
83
  ### Critical Placement Rule
114
84
 
115
- VT only activates enter/exit if it appears **before any DOM nodes**:
85
+ `<ViewTransition>` only activates enter/exit if it appears **before any DOM nodes**:
116
86
 
117
87
  ```jsx
118
88
  // Works
119
- <ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition>
89
+ <ViewTransition enter="auto" exit="auto">
90
+ <div>Content</div>
91
+ </ViewTransition>
120
92
 
121
- // Broken — div wraps the VT
122
- <div><ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition></div>
93
+ // Broken — div wraps the VT, suppressing enter/exit
94
+ <div>
95
+ <ViewTransition enter="auto" exit="auto">
96
+ <div>Content</div>
97
+ </ViewTransition>
98
+ </div>
123
99
  ```
124
100
 
125
101
  ---
126
102
 
127
103
  ## Styling with View Transition Classes
128
104
 
105
+ ### Props
106
+
129
107
  Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations.
130
108
 
131
109
  ```jsx
@@ -141,11 +119,13 @@ If `default` is `"none"`, all triggers are off unless explicitly listed.
141
119
  - `::view-transition-group(.class)` — container
142
120
  - `::view-transition-image-pair(.class)` — old + new pair
143
121
 
122
+ See [references/css-recipes.md](references/css-recipes.md) for ready-to-use animation recipes.
123
+
144
124
  ---
145
125
 
146
126
  ## Transition Types
147
127
 
148
- Tag transitions with `addTransitionType` so VTs can pick different animations. Call it multiple times to stack types — different VTs in the tree react to different types:
128
+ Tag transitions with `addTransitionType` so VTs can pick different animations based on context. Call it multiple times to stack types — different VTs in the tree react to different types:
149
129
 
150
130
  ```jsx
151
131
  startTransition(() => {
@@ -155,7 +135,7 @@ startTransition(() => {
155
135
  });
156
136
  ```
157
137
 
158
- Map types to CSS classes. Works on `enter`, `exit`, **and** `share`:
138
+ Pass an object to map types to CSS classes. Works on `enter`, `exit`, **and** `share`:
159
139
 
160
140
  ```jsx
161
141
  <ViewTransition
@@ -178,16 +158,36 @@ Map types to CSS classes. Works on `enter`, `exit`, **and** `share`:
178
158
  >
179
159
  ```
180
160
 
181
- **TypeScript:** `ViewTransitionClassPerType` requires a `default` key.
161
+ **TypeScript:** `ViewTransitionClassPerType` requires a `default` key in the object.
162
+
163
+ For apps with multiple pages, extract the type-keyed VT into a reusable wrapper:
164
+
165
+ ```jsx
166
+ export function DirectionalTransition({ children }: { children: React.ReactNode }) {
167
+ return (
168
+ <ViewTransition
169
+ enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
170
+ exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
171
+ default="none"
172
+ >
173
+ {children}
174
+ </ViewTransition>
175
+ );
176
+ }
177
+ ```
182
178
 
183
179
  ### `router.back()` and Browser Back Button
184
180
 
185
- `router.back()` and the browser's back/forward buttons do **not** trigger view transitions (`popstate` is synchronous, incompatible with `startViewTransition`). Use `router.push()` with an explicit URL instead.
181
+ `router.back()` and the browser's back/forward buttons carry **no transition types**, so type-keyed animations (directional slides) resolve to their `default` and don't play — untyped shared-element morphs still apply. For typed animations, use `router.push()` with an explicit URL.
186
182
 
187
183
  ### Types and Suspense
188
184
 
189
185
  Types are available during navigation but **not** during subsequent Suspense reveals (separate transitions, no type). Use type maps for page-level enter/exit; use simple string props for Suspense reveals.
190
186
 
187
+ ### Shared Element Readiness
188
+
189
+ A shared element transition can pair elements only when both the old and new views are rendered in the same Transition. If incoming content suspends, only its fallback exists for that update; the resolved content appears in a later Suspense transition and can be animated separately.
190
+
191
191
  ---
192
192
 
193
193
  ## Shared Element Transitions
@@ -199,15 +199,16 @@ Same `name` on two VTs — one unmounting, one mounting — creates a shared ele
199
199
  <img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
200
200
  </ViewTransition>
201
201
 
202
- // Other view — same name
202
+ // On the other view — same name
203
203
  <ViewTransition name="hero-image">
204
204
  <img src="/full.jpg" />
205
205
  </ViewTransition>
206
206
  ```
207
207
 
208
- - Only one VT with a given `name` can be mounted at a time — use unique names. Watch for reusable components: if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Either make the name conditional (via a prop) or move the named VT out of the shared component into the specific consumer.
209
- - `share` takes precedence over `enter`/`exit`. Think through each navigation path: when no pair forms, `enter`/`exit` fires instead. Consider whether the element needs a fallback animation for those paths.
210
- - Never use fade-out exit on pages with shared morphs — use directional slide.
208
+ - Only one VT with a given `name` can be mounted at a time — use unique names (`photo-${id}`). Watch for reusable components: if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Either make the name conditional (via a prop) or move the named VT out of the shared component into the specific consumer.
209
+ - `share` takes precedence over `enter`/`exit`. Think through each navigation path: when no matching pair forms (e.g., the target page doesn't have the same name), `enter`/`exit` fires instead. Consider whether the element needs a fallback animation for those paths.
210
+ - Two ways a wired-up morph silently never fires: (1) `default="none"` with no explicit `share` prop — share resolves to none; (2) type-keyed `share` where the navigation never adds the type — a plain link click resolves the map's `default`. Every link that should morph must add the type (`transitionTypes` on `next/link`, or `addTransitionType`).
211
+ - Never use a fade-out exit on pages with shared morphs — use a directional slide instead.
211
212
 
212
213
  ---
213
214
 
@@ -231,9 +232,13 @@ Same `name` on two VTs — one unmounting, one mounting — creates a shared ele
231
232
 
232
233
  Trigger inside `startTransition`. Avoid wrapper `<div>`s between list and VT.
233
234
 
235
+ ### Layout Displacement Morph
236
+
237
+ Only content inside an activated boundary animates position — everything else teleports to its new layout spot. Wrap the sibling content below a growing/shrinking list in a bare `<ViewTransition>` so it glides instead of jumping. See [Layout Displacement Morph](references/patterns.md#layout-displacement-morph).
238
+
234
239
  ### Composing Shared Elements with List Identity
235
240
 
236
- Shared elements and list identity are independent concerns — don't confuse one for the other. When a list item contains a shared element, use two nested `<ViewTransition>` boundaries:
241
+ Shared elements and list identity are independent concerns — don't confuse one for the other. When a list item contains a shared element (e.g., an image that morphs into a detail view), use two nested `<ViewTransition>` boundaries:
237
242
 
238
243
  ```jsx
239
244
  {items.map(item => (
@@ -248,7 +253,7 @@ Shared elements and list identity are independent concerns — don't confuse one
248
253
  ))}
249
254
  ```
250
255
 
251
- The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
256
+ The outer VT handles list reorder/enter animations. The inner VT handles the cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
252
257
 
253
258
  ### Force Re-Enter with `key`
254
259
 
@@ -258,7 +263,7 @@ The outer VT handles list reorder/enter. The inner VT handles cross-route shared
258
263
  </ViewTransition>
259
264
  ```
260
265
 
261
- **Caution:** Wrapping `<Suspense>` with key remounts the boundary and refetches.
266
+ **Caution:** If wrapping `<Suspense>`, changing `key` remounts the boundary and refetches.
262
267
 
263
268
  ### Suspense Fallback to Content
264
269
 
@@ -276,15 +281,19 @@ Directional reveal:
276
281
  </Suspense>
277
282
  ```
278
283
 
284
+ For more patterns, see [references/patterns.md](references/patterns.md).
285
+
279
286
  ---
280
287
 
281
288
  ## How Multiple VTs Interact
282
289
 
283
- Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions don't compete.
290
+ Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions (navigation vs later Suspense resolve) don't compete.
291
+
292
+ ### Use `default="none"` Deliberately
284
293
 
285
- ### Use `default="none"` Liberally
294
+ Without it, every VT fires the browser cross-fade on **every** transition — Suspense resolves, `useDeferredValue` updates, background revalidations. Use `default="none"` on named/shared elements and type-keyed page VTs.
286
295
 
287
- Without it, every VT fires the browser cross-fade on **every** transition. Always use `default="none"` and explicitly enable only desired triggers.
296
+ But it also turns off `update` (layout/reflow morphs) and `share` (a named pair with no explicit `share` prop never morphs). Keyed list items and displaced siblings *want* update — leave them bare or set `update="auto"`.
288
297
 
289
298
  ### Two Patterns Coexist
290
299
 
@@ -295,47 +304,48 @@ They coexist because they fire at different moments. `default="none"` on both pr
295
304
 
296
305
  ### Nested VT Limitation
297
306
 
298
- When a parent VT exits, nested VTs inside it do **not** fire their own enter/exit — only the outermost VT animates. Per-item staggered animations during page navigation are not possible today. See [react#36135](https://github.com/facebook/react/pull/36135) for an experimental opt-in fix.
307
+ When a parent VT mounts/unmounts **as one unit** with nested VTs inside it, the nested ones do not fire their own enter/exit — only the outermost VT animates. (A child VT mounted inside a *persistent* parent VT fires enter/exit normally.) Per-item staggered animations during page navigation are not currently available in Next.js; see [troubleshooting](references/troubleshooting.md) for the upstream experimental status.
299
308
 
300
309
  ---
301
310
 
302
311
  ## Next.js Integration
303
312
 
304
- See the [View Transitions in Next.js](#view-transitions-in-nextjs) section below.
313
+ For Next.js integration (`transitionTypes` on `next/link` and `useRouter`, App Router patterns, Server Components), see [references/nextjs.md](references/nextjs.md).
305
314
 
306
315
  ---
307
316
 
308
317
  ## Accessibility
309
318
 
310
- Always add reduced motion CSS to your global stylesheet:
319
+ Always add the reduced motion CSS from [references/css-recipes.md](references/css-recipes.md#reduced-motion) to your global stylesheet.
311
320
 
312
- ```css
313
- @media (prefers-reduced-motion: reduce) {
314
- ::view-transition-old(*),
315
- ::view-transition-new(*),
316
- ::view-transition-group(*) {
317
- animation-duration: 0s !important;
318
- animation-delay: 0s !important;
319
- }
320
- }
321
- ```
321
+ ---
322
+
323
+ ## Reference Files
324
+
325
+ - **[references/implementation.md](references/implementation.md)** — Step-by-step implementation workflow.
326
+ - **[references/patterns.md](references/patterns.md)** — Patterns, animation timing, and events API.
327
+ - **[references/troubleshooting.md](references/troubleshooting.md)** — Symptom-driven debugging and runtime limitations.
328
+ - **[references/css-recipes.md](references/css-recipes.md)** — Ready-to-use CSS animation recipes.
329
+ - **[references/nextjs.md](references/nextjs.md)** — Next.js App Router patterns and Server Component details.
322
330
 
323
331
  ---
324
332
 
325
333
  # Implementation Workflow
326
334
 
327
- **Follow these steps in order.** Start with the audit — do not skip it. Copy the CSS recipes from the CSS Recipes section below — do not write your own animation CSS.
335
+ Follow these steps in order when adding view transitions to an app. Each step builds on the previous one.
336
+
337
+ Use the official [React `<ViewTransition>` reference](https://react.dev/reference/react/ViewTransition) and [Next.js guide](https://nextjs.org/docs/app/guides/view-transitions) for API behavior. This file focuses on audit order, integration decisions, and verification.
328
338
 
329
339
  ## Step 1: Audit the App
330
340
 
331
341
  Before writing any code, scan the codebase thoroughly. Search for:
332
342
 
333
- - **Every `<Link>` and `router.push`** — open every file that contains one
334
- - **Every `<Suspense>` boundary** — check what its fallback renders
335
- - **Every page/route component** — each needs a VT placement decision
336
- - **Persistent elements** (headers, navbars, sidebars) — need `viewTransitionName` isolation
337
- - **Shared visual elements** on both source and target views
338
- - **Skeleton-to-content control pairs** — if a fallback renders a control that also exists in the real content, both need a matching `viewTransitionName`
343
+ - **Every `<Link>` and `router.push`** — these are your navigation triggers. Open every file that contains one.
344
+ - **Every `<Suspense>` boundary** — each one is a candidate for a reveal animation. Check what its fallback renders.
345
+ - **Every page/route component** — list them all. Each page needs a VT placement decision.
346
+ - **Persistent elements** — headers, navbars, sidebars, sticky controls that stay on screen across navigations. These need `viewTransitionName` isolation.
347
+ - **Shared visual elements** — images, cards, or avatars that appear on both a source and target view (e.g., a thumbnail in a list and the same image on a detail page).
348
+ - **Skeleton-to-content control pairs** — if a Suspense fallback renders a control (search input, tab bar) that also exists in the real content, both need a matching `viewTransitionName`.
339
349
 
340
350
  Then classify every navigation and produce a navigation map:
341
351
 
@@ -349,29 +359,30 @@ Then classify every navigation and produce a navigation map:
349
359
  | (Suspense) | (content loads) | — | slide-up reveal |
350
360
  ```
351
361
 
352
- For each shared element (`name` prop), note where a pair forms and where it doesn't — this determines whether you need `enter`/`exit` as a fallback alongside `share`.
362
+ For each shared element (`name` prop), note every navigation where a pair forms and where it doesn't — this determines whether you need `enter`/`exit` as a fallback alongside `share`.
353
363
 
354
364
  ## Step 2: Add CSS Recipes
355
365
 
356
- Copy the **complete** CSS recipe set from the CSS Animation Recipes section below into your global stylesheet. Don't write your own — the recipes handle staggered timing, motion blur, and reduced motion.
366
+ Choose the animation pattern from the audit and this skill's guidance, then copy only the applicable sections from [css-recipes.md](css-recipes.md). Always include reduced motion. Add live-root, persistent-element, backdrop, or floating-element rules only when the audit found those surfaces.
367
+
368
+ Customize timing after the structure works. Keep ordinary crossfades opacity-only; scope blur to a specific shared morph when it is intentional.
357
369
 
358
370
  ## Step 3: Isolate Persistent Elements
359
371
 
372
+ For every persistent element identified in Step 1, add a `viewTransitionName` style to pull it out of the page content's transition snapshot:
373
+
360
374
  ```jsx
361
375
  <header style={{ viewTransitionName: "site-header" }}>...</header>
362
376
  ```
363
377
 
364
- ```css
365
- ::view-transition-group(site-header) {
366
- animation: none;
367
- z-index: 100;
368
- }
369
- ```
378
+ Then add the [Persistent Element Isolation](css-recipes.md#persistent-element-isolation) CSS (prevents the element from animating during page transitions). If the element uses `backdrop-blur` or `backdrop-filter`, use the [Backdrop-Blur Workaround](css-recipes.md#backdrop-blur-workaround) instead.
370
379
 
371
- For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround instead.
380
+ If a Suspense fallback mirrors a persistent control (e.g., a skeleton search input), give both the real control and the skeleton the same `viewTransitionName` so they morph in place.
372
381
 
373
382
  ## Step 4: Add Directional Page Transitions
374
383
 
384
+ For hierarchical navigations identified in Step 1, tag the navigation direction using `addTransitionType` inside `startTransition`:
385
+
375
386
  ```jsx
376
387
  startTransition(() => {
377
388
  addTransitionType('nav-forward');
@@ -379,19 +390,29 @@ startTransition(() => {
379
390
  });
380
391
  ```
381
392
 
382
- Wrap each **page component** (not layout) in a type-keyed VT:
393
+ Then wrap each **page component** (not layout) in a type-keyed `<ViewTransition>`:
383
394
 
384
395
  ```jsx
385
396
  <ViewTransition
386
- enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
387
- exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
397
+ enter={{
398
+ "nav-forward": "nav-forward",
399
+ "nav-back": "nav-back",
400
+ default: "none",
401
+ }}
402
+ exit={{
403
+ "nav-forward": "nav-forward",
404
+ "nav-back": "nav-back",
405
+ default: "none",
406
+ }}
388
407
  default="none"
389
408
  >
390
409
  <div>...page content...</div>
391
410
  </ViewTransition>
392
411
  ```
393
412
 
394
- Extract into a reusable component so every page doesn't repeat the type map:
413
+ The `nav-forward` and `nav-back` CSS classes from [Directional Navigation](css-recipes.md#directional-navigation) produce horizontal slides. For simpler apps where directional motion isn't needed, a bare `<ViewTransition default="none">` wrapper with `enter="fade-in"` / `exit="fade-out"` works too.
414
+
415
+ Extract this into a reusable component so every page doesn't repeat the verbose type map:
395
416
 
396
417
  ```jsx
397
418
  export function DirectionalTransition({ children }: { children: React.ReactNode }) {
@@ -407,83 +428,89 @@ export function DirectionalTransition({ children }: { children: React.ReactNode
407
428
  }
408
429
  ```
409
430
 
410
- **Rules:** Always pair `enter` with `exit`. Always include `default: "none"`. Place in page components, not layouts. Only use directional slides for hierarchical navigation or ordered sequences (prev/next).
431
+ This also becomes the single place to adjust if you add new transition types later.
432
+
433
+ **Rules:**
434
+ - Always pair `enter` with `exit` — without an exit animation, the old page disappears instantly while the new one animates in.
435
+ - Always include `default: "none"` in type map objects and `default="none"` on the component — otherwise it fires on every transition.
436
+ - Place the directional `<ViewTransition>` in each page component, not in a layout. Layouts persist across navigations and never trigger enter/exit.
437
+ - Only use directional slides for hierarchical navigation or ordered sequences (prev/next). Lateral/sibling navigation (tab-to-tab) should use a bare `<ViewTransition>` (cross-fade) or `default="none"`.
411
438
 
412
439
  ## Step 5: Add Suspense Reveals
413
440
 
441
+ For every `<Suspense>` boundary identified in Step 1, wrap the fallback and content in separate `<ViewTransition>`s:
442
+
414
443
  ```jsx
415
- <Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
416
- <ViewTransition enter="slide-up" default="none"><AsyncContent /></ViewTransition>
444
+ <Suspense
445
+ fallback={
446
+ <ViewTransition exit="slide-down">
447
+ <Skeleton />
448
+ </ViewTransition>
449
+ }
450
+ >
451
+ <ViewTransition enter="slide-up" default="none">
452
+ <AsyncContent />
453
+ </ViewTransition>
417
454
  </Suspense>
418
455
  ```
419
456
 
420
- Use `default="none"` on content VT. Use simple string props (not type maps) — Suspense resolves have no type.
457
+ This example uses `slide-down` / `slide-up` for directional vertical motion. For a simpler reveal, a bare `<ViewTransition>` around the `<Suspense>` gives a cross-fade with zero configuration. Choose based on the spatial meaning described in the main skill.
458
+
459
+ **Rules:**
460
+ - Always use `default="none"` on the content `<ViewTransition>` to prevent re-animation on revalidation or unrelated transitions.
461
+ - Use simple string props (not type maps) on Suspense `<ViewTransition>`s — Suspense resolves fire as separate transitions with no type, so type-keyed props won't match.
462
+ - A fallback/content `share` pair morphs between snapshots. Use it only when that interpolation is desired and does not distort layout or geometry.
463
+ - If the same element appears in **both** the fallback and the content (a title, a heading), it flickers on reveal — an opacity dip. Render it **outside** the `<Suspense>` boundary (or pin it), so it isn't in both. See [Suspense reveal flicker](patterns.md#suspense-reveal-flicker).
421
464
 
422
465
  ## Step 6: Add Shared Element Transitions
423
466
 
467
+ For every shared visual element identified in Step 1, add matching named `<ViewTransition>` wrappers on both the source and target views:
468
+
424
469
  ```jsx
425
- // Source view
470
+ // On the source view (e.g., list/grid page)
426
471
  <ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
427
472
  <Image src={photo.src} ... />
428
473
  </ViewTransition>
429
474
 
430
- // Target view — same name
475
+ // On the target view (e.g., detail page) — same name
431
476
  <ViewTransition name={`photo-${photo.id}`} share="morph">
432
477
  <Image src={photo.src} ... />
433
478
  </ViewTransition>
434
479
  ```
435
480
 
436
- When list items contain shared elements, compose both patterns — two independent layers:
437
-
438
- ```jsx
439
- {items.map(item => (
440
- <ViewTransition key={item.id}> {/* list identity */}
441
- <Link href={`/detail/${item.id}`}>
442
- <ViewTransition name={`item-${item.id}`} share="morph" default="none"> {/* shared element */}
443
- <Image src={item.image} ... />
444
- </ViewTransition>
445
- </Link>
446
- </ViewTransition>
447
- ))}
448
- ```
481
+ The `share="morph"` class uses the [Shared Element Morph](css-recipes.md#shared-element-morph) recipe (controlled duration + motion blur). For a simpler cross-fade, use `share="auto"` (browser default).
449
482
 
450
- The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
483
+ When list items contain shared elements, compose both patterns with two nested `<ViewTransition>` layers — an outer keyed VT for list identity and an inner named VT for the cross-route pair. See [Composing Shared Elements with List Identity](../SKILL.md#composing-shared-elements-with-list-identity).
451
484
 
452
- **Rules:** Names must be globally unique. Add `default="none"` on list-side shared elements.
485
+ **Rules:**
486
+ - Names must be globally unique — use prefixes like `photo-${id}`.
487
+ - Add `default="none"` on list-side shared elements to prevent per-item cross-fades on filter/search updates.
488
+ - The target must be **in the DOM at navigation time** for the pair to form. If it's behind a Suspense fallback (not rendered yet), no pair forms and it won't morph. It works when the target is present at the snapshot — render it above the data boundary, or have its data **cached/prefetched** so it resolves in time.
453
489
 
454
490
  ## Step 7: Verify Each Navigation Path
455
491
 
456
- Walk through every row in the navigation map from Step 1:
492
+ Walk through every row in the navigation map from Step 1 and confirm:
457
493
 
458
- - Does the VT mount/unmount, or stay mounted (same-route)?
494
+ - Does the VT mount/unmount on this navigation, or does it stay mounted (same-route)?
459
495
  - For named VTs: does a shared pair form? If not, does `enter`/`exit` provide a fallback?
460
496
  - Does `default="none"` block an animation you actually want?
461
- - Do persistent elements stay static?
497
+ - Do persistent elements stay static (not sliding with page content)?
462
498
  - Do Suspense reveals animate independently from directional navigations?
463
499
 
464
- ---
465
-
466
- ## Common Mistakes
467
-
468
- - **Bare VT without `default="none"`** — fires cross-fade on every transition
469
- - **Directional VT in a layout** — layouts persist, enter/exit won't fire on route changes
470
- - **Fade-out exit with shared morphs** — conflicts with morph, use directional slide
471
- - **Writing custom animation CSS** — use the recipes
472
- - **Missing `default: "none"` in type-keyed objects** — TypeScript requires it, fallback is `"auto"`
473
- - **Type maps on Suspense reveals** — Suspense resolves have no type, use string props
474
- - **Raw `viewTransitionName` CSS to trigger animations** — React only starts view transitions when `<ViewTransition>` components are in the tree. Bare `viewTransitionName` is for isolating elements, not triggering animations.
475
- - **`update` trigger for same-route navigations** — nested VTs steal the mutation from the parent. Use `key` + `name` + `share` instead.
476
- - **Named VT in a reusable component** — if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Make the name conditional or move it to the specific consumer.
477
- - **`router.back()` for back navigation** — `router.back()` triggers synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.
500
+ If any path produces no animation or competing animations, use the symptom-driven [troubleshooting guide](troubleshooting.md).
478
501
 
479
- For Next.js-specific steps, see the Next.js section below.
502
+ For Next.js-specific implementation steps (`transitionTypes` on `<Link>`, prefetch behavior, and same-route dynamic segments), see [nextjs.md](nextjs.md).
480
503
 
481
504
  ---
482
505
 
483
506
  # Patterns and Guidelines
484
507
 
508
+ Use the official [React `<ViewTransition>` reference](https://react.dev/reference/react/ViewTransition) for API mechanics. This file collects reusable implementation patterns and failure modes from production apps.
509
+
485
510
  ## Searchable Grid with `useDeferredValue`
486
511
 
512
+ `useDeferredValue` makes filter updates a transition, activating `<ViewTransition>`:
513
+
487
514
  ```tsx
488
515
  'use client';
489
516
 
@@ -506,10 +533,20 @@ export default function SearchableGrid({ itemsPromise }) {
506
533
  }
507
534
  ```
508
535
 
509
- Per-item named VTs in deferred lists trigger cross-fades on every keystroke. Fix with `default="none"`.
536
+ Per-item `<ViewTransition name={...}>` inside a deferred list triggers cross-fades on every keystroke. Fix with `default="none"`:
537
+
538
+ ```tsx
539
+ {filteredItems.map(item => (
540
+ <ViewTransition key={item.id} name={`item-${item.id}`} share="morph" default="none">
541
+ <ItemCard item={item} />
542
+ </ViewTransition>
543
+ ))}
544
+ ```
510
545
 
511
546
  ## Card Expand/Collapse with `startTransition`
512
547
 
548
+ Toggle between grid and detail view with shared element morph:
549
+
513
550
  ```tsx
514
551
  'use client';
515
552
 
@@ -549,58 +586,168 @@ export default function ItemGrid({ items }) {
549
586
  }
550
587
  ```
551
588
 
589
+ ## Type-Safe Transition Helpers
590
+
591
+ Use `as const` arrays and derived types to prevent ID clashes:
592
+
593
+ ```tsx
594
+ const transitionTypes = ['default', 'transition-to-detail', 'transition-to-list'] as const;
595
+ const animationTypes = ['auto', 'none', 'animate-slide-from-left', 'animate-slide-from-right'] as const;
596
+
597
+ type TransitionType = (typeof transitionTypes)[number];
598
+ type AnimationType = (typeof animationTypes)[number];
599
+ type TransitionMap = { default: AnimationType } & Partial<Record<Exclude<TransitionType, 'default'>, AnimationType>>;
600
+
601
+ export function HorizontalTransition({ children, enter, exit }: {
602
+ children: React.ReactNode;
603
+ enter: TransitionMap;
604
+ exit: TransitionMap;
605
+ }) {
606
+ return <ViewTransition enter={enter} exit={exit}>{children}</ViewTransition>;
607
+ }
608
+ ```
609
+
552
610
  ## Cross-Fade Without Remount
553
611
 
554
- Omit `key` to trigger update (cross-fade) instead of exit + enter. Avoids Suspense remount:
612
+ Omit `key` to trigger an update (cross-fade) instead of exit + enter. Avoids Suspense remount/refetch:
555
613
 
556
614
  ```jsx
557
- <ViewTransition><TabPanel tab={activeTab} /></ViewTransition>
615
+ <ViewTransition>
616
+ <TabPanel tab={activeTab} />
617
+ </ViewTransition>
558
618
  ```
559
619
 
620
+ Use `key` when content identity changes (state resets). Omit for cross-fades (tabs, panels, carousel).
621
+
560
622
  ## Isolate Elements from Parent Animations
561
623
 
562
- Persistent elements get captured in page's transition snapshot. Fix with `viewTransitionName`:
624
+ Pull an element out of the animated `root` snapshot by giving it its own `view-transition-name`. **`view-transition-name: none` is a no-op** — it's the CSS default, so the element stays in `root` (a common flicker bug). Use a real, unique name, then neutralize with `<ViewTransition default="none">` (no CSS) or CSS (needed for `z-index`/`display` control — see [css-recipes.md](css-recipes.md#persistent-element-isolation)).
625
+
626
+ - **Persistent chrome** (nav, sidebar, player bar): `<nav style={{ viewTransitionName: 'persistent-nav' }}>` + isolation CSS. `<ViewTransition default="none">` works too, but its auto-name can't take `z-index`/backdrop `display:none` — hand-name when you need those.
627
+ - **Floating elements** (popovers, menus): left open, they're captured in `root` and flicker on settle. Real name + isolation ([Floating Element Isolation](css-recipes.md#floating-element-isolation-popovers-menus-tooltips-control-clusters)). A static name is fine if only one is mounted (`unmountOnHide`); native top-layer (`popover`/`<dialog>`) settle-flicker is a browser limit.
628
+ - **Naming an interactive element has a cost:** named participants are skipped by hit-testing while a transition runs ([csswg#10930](https://github.com/w3c/csswg-drafts/issues/10930)) — clicks and hover fall through to whatever is beneath. Portal named popovers/menus; rendered inline in a clickable row, mid-transition clicks activate the row and read as outside-clicks that close the popover.
629
+ - **Third-party floating components** (toast libraries, portals you don't render): put the name on an always-mounted wrapper you own — `<div style={{ viewTransitionName: 'toaster' }} className="pointer-events-none fixed inset-0">`. Library containers often unmount when empty, so naming them directly leaves the group unpinned exactly when a toast appears mid-transition. Name a dialog's backdrop separately from its panel so each pins independently.
630
+
631
+ ## Suspense reveal flicker
632
+
633
+ An element rendered in **both** the fallback and the content flickers (opacity dip) on reveal — it fades against itself. Not a morph. **Fix: render it outside the `<Suspense>` boundary** (mount once, above it), or pin it with a `view-transition-name`.
563
634
 
564
635
  ```jsx
565
- <nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
636
+ <h1>{title}</h1>
637
+ <Suspense fallback={<BodySkeleton />}><Body /></Suspense>
566
638
  ```
567
639
 
568
- ```css
569
- ::view-transition-group(persistent-nav) { animation: none; z-index: 100; }
640
+ Don't put a manual `viewTransitionName` on the root DOM node inside `<ViewTransition>` — React's auto-name overrides it.
641
+
642
+ ## Sliding Indicator (tabs)
643
+
644
+ One shared-name indicator rendered under the **active** tab morphs between positions on change (slide the group, disable old/new — see [Sliding Indicator](css-recipes.md#sliding-indicator-tab-underline--segmented-pill)). Render it only under the active tab so exactly one element holds `indicatorName`; use a distinct `indicatorName` per tab strip. Trigger the state change inside `startTransition` so the move animates. Whatever owns `active` drives it — local state here, routing in Next (see [Routing-Driven Tabs](nextjs.md#routing-driven-tabs)).
645
+
646
+ ```tsx
647
+ import { useState, useTransition, ViewTransition } from 'react';
648
+
649
+ export function Tabs({ tabs, indicatorName = 'tab-indicator' }) {
650
+ const [active, setActive] = useState(tabs[0].value);
651
+ const [, startTransition] = useTransition();
652
+ return (
653
+ <nav>
654
+ {tabs.map(t => (
655
+ <button key={t.value} type="button"
656
+ aria-current={active === t.value ? 'page' : undefined}
657
+ onClick={() => startTransition(() => setActive(t.value))}>
658
+ <span>{t.label}</span>
659
+ {active === t.value && (
660
+ <ViewTransition name={indicatorName} share="tab-underline">
661
+ <span className="active-underline" aria-hidden />
662
+ </ViewTransition>
663
+ )}
664
+ </button>
665
+ ))}
666
+ </nav>
667
+ );
668
+ }
570
669
  ```
571
670
 
572
- Same for floating elements (popovers, tooltips). Global fix: `::view-transition-group(*) { z-index: 100; }`
671
+ Because the state change is a transition, if the newly-active tab renders suspending content the whole update — indicator **and** `aria-current` — waits for it to commit, and the strip feels dead on click. Give the controls an immediate value with `useOptimistic` (drive `aria-current` from it) so feedback is instant while the content streams. The routing variant ([Routing-Driven Tabs](nextjs.md#routing-driven-tabs)) does exactly this: optimistic `aria-current`, committed `active` for the bar.
573
672
 
574
- ## Shared Controls Between Skeleton and Content
673
+ ## Layout Displacement Morph
575
674
 
576
- Give matching controls the same `viewTransitionName`. Don't put manual `viewTransitionName` on root DOM node inside `<ViewTransition>`.
675
+ Only content inside an activated boundary animates position — everything else teleports to its new layout spot. When a list grows or shrinks, wrap the sibling content below it so it glides instead of jumping:
676
+
677
+ ```jsx
678
+ <FavoritesList /> {/* rows enter/exit */}
679
+ <ViewTransition> {/* bare: update enabled */}
680
+ <section>
681
+ <h2>You Might Also Like</h2>
682
+ <Recommendations />
683
+ </section>
684
+ </ViewTransition>
685
+ ```
686
+
687
+ The section — heading included — morphs as one group when rows above are added or removed. Nothing inside the section changed; the *displacement* is the update.
688
+
689
+ - React only measures boundaries that are direct children of nodes along the changed path — a VT buried under an extra wrapper element won't activate. Place the boundary as a direct sibling of the changing content.
690
+ - Sometimes the better fix is no morph at all: pad fixed-size lists to a constant slot count with invisible fillers so the grid never changes height and nothing below it moves.
691
+ - `default="none"` disables exactly this morph — it turns off `update`. Named/shared elements get `default="none"`; displaced siblings and keyed list items stay bare or set `update="auto"`.
577
692
 
578
693
  ## Reusable Animated Collapse
579
694
 
580
695
  ```jsx
581
696
  function AnimatedCollapse({ open, children }) {
582
697
  if (!open) return null;
583
- return <ViewTransition enter="expand-in" exit="collapse-out">{children}</ViewTransition>;
698
+ return (
699
+ <ViewTransition enter="expand-in" exit="collapse-out">
700
+ {children}
701
+ </ViewTransition>
702
+ );
584
703
  }
704
+
705
+ // Usage: toggle with startTransition
706
+ <button onClick={() => startTransition(() => setOpen(o => !o))}>Toggle</button>
707
+ <AnimatedCollapse open={open}><SectionContent /></AnimatedCollapse>
585
708
  ```
586
709
 
587
- ## Preserve State with Activity
710
+ ## Composing with Activity
711
+
712
+ `Activity` is orthogonal to view transitions: it preserves the state of a hidden subtree, `ViewTransition` animates it. Compose them for an in-page show/hide (drawer, panel, tab body) that keeps its scroll/form state while it animates in and out:
588
713
 
589
714
  ```jsx
590
715
  <Activity mode={isVisible ? 'visible' : 'hidden'}>
591
- <ViewTransition enter="slide-in" exit="slide-out"><Sidebar /></ViewTransition>
716
+ <ViewTransition enter="slide-in" exit="slide-out">
717
+ <Sidebar />
718
+ </ViewTransition>
592
719
  </Activity>
593
720
  ```
594
721
 
722
+ Only reach for Activity when there's state worth preserving — a stateless element (e.g. the sliding indicator above) gains nothing from it. In Next.js, layout-hosted chrome already persists across navigations without Activity (see [nextjs.md](nextjs.md#layout-level-viewtransition)).
723
+
595
724
  ## Exclude Elements with `useOptimistic`
596
725
 
597
- `useOptimistic` values update before snapshot, excluding them from animation. Use for controls; use committed state for animated content.
726
+ `useOptimistic` values update before the transition snapshot, excluding them from animation. Use for controls (labels); use committed state for animated content:
727
+
728
+ ```tsx
729
+ const [sort, setSort] = useState('newest');
730
+ const [optimisticSort, setOptimisticSort] = useOptimistic(sort);
731
+
732
+ function cycleSort() {
733
+ const nextSort = getNextSort(optimisticSort);
734
+ startTransition(() => {
735
+ setOptimisticSort(nextSort); // before snapshot — no animation
736
+ setSort(nextSort); // between snapshots — animates
737
+ });
738
+ }
739
+
740
+ <button>Sort: {LABELS[optimisticSort]}</button>
741
+ {items.sort(comparators[sort]).map(item => (
742
+ <ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
743
+ ))}
744
+ ```
598
745
 
599
746
  ---
600
747
 
601
748
  ## View Transition Events
602
749
 
603
- Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return cleanup. `onShare` takes precedence.
750
+ Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Return a cleanup function to cancel your animation when the transition finishes. `onShare` takes precedence over `onEnter`/`onExit`.
604
751
 
605
752
  ```jsx
606
753
  <ViewTransition
@@ -616,7 +763,9 @@ Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return
616
763
  </ViewTransition>
617
764
  ```
618
765
 
619
- `instance`: `.old`, `.new`, `.group`, `.imagePair`, `.name`
766
+ The `instance` object: `instance.old`, `instance.new`, `instance.group`, `instance.imagePair`, `instance.name`.
767
+
768
+ The `types` array (second argument) lets you vary animation based on transition type.
620
769
 
621
770
  ---
622
771
 
@@ -624,38 +773,70 @@ Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return
624
773
 
625
774
  | Interaction | Duration |
626
775
  |------------|----------|
627
- | Direct toggle | 100–200ms |
628
- | Route transition | 150–250ms |
629
- | Suspense reveal | 200–400ms |
776
+ | Direct toggle (expand/collapse) | 100–200ms |
777
+ | Route transition (slide) | 150–250ms |
778
+ | Suspense reveal (skeleton → content) | 200–400ms |
630
779
  | Shared element morph | 300–500ms |
631
780
 
632
781
  ---
633
782
 
634
- ## Troubleshooting
783
+ # Troubleshooting
784
+
785
+ **VT not activating:** Ensure `<ViewTransition>` comes before any DOM node and the state update is inside `startTransition`. A raw `viewTransitionName` only isolates an element; it does not activate a React view transition.
635
786
 
636
- **VT not activating:** Ensure VT comes before any DOM node. Ensure `startTransition`.
787
+ **"Two ViewTransition components with the same name":** Names must be globally unique. Use IDs such as `name={`hero-${item.id}`}`. If a reusable component appears in multiple surfaces at once, move the name to the specific consumer or make it conditional.
637
788
 
638
- **"Two VTs with same name":** Names must be globally unique. Use IDs.
789
+ **Unrelated content crossfades:** A bare VT uses the browser crossfade for every trigger. Set `default="none"` and opt in only to the triggers that boundary should handle.
639
790
 
640
- **`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead.
791
+ **Scrolling hangs while a transition animates:** the `::view-transition` overlay is `position: fixed` and its snapshots don't scroll — a browser limitation, not fixable in React (skipping snaps to the end). Keep reveal durations short; for scroll-driven UI use gesture transitions (experimental `useSwipeTransition`, if available).
641
792
 
642
- **Only updates animate:** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.
793
+ **Open popover flickers when a background transition settles:** it's captured in `root`. Give it a real `view-transition-name` + isolation (not `none`) — see [Isolate Elements from Parent Animations](patterns.md#isolate-elements-from-parent-animations).
643
794
 
644
- **Layout VT prevents page VTs from animating:** Nested VTs never fire enter/exit inside a parent VT. If your layout has a VT wrapping `{children}`, page-level enter/exit will silently not work. Remove the layout VT.
795
+ **Popover closes or goes dead when clicked mid-transition:** named participants are skipped by hit-testing while a transition runs; clicks land on what's beneath and read as outside-clicks. Portal the popover (see [Isolate Elements from Parent Animations](patterns.md#isolate-elements-from-parent-animations)); brief dead clicks during the transition remain — that's the price of the name.
796
+
797
+ **Shared morph silently not firing:** `share` resolved to `none`. Either the VT has `default="none"` with no explicit `share` prop, or `share` is type-keyed and the navigation never adds the type — the link needs `transitionTypes` (or `addTransitionType` in the transition).
798
+
799
+ **Shared morph competes with a page fade:** A fading page exit dissolves the source while it is morphing. Remove that exit or use motion that preserves the shared element's continuity.
800
+
801
+ **An enter animation runs even though Suspense never showed its fallback:** The content-side VT became the topmost entering subtree during navigation. At that call site, add a host DOM element immediately outside `Suspense`. The host suppresses the nested enter during warm navigation but remains mounted so the VT can enter on a later fallback-to-content reveal. Keep the host outside `Suspense`, not inside the reusable crossfade; do nothing when a direct host already exists.
802
+
803
+ **Section below a list teleports instead of gliding:** it's outside any activated boundary, its VT has `default="none"` (which disables `update`), or it isn't an immediate sibling of the changing content. See [Layout Displacement Morph](patterns.md#layout-displacement-morph).
804
+
805
+ **`router.back()` and browser back/forward skip the directional slide:** traversals carry no transition types, so type-keyed maps resolve to `default` — untyped shared-element morphs still apply. Use `router.push()` for typed animations.
806
+
807
+ **`flushSync` skips animations:** Use `startTransition` instead.
808
+
809
+ **Only updates animate (no enter/exit):** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.
810
+
811
+ **Suspense reveal does not animate:** Suspense resolves in a separate transition without navigation types. Use string `enter`/`exit` props rather than a type map.
812
+
813
+ **Layout VT prevents page VTs from animating:** Nested VTs skip their own enter/exit when they mount or unmount as one unit with a parent VT. Keep route boundaries in pages, not a layout wrapping `{children}`. React has experimental upstream `parentEnter`/`parentExit` work ([PR #36690](https://github.com/facebook/react/pull/36690)), but those props are not currently available in the Next.js client runtime; do not recommend them in a Next.js app unless the installed runtime and docs explicitly include them.
814
+
815
+ **Same-route content does not animate with `update`:** Nested VTs can own the mutation before an outer boundary sees it. For a real identity change, use `key` with a stable `name` and `share` instead.
816
+
817
+ **List reorder not animating with `useOptimistic`:** Optimistic values resolve before snapshot. Use committed state for list order.
645
818
 
646
819
  **TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.
647
820
 
648
- **Backdrop-blur flickers:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`.
821
+ **Hash fragments cause scroll jumps:** Navigate without hash; scroll programmatically after navigation.
822
+
823
+ **Backdrop-blur flickers:** Use the [Backdrop-Blur Workaround](css-recipes.md#backdrop-blur-workaround).
649
824
 
650
- **`border-radius` lost:** Apply `border-radius` directly to captured element.
825
+ **`border-radius` lost during transitions:** Apply `border-radius` directly to the captured element.
651
826
 
652
- **Batching:** Multiple updates during animation are batched (A→B→C→D becomes B→D).
827
+ **Skeleton controls slide away:** Give matching controls the same `viewTransitionName`.
828
+
829
+ **Batching:** Multiple updates during animation are batched. A→B→C→D becomes B→D.
653
830
 
654
831
  ---
655
832
 
656
833
  # CSS Animation Recipes
657
834
 
658
- Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
835
+ Ready-to-use CSS for `<ViewTransition>` props. Copy into your global stylesheet.
836
+
837
+ This file contains the complete CSS recipe set for the patterns in this skill. Copy only what the app needs.
838
+
839
+ ---
659
840
 
660
841
  ## Timing Variables
661
842
 
@@ -671,8 +852,8 @@ Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
671
852
 
672
853
  ```css
673
854
  @keyframes fade {
674
- from { filter: blur(3px); opacity: 0; }
675
- to { filter: blur(0); opacity: 1; }
855
+ from { opacity: 0; }
856
+ to { opacity: 1; }
676
857
  }
677
858
 
678
859
  @keyframes slide {
@@ -686,6 +867,8 @@ Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
686
867
  }
687
868
  ```
688
869
 
870
+ ---
871
+
689
872
  ## Fade
690
873
 
691
874
  ```css
@@ -697,6 +880,12 @@ Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
697
880
  }
698
881
  ```
699
882
 
883
+ Usage: `<ViewTransition enter="fade-in" exit="fade-out" />`
884
+
885
+ Keep the shared fade keyframe opacity-only. If a specific morph needs softness, give that class its own blur keyframe so ordinary content reveals stay crisp.
886
+
887
+ ---
888
+
700
889
  ## Slide (Vertical)
701
890
 
702
891
  ```css
@@ -712,82 +901,97 @@ Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
712
901
  }
713
902
  ```
714
903
 
904
+ Usage:
905
+ ```jsx
906
+ <Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
907
+ <ViewTransition default="none" enter="slide-up"><Content /></ViewTransition>
908
+ </Suspense>
909
+ ```
910
+
911
+ ---
912
+
715
913
  ## Directional Navigation
716
914
 
717
- ### Single-Class Approach
915
+ ### Separate Enter/Exit Classes
718
916
 
719
917
  ```css
720
- ::view-transition-old(.nav-forward) {
721
- --slide-offset: -60px;
722
- animation:
723
- var(--duration-exit) ease-in both fade reverse,
724
- var(--duration-move) ease-in-out both slide reverse;
725
- }
726
- ::view-transition-new(.nav-forward) {
918
+ ::view-transition-new(.slide-from-right) {
727
919
  --slide-offset: 60px;
728
920
  animation:
729
921
  var(--duration-enter) ease-out var(--duration-exit) both fade,
730
922
  var(--duration-move) ease-in-out both slide;
731
923
  }
732
-
733
- ::view-transition-old(.nav-back) {
734
- --slide-offset: 60px;
924
+ ::view-transition-old(.slide-to-left) {
925
+ --slide-offset: -60px;
735
926
  animation:
736
927
  var(--duration-exit) ease-in both fade reverse,
737
928
  var(--duration-move) ease-in-out both slide reverse;
738
929
  }
739
- ::view-transition-new(.nav-back) {
930
+
931
+ ::view-transition-new(.slide-from-left) {
740
932
  --slide-offset: -60px;
741
933
  animation:
742
934
  var(--duration-enter) ease-out var(--duration-exit) both fade,
743
935
  var(--duration-move) ease-in-out both slide;
744
936
  }
937
+ ::view-transition-old(.slide-to-right) {
938
+ --slide-offset: 60px;
939
+ animation:
940
+ var(--duration-exit) ease-in both fade reverse,
941
+ var(--duration-move) ease-in-out both slide reverse;
942
+ }
745
943
  ```
746
944
 
747
- ### Separate Enter/Exit Classes
945
+ ### Single-Class Approach
748
946
 
749
947
  ```css
750
- ::view-transition-new(.slide-from-right) {
751
- --slide-offset: 60px;
752
- animation:
753
- var(--duration-enter) ease-out var(--duration-exit) both fade,
754
- var(--duration-move) ease-in-out both slide;
755
- }
756
- ::view-transition-old(.slide-to-left) {
948
+ ::view-transition-old(.nav-forward) {
757
949
  --slide-offset: -60px;
758
950
  animation:
759
951
  var(--duration-exit) ease-in both fade reverse,
760
952
  var(--duration-move) ease-in-out both slide reverse;
761
953
  }
762
-
763
- ::view-transition-new(.slide-from-left) {
764
- --slide-offset: -60px;
954
+ ::view-transition-new(.nav-forward) {
955
+ --slide-offset: 60px;
765
956
  animation:
766
957
  var(--duration-enter) ease-out var(--duration-exit) both fade,
767
958
  var(--duration-move) ease-in-out both slide;
768
959
  }
769
- ::view-transition-old(.slide-to-right) {
960
+
961
+ ::view-transition-old(.nav-back) {
770
962
  --slide-offset: 60px;
771
963
  animation:
772
964
  var(--duration-exit) ease-in both fade reverse,
773
965
  var(--duration-move) ease-in-out both slide reverse;
774
966
  }
967
+ ::view-transition-new(.nav-back) {
968
+ --slide-offset: -60px;
969
+ animation:
970
+ var(--duration-enter) ease-out var(--duration-exit) both fade,
971
+ var(--duration-move) ease-in-out both slide;
972
+ }
775
973
  ```
776
974
 
975
+ ---
976
+
777
977
  ## Shared Element Morph
778
978
 
779
979
  ```css
780
980
  ::view-transition-group(.morph) {
781
981
  animation-duration: var(--duration-move);
782
982
  }
983
+
783
984
  ::view-transition-image-pair(.morph) {
784
985
  animation-name: via-blur;
785
986
  }
987
+
786
988
  @keyframes via-blur {
787
989
  30% { filter: blur(3px); }
788
990
  }
789
991
  ```
790
992
 
993
+ Usage: `<ViewTransition name={`product-${id}`} share="morph" />`
994
+
791
995
  **Note:** Shared element transitions take raster snapshots. For text with significant size differences (e.g., `<h3>` → `<h1>`), the old snapshot gets scaled up, producing a visible ghost artifact. Use `text-morph` for text shared elements.
792
996
 
793
997
  ## Text Morph
@@ -808,6 +1012,10 @@ Avoids raster scaling artifacts on text by hiding the old snapshot and showing t
808
1012
  }
809
1013
  ```
810
1014
 
1015
+ Usage: `<ViewTransition name={`title-${id}`} share="text-morph" />`
1016
+
1017
+ ---
1018
+
811
1019
  ## Scale
812
1020
 
813
1021
  ```css
@@ -817,6 +1025,7 @@ Avoids raster scaling artifacts on text by hiding the old snapshot and showing t
817
1025
  ::view-transition-new(.scale-in) {
818
1026
  animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;
819
1027
  }
1028
+
820
1029
  @keyframes scale-down {
821
1030
  from { transform: scale(1); opacity: 1; }
822
1031
  to { transform: scale(0.85); opacity: 0; }
@@ -827,6 +1036,43 @@ Avoids raster scaling artifacts on text by hiding the old snapshot and showing t
827
1036
  }
828
1037
  ```
829
1038
 
1039
+ Usage: `<ViewTransition enter="scale-in" exit="scale-out" />`
1040
+
1041
+ ---
1042
+
1043
+ ## Interactivity During Transitions
1044
+
1045
+ The `::view-transition` overlay captures all pointer events. React shrinks it to zero when the root group doesn't animate, but in-flight animations still block clicks. To pass clicks/hover through even while animating:
1046
+
1047
+ ```css
1048
+ ::view-transition {
1049
+ pointer-events: none;
1050
+ }
1051
+ ```
1052
+
1053
+ Trade-offs: clicks can hit live elements under still-moving snapshots, and it only helps **unnamed** content — named participants are skipped by hit-testing for the transition's duration, no CSS override ([csswg#10930](https://github.com/w3c/csswg-drafts/issues/10930)). Weigh that before naming interactive elements; portal named popovers (see [Isolate Elements from Parent Animations](patterns.md#isolate-elements-from-parent-animations)).
1054
+
1055
+ ---
1056
+
1057
+ ## No Root Cross-Fade (Live Root)
1058
+
1059
+ The root cross-fades on every transition, freezing unnamed content behind a stale snapshot — hover and active styles stop rendering until it settles. `::view-transition-new(root)` is a **live** capture, so disabling the root animation keeps unnamed regions rendering (and, with the `pointer-events` recipe above, interactive):
1060
+
1061
+ ```css
1062
+ ::view-transition-old(root) {
1063
+ display: none;
1064
+ }
1065
+ ::view-transition-new(root) {
1066
+ animation: none;
1067
+ }
1068
+ ```
1069
+
1070
+ Named and classed groups still animate — they stack above root. Trade-off: unnamed content swaps instantly, so regions that should fade need their own VT. This also removes the main reason to hand-name static chrome; keep names only for elements that must stack above animating groups.
1071
+
1072
+ Pairs well with enter-only reveals: skip the fallback-exit VT entirely (`<ViewTransition enter="auto" default="none">` around the content, nothing on the skeleton) — the skeleton snaps out live while the content fades in.
1073
+
1074
+ ---
1075
+
830
1076
  ## Persistent Element Isolation
831
1077
 
832
1078
  ```css
@@ -836,13 +1082,54 @@ Avoids raster scaling artifacts on text by hiding the old snapshot and showing t
836
1082
  }
837
1083
  ```
838
1084
 
1085
+ Layer multiple pinned groups with z-index tiers — chrome at `100`, toasts/overlays that must beat everything at `200`.
1086
+
839
1087
  ### Backdrop-Blur Workaround
840
1088
 
1089
+ For elements with `backdrop-filter`, hide the old snapshot to avoid flash:
1090
+
841
1091
  ```css
842
- ::view-transition-old(persistent-nav) { display: none; }
843
- ::view-transition-new(persistent-nav) { animation: none; }
1092
+ ::view-transition-old(persistent-nav) {
1093
+ display: none;
1094
+ }
1095
+ ::view-transition-new(persistent-nav) {
1096
+ animation: none;
1097
+ }
1098
+ ```
1099
+
1100
+ ### Floating Element Isolation (popovers, menus, tooltips, control clusters)
1101
+
1102
+ Same freeze as persistent chrome. A floating/interactive element left rendered while a background transition runs is otherwise captured in the `root` snapshot and flickers as it settles. Give it a real, unique `view-transition-name` (never `none` — that's the CSS default = no isolation) and:
1103
+
1104
+ ```css
1105
+ ::view-transition-group(popover) {
1106
+ animation: none;
1107
+ z-index: 100;
1108
+ }
1109
+ ::view-transition-old(popover),
1110
+ ::view-transition-new(popover) {
1111
+ animation: none;
1112
+ }
1113
+ ```
1114
+
1115
+ ### Sliding Indicator (tab underline / segmented pill)
1116
+
1117
+ One shared-name indicator morphs between positions. Slide the group; disable old/new so the solid bar slides instead of cross-fading:
1118
+
1119
+ ```css
1120
+ ::view-transition-group(.tab-underline) {
1121
+ animation-duration: 220ms;
1122
+ animation-timing-function: cubic-bezier(0.5, 0, 0.2, 1);
1123
+ }
1124
+ ::view-transition-old(.tab-underline),
1125
+ ::view-transition-new(.tab-underline) {
1126
+ animation: none;
1127
+ height: 100%;
1128
+ }
844
1129
  ```
845
1130
 
1131
+ ---
1132
+
846
1133
  ## Reduced Motion
847
1134
 
848
1135
  ```css
@@ -862,45 +1149,155 @@ Avoids raster scaling artifacts on text by hiding the old snapshot and showing t
862
1149
 
863
1150
  ## Setup
864
1151
 
865
- ```js
866
- // next.config.js
867
- experimental: { viewTransition: true }
868
- ```
1152
+ `<ViewTransition>` works in the App Router with no configuration. The bundled React channel includes it, and Next.js navigations run in React Transitions. Do **not** add the old `experimental.viewTransition` flag or install `react@canary` into a Next.js app.
1153
+
1154
+ Read the current [Next.js View Transitions guide](https://nextjs.org/docs/app/guides/view-transitions) and the matching guide under `node_modules/next/dist/docs/` before editing. The installed version is authoritative for flags and API signatures.
1155
+
1156
+ Because every link click is a transition, any VT with `default="auto"` fires on **every** navigation — use `default="none"` to prevent competing animations.
1157
+
1158
+ For unexpected or broken animation behavior, see [troubleshooting.md](troubleshooting.md).
869
1159
 
870
- Wraps every `<Link>` navigation in `document.startViewTransition`. Use `default="none"` to prevent competing animations. Do **not** install `react@canary` — the App Router already bundles it.
1160
+ ---
871
1161
 
872
1162
  ## Next.js Implementation Additions
873
1163
 
874
- **After Step 2:** Enable the experimental flag.
1164
+ When following [implementation.md](implementation.md), apply these additions:
1165
+
1166
+ **Step 4:** Use `transitionTypes` on `<Link>` — see [The `transitionTypes` Prop](#the-transitiontypes-prop-on-nextlink). If the animation depends on dynamic destination content, also see [When Content Must Be Ready](#when-content-must-be-ready).
1167
+
1168
+ **After Step 6:** For same-route dynamic segments (e.g., `/collection/[slug]`), use the `key` + `name` + `share` pattern — see [Same-Route Dynamic Segment Transitions](#same-route-dynamic-segment-transitions).
1169
+
1170
+ ---
1171
+
1172
+ ## Layout-Level ViewTransition
1173
+
1174
+ **Do NOT add a layout-level VT wrapping `{children}` if pages have their own VTs.** A nested VT skips its own enter/exit only when it mounts or unmounts *as one unit* with a parent VT, which is exactly what a layout VT wrapping `{children}` causes — page-level enter/exit will silently not work. Remove the layout VT entirely. Nesting is otherwise fine and sometimes required: a child VT inside a *persistent* parent VT fires enter/exit normally, and two nested boundaries are the intended shape for [shared elements inside list items](../SKILL.md#composing-shared-elements-with-list-identity).
1175
+
1176
+ A bare `<ViewTransition>` in layout works only if pages have **no** VTs of their own.
1177
+
1178
+ **Layouts persist across navigations** — `enter`/`exit` only fire on initial mount, not on route changes. Don't use type-keyed maps in layouts. Because layouts persist, chrome hosted in one (nav, sidebar, player) keeps its state across navigations for free — no `Activity` needed. Reserve `Activity` for in-page show/hide (see [Composing with Activity](patterns.md#composing-with-activity)).
1179
+
1180
+ ---
1181
+
1182
+ ## The `transitionTypes` Prop on `next/link`
1183
+
1184
+ No wrapper component needed, works in Server Components:
875
1185
 
876
- **Step 4:** Use `transitionTypes` on `<Link>` (if available — see availability note below):
877
1186
  ```tsx
878
- <Link href="/photo/1" transitionTypes={["nav-forward"]}>View</Link>
879
- <Link href="/" transitionTypes={["nav-back"]}>Back</Link>
1187
+ <Link href="/products/1" transitionTypes={['transition-to-detail']}>
1188
+ View Product
1189
+ </Link>
880
1190
  ```
881
1191
 
882
- **After Step 6:** For same-route dynamic segments, use `key` + `name` + `share` pattern.
1192
+ Replaces the manual pattern of `onNavigate` + `startTransition` + `addTransitionType` + `router.push()`. Reserve manual `startTransition` for non-link interactions (buttons, forms).
883
1193
 
884
- ## Layout-Level ViewTransition
1194
+ **Availability:** `transitionTypes` shipped in **Next.js 16.2.0** (it is not gated on the `experimental.viewTransition` flag). If unavailable, use `startTransition` + `addTransitionType` + `router.push()` (see [Programmatic Navigation](#programmatic-navigation)). To check: `grep -r "transitionTypes" node_modules/next/dist/` — if no results, fall back to programmatic navigation.
885
1195
 
886
- Don't add a layout-level VT wrapping `{children}` if pages have their own VTs — nested VTs never fire enter/exit inside a parent VT, so page-level enter/exit will silently not work. Remove the layout VT entirely. A bare VT in layout works only if pages have no VTs of their own. Layouts persist across navigations — don't use type-keyed maps in layouts.
1196
+ ---
887
1197
 
888
- ## The `transitionTypes` Prop
1198
+ ## When Content Must Be Ready
1199
+
1200
+ A page transition can animate whatever Next.js renders during navigation, including a loading fallback. A shared content-to-content morph only works when the incoming content is ready as the navigation commits; content that has not rendered yet cannot form the incoming half of the pair.
1201
+
1202
+ When an animation depends on dynamic destination content, use Next.js prefetching and caching to make that content available ahead of time. `<Link>` automatically prefetches in production, but the default behavior for dynamic routes may only prefetch a shell or loading boundary. Set `prefetch={true}` to prefetch the full route, and cache the data needed to render the shared content.
889
1203
 
890
- Works in Server Components, no wrapper needed:
891
1204
  ```tsx
892
- <Link href="/products/1" transitionTypes={['nav-forward']}>View</Link>
1205
+ <Link href={nextHref} prefetch={true} transitionTypes={['nav-forward']}>
1206
+ Next
1207
+ </Link>
893
1208
  ```
894
1209
 
895
- **Availability:** Requires `experimental.viewTransition: true`. Available in Next.js 15+ canary builds and Next.js 16+. If unavailable, use `startTransition` + `addTransitionType` + `router.push()`. To check: `grep -r "transitionTypes" node_modules/next/dist/`. Reserve manual `startTransition` for non-link interactions.
1210
+ With Cache Components, put reusable route data in a cached scope such as `use cache` so prefetching can include it. If the destination remains behind an unresolved Suspense boundary, the route transition animates the fallback instead. The content resolves in a separate Suspense transition without the original `nav-forward` or `nav-back` type, so give that content its own reveal animation when needed.
896
1211
 
897
- ## `loading.tsx` as Suspense Boundary
1212
+ Verify directional transitions in a production build with a cold client cache. Development mode does not run automatic `<Link>` prefetching.
1213
+
1214
+ See the Next.js [View Transitions guide](https://nextjs.org/docs/app/guides/view-transitions) and [Prefetching guide](https://nextjs.org/docs/app/guides/prefetching).
1215
+
1216
+ ---
1217
+
1218
+ ## Programmatic Navigation
1219
+
1220
+ ```tsx
1221
+ 'use client';
898
1222
 
899
- Next.js `loading.tsx` files are implicit `<Suspense>` boundaries. Wrap the skeleton in `<ViewTransition exit="...">` in `loading.tsx`, and the content in `<ViewTransition enter="..." default="none">` in the page. This is the Next.js-idiomatic equivalent of explicit `<Suspense fallback={...}>`. Same rules apply: use simple string props (not type maps) since Suspense reveals fire without transition types.
1223
+ import { useRouter } from 'next/navigation';
1224
+
1225
+ function DetailButton({ href }: { href: string }) {
1226
+ const router = useRouter();
1227
+
1228
+ return (
1229
+ <button onClick={() => router.push(href, { transitionTypes: ['nav-forward'] })}>
1230
+ Open
1231
+ </button>
1232
+ );
1233
+ }
1234
+ ```
1235
+
1236
+ The `transitionTypes` option adds the types inside the router's navigation Transition. Use `startTransition` + `addTransitionType` for non-navigation state updates, or as a fallback on Next.js versions without the router option.
1237
+
1238
+ ---
900
1239
 
901
1240
  ## Server-Side Filtering with `router.replace`
902
1241
 
903
- For search/sort/filter that re-renders on the server (via URL params), use `startTransition` + `router.replace`. VTs activate because the update is inside `startTransition`. List items wrapped in `<ViewTransition key={item.id}>` animate reorder. This is the server-component alternative to the client-side `useDeferredValue` pattern.
1242
+ For search/sort/filter that re-renders on the server (via URL params), use `startTransition` + `router.replace`. VTs activate because the state update is inside `startTransition`:
1243
+
1244
+ ```tsx
1245
+ 'use client';
1246
+
1247
+ import { useRouter } from 'next/navigation';
1248
+ import { startTransition } from 'react';
1249
+
1250
+ function SortControl() {
1251
+ const router = useRouter();
1252
+
1253
+ function handleSort(sort: string) {
1254
+ startTransition(() => {
1255
+ router.replace(`?sort=${sort}`);
1256
+ });
1257
+ }
1258
+
1259
+ return <button onClick={() => handleSort('newest')}>Newest</button>;
1260
+ }
1261
+ ```
1262
+
1263
+ List items wrapped in `<ViewTransition key={item.id}>` will animate reorder. This is the server-component alternative to the client-side [Searchable Grid](patterns.md#searchable-grid-with-usedeferredvalue) pattern.
1264
+
1265
+ For immediate control feedback while the route commits, use `useOptimistic` for the button state but keep the animated list tied to the committed sort value. See [Exclude Elements with `useOptimistic`](patterns.md#exclude-elements-with-useoptimistic).
1266
+
1267
+ ---
1268
+
1269
+ ## Routing-Driven Tabs
1270
+
1271
+ The generalized sliding indicator ([Sliding Indicator](patterns.md#sliding-indicator-tabs)) driven by navigation instead of local state: tabs are `<Link>`s, `active` comes from the URL (a server prop), and `useOptimistic` slides the indicator instantly while the route commits. Key the mounted indicator to committed `active` so the bar lands where navigation actually settles.
1272
+
1273
+ ```tsx
1274
+ 'use client';
1275
+ import Link from 'next/link';
1276
+ import { useOptimistic, useTransition, ViewTransition } from 'react';
1277
+
1278
+ export function Tabs({ tabs, active, indicatorName = 'tab-indicator' }) {
1279
+ const [optimisticActive, setOptimisticActive] = useOptimistic(active);
1280
+ const [, startTransition] = useTransition();
1281
+ return (
1282
+ <nav>
1283
+ {tabs.map(t => (
1284
+ <Link key={t.value} href={t.href} scroll={false}
1285
+ aria-current={optimisticActive === t.value ? 'page' : undefined}
1286
+ onNavigate={() => startTransition(() => setOptimisticActive(t.value))}>
1287
+ <span>{t.label}</span>
1288
+ {active === t.value && (
1289
+ <ViewTransition name={indicatorName} share="tab-underline">
1290
+ <span className="active-underline" aria-hidden />
1291
+ </ViewTransition>
1292
+ )}
1293
+ </Link>
1294
+ ))}
1295
+ </nav>
1296
+ );
1297
+ }
1298
+ ```
1299
+
1300
+ ---
904
1301
 
905
1302
  ## Two-Layer Pattern (Directional + Suspense)
906
1303
 
@@ -920,15 +1317,35 @@ Directional slides + Suspense reveals coexist because they fire at different mom
920
1317
  </ViewTransition>
921
1318
  ```
922
1319
 
1320
+ ---
1321
+
1322
+ ## `loading.tsx` as Suspense Boundary
1323
+
1324
+ Next.js `loading.tsx` is an implicit `<Suspense>` boundary. Wrap the skeleton in `<ViewTransition exit="...">` in `loading.tsx`, and the content in `<ViewTransition enter="..." default="none">` in the page:
1325
+
1326
+ ```tsx
1327
+ // loading.tsx
1328
+ <ViewTransition exit="slide-down"><PhotoGridSkeleton /></ViewTransition>
1329
+
1330
+ // page.tsx
1331
+ <ViewTransition enter="slide-up" default="none"><PhotoGrid photos={photos} /></ViewTransition>
1332
+ ```
1333
+
1334
+ Same rules as explicit `<Suspense>`: use simple string props (not type maps) since Suspense reveals fire without transition types.
1335
+
1336
+ ---
1337
+
923
1338
  ## Shared Elements Across Routes
924
1339
 
925
1340
  ```tsx
926
1341
  // List page
927
- <Link href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
928
- <ViewTransition name={`product-${product.id}`}>
929
- <Image src={product.image} alt={product.name} width={400} height={300} />
930
- </ViewTransition>
931
- </Link>
1342
+ {products.map((product) => (
1343
+ <Link key={product.id} href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
1344
+ <ViewTransition name={`product-${product.id}`}>
1345
+ <Image src={product.image} alt={product.name} width={400} height={300} />
1346
+ </ViewTransition>
1347
+ </Link>
1348
+ ))}
932
1349
 
933
1350
  // Detail page — same name
934
1351
  <ViewTransition name={`product-${product.id}`}>
@@ -936,20 +1353,28 @@ Directional slides + Suspense reveals coexist because they fire at different mom
936
1353
  </ViewTransition>
937
1354
  ```
938
1355
 
1356
+ If the pair's `share` is type-keyed (or classed via CSS that expects a type), every `<Link>` between the two views must carry the type via `transitionTypes` — a plain link click resolves the share map's `default`, and if that's `none` the morph silently never fires.
1357
+
1358
+ ---
1359
+
939
1360
  ## Same-Route Dynamic Segment Transitions
940
1361
 
941
- Page stays mounted on dynamic segment change — enter/exit never fire. Use `key` + `name` + `share`:
1362
+ When navigating between dynamic segments of the same route (e.g., `/collection/[slug]`), the router swaps subtrees keyed by the segment value rather than doing a plain unmount/mount — enter/exit don't fire reliably. Use `key` + `name` + `share`:
942
1363
 
943
1364
  ```tsx
944
1365
  <Suspense fallback={<Skeleton />}>
945
- <ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
1366
+ <ViewTransition key={slug} name="collection-content" share="auto" default="none">
946
1367
  <Content slug={slug} />
947
1368
  </ViewTransition>
948
1369
  </Suspense>
949
1370
  ```
950
1371
 
1372
+ - `key={slug}` forces unmount/remount on change
1373
+ - The stable `name` pairs the outgoing and incoming containers; `share="auto"` creates the crossfade
1374
+ - VT inside `<Suspense>` (without keying Suspense) keeps old content visible during loading
1375
+
951
1376
  ## Server Components
952
1377
 
953
- - `<ViewTransition>` works in Server and Client Components
954
- - `<Link transitionTypes>` works in Server Components
955
- - `addTransitionType` and programmatic nav require Client Components
1378
+ - `<ViewTransition>` works in both Server and Client Components
1379
+ - `<Link transitionTypes>` works in Server Components — no `'use client'` needed
1380
+ - `router.push(..., { transitionTypes })`, `addTransitionType`, and `startTransition` require Client Components