geneseed 4.9.4 → 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.
- package/CHANGELOG.md +21 -0
- package/README.md +13 -0
- package/SHIPPED.md +1 -1
- package/docs/README.md +1 -1
- package/docs/concepts/collaboration.md +1 -1
- package/docs/concepts/enforcement-table.md +1 -1
- package/docs/concepts/foreman-mode.md +1 -1
- package/docs/concepts/kits.md +79 -0
- package/docs/concepts/loop-branch.md +1 -1
- package/docs/concepts/loop-bricks.md +1 -1
- package/docs/concepts/loop-contracts.md +1 -1
- package/docs/concepts/loop-gates.md +1 -1
- package/docs/concepts/loop-notes.md +1 -1
- package/docs/concepts/loop-risk.md +1 -1
- package/docs/concepts/loop-templates.md +1 -1
- package/docs/concepts/loops.md +1 -1
- package/docs/concepts/lsp.md +1 -1
- package/docs/extending.md +108 -4
- package/docs/guides/install-scripted.md +1 -1
- package/docs/reference/glossary.md +1 -0
- package/docs/web-ui.md +4 -0
- package/harness.config.json +1 -1
- package/js/build/args.mjs +27 -6
- package/js/build/driver.mjs +29 -8
- package/js/build/emit-claude.mjs +3 -1
- package/js/build/generate.mjs +10 -5
- package/js/build/render.mjs +94 -11
- package/js/build/source.mjs +117 -4
- package/js/cli-table.json +15 -0
- package/js/hosts/installs.mjs +35 -2
- package/js/hosts/native.mjs +14 -4
- package/js/inspect/checks-authoring.mjs +6 -3
- package/js/inspect/checks-build.mjs +7 -1
- package/js/inspect/checks-counts.mjs +153 -6
- package/js/inspect/checks-repo.mjs +95 -20
- package/js/inspect/diff.mjs +3 -1
- package/js/inspect/inventory.mjs +1 -0
- package/js/inspect/status.mjs +44 -3
- package/js/inspect/validate.mjs +3 -3
- package/js/maintain/setup.mjs +30 -6
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +30 -6
- package/js/web/api.mjs +40 -3
- package/js/web/routes.mjs +2 -1
- package/package.json +1 -1
- package/registry.json +63 -0
- package/src/AGENT.md.tmpl +2 -2
- package/src/agents/devops-engineer.md +45 -0
- package/src/agents/docker-expert.md +45 -0
- package/src/agents/kubernetes-specialist.md +45 -0
- package/src/agents/reviewer.md +2 -0
- package/src/agents/spring-boot-engineer.md +48 -0
- package/src/agents/spring-reviewer.md +49 -0
- package/src/agents/spring-security.md +50 -0
- package/src/kits/spring-boot/rules.md +10 -0
- package/src/kits/spring-boot.json +23 -0
- package/src/licenses/mattpocock-skills.txt +21 -0
- package/src/licenses/piomin-spring-boot.txt +190 -0
- package/src/licenses/superpowers.txt +21 -0
- package/src/licenses/vibe-wise.txt +21 -0
- package/src/skills/brainstorm/builder-check.md +2 -0
- package/src/skills/brainstorm.md +4 -0
- package/src/skills/codebase-design.md +4 -0
- package/src/skills/daydream/VENDOR.md +7 -7
- package/src/skills/debug.md +5 -1
- package/src/skills/develop.md +2 -0
- package/src/skills/geneseed-code-review.md +6 -2
- package/src/skills/git-history.md +2 -0
- package/src/skills/handoff.md +2 -0
- package/src/skills/learn-mode.md +3 -2
- package/src/skills/parallel-agents.md +2 -0
- package/src/skills/pipeline.md +2 -0
- package/src/skills/plan.md +4 -0
- package/src/skills/prototype.md +2 -0
- package/src/skills/react-view-transitions/AGENTS.md +646 -221
- package/src/skills/react-view-transitions/SKILL.md +28 -16
- package/src/skills/react-view-transitions/VENDOR.md +10 -8
- package/src/skills/react-view-transitions/references/css-recipes.md +72 -2
- package/src/skills/react-view-transitions/references/implementation.md +14 -26
- package/src/skills/react-view-transitions/references/nextjs.md +94 -37
- package/src/skills/react-view-transitions/references/patterns.md +60 -48
- package/src/skills/react-view-transitions/references/troubleshooting.md +47 -0
- package/src/skills/research.md +2 -0
- package/src/skills/review-response.md +2 -0
- package/src/skills/rule.md +2 -0
- package/src/skills/ship.md +2 -0
- package/src/skills/skill-forge.md +1 -0
- package/src/skills/spring-boot/cloud.md +103 -0
- package/src/skills/spring-boot/java-modern.md +71 -0
- package/src/skills/spring-boot/logging.md +89 -0
- package/src/skills/spring-boot/security.md +112 -0
- package/src/skills/spring-boot/web.md +112 -0
- package/src/skills/spring-boot.md +57 -0
- package/src/skills/spring-data-jpa/patterns.md +68 -0
- package/src/skills/spring-data-jpa.md +81 -0
- package/src/skills/spring-testing.md +93 -0
- package/src/skills/wayfinder.md +2 -0
- package/src/upstreams.json +59 -0
- package/themes/_TEMPLATE.json +9 -0
- package/themes/biker.json +9 -0
- package/themes/commentator.json +9 -0
- package/themes/cyberpunk.json +9 -0
- package/themes/gamer.json +9 -0
- package/themes/imperial.json +9 -0
- package/themes/joker.json +9 -0
- package/themes/marvin.json +9 -0
- package/themes/mean.json +9 -0
- package/themes/military.json +9 -0
- package/themes/neutral.json +9 -0
- package/themes/pirate.json +9 -0
- package/themes/sports.json +9 -0
- package/themes/verstappen.json +9 -0
- package/themes/wizard.json +9 -0
- package/web/dist/assets/{Activity-C8sZVIUU.js → Activity-BjyJaf8e.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Cc6vWUS4.js → ActivityDetail-Bo9nRDNV.js} +1 -1
- package/web/dist/assets/{Dashboard-C5ATa3ow.js → Dashboard-BQJGU7vE.js} +2 -2
- package/web/dist/assets/{Docs-tDW7i7mo.js → Docs-DLVckkew.js} +1 -1
- package/web/dist/assets/{GreenhouseView-zk2Uzc3A.js → GreenhouseView--DML7CW3.js} +1 -1
- package/web/dist/assets/{Installs-CB_rvbBP.js → Installs-CxCiln1F.js} +2 -2
- package/web/dist/assets/{JournalView-Bfj27XHC.js → JournalView-ymgZleHi.js} +1 -1
- package/web/dist/assets/{Laws-BA3CzUf5.js → Laws-BkKnZOBI.js} +1 -1
- package/web/dist/assets/Library-C8ercIZv.js +1 -0
- package/web/dist/assets/{Loops-DaPk-ckL.js → Loops-C95zhbFl.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CyiGhiHk.js → OperatorHudView-B9qwc3w3.js} +1 -1
- package/web/dist/assets/{Personal-Bwd27CmT.js → Personal-DyR72AF-.js} +1 -1
- package/web/dist/assets/{Tabs-BOD7Kaz1.js → Tabs-ChCr_HqV.js} +1 -1
- package/web/dist/assets/api-BfpZmUMI.js +1 -0
- package/web/dist/assets/{drawable-CyLe1gtW.js → drawable-CgYi0Dtg.js} +1 -1
- package/web/dist/assets/index-B32EvfDT.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Library-BWwiy9Vb.js +0 -1
- package/web/dist/assets/api-CiXlkbe0.js +0 -1
- 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
|
|
9
|
-
>
|
|
10
|
-
>
|
|
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** (
|
|
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
|
|
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** |
|
|
107
|
-
| **exit** |
|
|
108
|
-
| **update** | DOM mutations inside a
|
|
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
|
-
|
|
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"
|
|
89
|
+
<ViewTransition enter="auto" exit="auto">
|
|
90
|
+
<div>Content</div>
|
|
91
|
+
</ViewTransition>
|
|
120
92
|
|
|
121
|
-
// Broken — div wraps the VT
|
|
122
|
-
<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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
-
|
|
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:**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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`** —
|
|
334
|
-
- **Every `<Suspense>` boundary** —
|
|
335
|
-
- **Every page/route component** —
|
|
336
|
-
- **Persistent elements**
|
|
337
|
-
- **Shared visual elements** on both source and target
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
+
Then wrap each **page component** (not layout) in a type-keyed `<ViewTransition>`:
|
|
383
394
|
|
|
384
395
|
```jsx
|
|
385
396
|
<ViewTransition
|
|
386
|
-
enter={{
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
636
|
+
<h1>{title}</h1>
|
|
637
|
+
<Suspense fallback={<BodySkeleton />}><Body /></Suspense>
|
|
566
638
|
```
|
|
567
639
|
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
673
|
+
## Layout Displacement Morph
|
|
575
674
|
|
|
576
|
-
|
|
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
|
|
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
|
-
##
|
|
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"
|
|
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`.
|
|
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
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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 {
|
|
675
|
-
to {
|
|
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
|
-
###
|
|
915
|
+
### Separate Enter/Exit Classes
|
|
718
916
|
|
|
719
917
|
```css
|
|
720
|
-
::view-transition-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
945
|
+
### Single-Class Approach
|
|
748
946
|
|
|
749
947
|
```css
|
|
750
|
-
::view-transition-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
843
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
1160
|
+
---
|
|
871
1161
|
|
|
872
1162
|
## Next.js Implementation Additions
|
|
873
1163
|
|
|
874
|
-
|
|
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="/
|
|
879
|
-
|
|
1187
|
+
<Link href="/products/1" transitionTypes={['transition-to-detail']}>
|
|
1188
|
+
View Product
|
|
1189
|
+
</Link>
|
|
880
1190
|
```
|
|
881
1191
|
|
|
882
|
-
|
|
1192
|
+
Replaces the manual pattern of `onNavigate` + `startTransition` + `addTransitionType` + `router.push()`. Reserve manual `startTransition` for non-link interactions (buttons, forms).
|
|
883
1193
|
|
|
884
|
-
|
|
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
|
-
|
|
1196
|
+
---
|
|
887
1197
|
|
|
888
|
-
##
|
|
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=
|
|
1205
|
+
<Link href={nextHref} prefetch={true} transitionTypes={['nav-forward']}>
|
|
1206
|
+
Next
|
|
1207
|
+
</Link>
|
|
893
1208
|
```
|
|
894
1209
|
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
928
|
-
<
|
|
929
|
-
<
|
|
930
|
-
|
|
931
|
-
</
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|