📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-06-03 16:02:56 +00:00
parent ac7fffe532
commit 5cb67428bd
803 changed files with 78437 additions and 401 deletions
@@ -0,0 +1,327 @@
---
name: vercel-react-view-transitions
description: "Guide React and Next.js view transitions, shared element animations, route transitions, transition types, and reduced-motion-safe UI state animation."
risk: safe
source: "https://github.com/vercel-labs/agent-skills"
date_added: "2026-06-02"
---
# React View Transitions
Animate between UI states using the browser's native `document.startViewTransition`. Declare *what* with `<ViewTransition>`, trigger *when* with `startTransition` / `useDeferredValue` / `Suspense`, control *how* with CSS classes. Unsupported browsers skip animations gracefully.
## When to Use
- Use this skill when the task matches this description: Guide React and Next.js view transitions, shared element animations, route transitions, transition types, and reduced-motion-safe UI state animation.
## When to Animate
Every `<ViewTransition>` should communicate a spatial relationship or continuity. If you can't articulate what it communicates, don't add it.
Implement **all** applicable patterns from this list, in this order:
| Priority | Pattern | What it communicates |
|----------|---------|---------------------|
| 1 | **Shared element** (`name`) | "Same thing — going deeper" |
| 2 | **Suspense reveal** | "Data loaded" |
| 3 | **List identity** (per-item `key`) | "Same items, new arrangement" |
| 4 | **State change** (`enter`/`exit`) | "Something appeared/disappeared" |
| 5 | **Route change** (layout-level) | "Going to a new place" |
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.
### Choosing Animation Style
| Context | Animation | Why |
|---------|-----------|-----|
| Hierarchical navigation (list → detail) | Type-keyed `nav-forward` / `nav-back` | Communicates spatial depth |
| Lateral navigation (tab-to-tab) | Bare `<ViewTransition>` (fade) or `default="none"` | No depth to communicate |
| Suspense reveal | `enter`/`exit` string props | Content arriving |
| Revalidation / background refresh | `default="none"` | Silent — no animation needed |
Reserve directional slides for hierarchical navigation (list → detail) and ordered sequences (prev/next photo, carousel, paginated results). For ordered sequences, the direction communicates position: "next" slides from right, "previous" from left. Lateral/unordered navigation (tab-to-tab) should not use directional slides — it falsely implies spatial depth.
---
## Availability
- **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.
- **Without Next.js:** Install `react@canary react-dom@canary` (`ViewTransition` is not in stable React).
- Browser support: Chromium 111+, Firefox 144+, Safari 18.2+. Graceful degradation on unsupported browsers.
---
## Implementation Workflow
When adding view transitions to an existing app, **follow `references/implementation.md` step by step.** Start with the audit — do not skip it. Copy the CSS recipes from `references/css-recipes.md` into the global stylesheet — do not write your own animation CSS.
---
## Core Concepts
### The `<ViewTransition>` Component
```jsx
import { ViewTransition } from 'react';
<ViewTransition>
<Component />
</ViewTransition>
```
React auto-assigns a unique `view-transition-name` and calls `document.startViewTransition` behind the scenes. Never call `startViewTransition` yourself.
### Animation Triggers
| Trigger | When it fires |
|---------|--------------|
| **enter** | `<ViewTransition>` first inserted during a Transition |
| **exit** | `<ViewTransition>` first removed during a Transition |
| **update** | DOM mutations inside a `<ViewTransition>`. With nested VTs, mutation applies to the innermost one |
| **share** | Named VT unmounts and another with same `name` mounts in the same Transition |
Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate.
### Critical Placement Rule
`<ViewTransition>` only activates enter/exit if it appears **before any DOM nodes**:
```jsx
// Works
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
// Broken — div wraps the VT, suppressing enter/exit
<div>
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
</div>
```
---
## Styling with View Transition Classes
### Props
Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations.
```jsx
<ViewTransition default="none" enter="slide-in" exit="slide-out" share="morph" />
```
If `default` is `"none"`, all triggers are off unless explicitly listed.
### CSS Pseudo-Elements
- `::view-transition-old(.class)` — outgoing snapshot
- `::view-transition-new(.class)` — incoming snapshot
- `::view-transition-group(.class)` — container
- `::view-transition-image-pair(.class)` — old + new pair
See `references/css-recipes.md` for ready-to-use animation recipes.
---
## Transition Types
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:
```jsx
startTransition(() => {
addTransitionType('nav-forward');
addTransitionType('select-item');
router.push('/detail/1');
});
```
Pass an object to map types to CSS classes. Works on `enter`, `exit`, **and** `share`:
```jsx
<ViewTransition
enter={{ 'nav-forward': 'slide-from-right', 'nav-back': 'slide-from-left', default: 'none' }}
exit={{ 'nav-forward': 'slide-to-left', 'nav-back': 'slide-to-right', default: 'none' }}
share={{ 'nav-forward': 'morph-forward', 'nav-back': 'morph-back', default: 'morph' }}
default="none"
>
<Page />
</ViewTransition>
```
`enter` and `exit` don't have to be symmetric. For example, fade in but slide out directionally:
```jsx
<ViewTransition
enter={{ 'nav-forward': 'fade-in', 'nav-back': 'fade-in', default: 'none' }}
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
default="none"
>
```
**TypeScript:** `ViewTransitionClassPerType` requires a `default` key in the object.
For apps with multiple pages, extract the type-keyed VT into a reusable wrapper:
```jsx
export function DirectionalTransition({ children }: { children: React.ReactNode }) {
return (
<ViewTransition
enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
default="none"
>
{children}
</ViewTransition>
);
}
```
### `router.back()` and Browser Back Button
`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.
### Types and Suspense
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.
---
## Shared Element Transitions
Same `name` on two VTs — one unmounting, one mounting — creates a shared element morph:
```jsx
<ViewTransition name="hero-image">
<img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
</ViewTransition>
// On the other view — same name
<ViewTransition name="hero-image">
<img src="/full.jpg" />
</ViewTransition>
```
- 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.
- `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.
- Never use a fade-out exit on pages with shared morphs — use a directional slide instead.
---
## Common Patterns
### Enter/Exit
```jsx
{show && (
<ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>
)}
```
### List Reorder
```jsx
{items.map(item => (
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
))}
```
Trigger inside `startTransition`. Avoid wrapper `<div>`s between list and VT.
### Composing Shared Elements with List Identity
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:
```jsx
{items.map(item => (
<ViewTransition key={item.id}> {/* list identity */}
<Link href={`/items/${item.id}`}>
<ViewTransition name={`item-image-${item.id}`} share="morph"> {/* shared element */}
<Image src={item.image} />
</ViewTransition>
<p>{item.name}</p>
</Link>
</ViewTransition>
))}
```
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.
### Force Re-Enter with `key`
```jsx
<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">
<ResultsGrid />
</ViewTransition>
```
**Caution:** If wrapping `<Suspense>`, changing `key` remounts the boundary and refetches.
### Suspense Fallback to Content
Simple cross-fade:
```jsx
<ViewTransition>
<Suspense fallback={<Skeleton />}><Content /></Suspense>
</ViewTransition>
```
Directional reveal:
```jsx
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
<ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
</Suspense>
```
For more patterns, see `references/patterns.md`.
---
## How Multiple VTs Interact
Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions (navigation vs later Suspense resolve) don't compete.
### Use `default="none"` Liberally
Without it, every VT fires the browser cross-fade on **every** transition — Suspense resolves, `useDeferredValue` updates, background revalidations. Always use `default="none"` and explicitly enable only desired triggers.
### Two Patterns Coexist
**Pattern A — Directional slides:** Type-keyed VT on each page, fires during navigation.
**Pattern B — Suspense reveals:** Simple string props, fires when data loads (no type).
They coexist because they fire at different moments. `default="none"` on both prevents cross-interference. Always pair `enter` with `exit`. Place directional VTs in page components, not layouts.
### Nested VT Limitation
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.
---
## Next.js Integration
For Next.js setup (`experimental.viewTransition` flag, `transitionTypes` prop on `next/link`, App Router patterns, Server Components), see `references/nextjs.md`.
---
## Accessibility
Always add the reduced motion CSS from `references/css-recipes.md` to your global stylesheet.
---
## Reference Files
- **`references/implementation.md`** — Step-by-step implementation workflow.
- **`references/patterns.md`** — Patterns, animation timing, events API, troubleshooting.
- **`references/css-recipes.md`** — Ready-to-use CSS animation recipes.
- **`references/nextjs.md`** — Next.js App Router patterns and Server Component details.
## Full Compiled Document
For the complete guide with all reference files expanded: `AGENTS.md`
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
@@ -0,0 +1,242 @@
# CSS Animation Recipes
Ready-to-use CSS for `<ViewTransition>` props. Copy into your global stylesheet.
---
## Timing Variables
```css
:root {
--duration-exit: 150ms;
--duration-enter: 210ms;
--duration-move: 400ms;
}
```
### Shared Keyframes
```css
@keyframes fade {
from { filter: blur(3px); opacity: 0; }
to { filter: blur(0); opacity: 1; }
}
@keyframes slide {
from { translate: var(--slide-offset); }
to { translate: 0; }
}
@keyframes slide-y {
from { transform: translateY(var(--slide-y-offset, 10px)); }
to { transform: translateY(0); }
}
```
---
## Fade
```css
::view-transition-old(.fade-out) {
animation: var(--duration-exit) ease-in fade reverse;
}
::view-transition-new(.fade-in) {
animation: var(--duration-enter) ease-out var(--duration-exit) both fade;
}
```
Usage: `<ViewTransition enter="fade-in" exit="fade-out" />`
---
## Slide (Vertical)
```css
::view-transition-old(.slide-down) {
animation:
var(--duration-exit) ease-out both fade reverse,
var(--duration-exit) ease-out both slide-y reverse;
}
::view-transition-new(.slide-up) {
animation:
var(--duration-enter) ease-in var(--duration-exit) both fade,
var(--duration-move) ease-in both slide-y;
}
```
Usage:
```jsx
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
<ViewTransition default="none" enter="slide-up"><Content /></ViewTransition>
</Suspense>
```
---
## Directional Navigation
### Separate Enter/Exit Classes
```css
::view-transition-new(.slide-from-right) {
--slide-offset: 60px;
animation:
var(--duration-enter) ease-out var(--duration-exit) both fade,
var(--duration-move) ease-in-out both slide;
}
::view-transition-old(.slide-to-left) {
--slide-offset: -60px;
animation:
var(--duration-exit) ease-in both fade reverse,
var(--duration-move) ease-in-out both slide reverse;
}
::view-transition-new(.slide-from-left) {
--slide-offset: -60px;
animation:
var(--duration-enter) ease-out var(--duration-exit) both fade,
var(--duration-move) ease-in-out both slide;
}
::view-transition-old(.slide-to-right) {
--slide-offset: 60px;
animation:
var(--duration-exit) ease-in both fade reverse,
var(--duration-move) ease-in-out both slide reverse;
}
```
### Single-Class Approach
```css
::view-transition-old(.nav-forward) {
--slide-offset: -60px;
animation:
var(--duration-exit) ease-in both fade reverse,
var(--duration-move) ease-in-out both slide reverse;
}
::view-transition-new(.nav-forward) {
--slide-offset: 60px;
animation:
var(--duration-enter) ease-out var(--duration-exit) both fade,
var(--duration-move) ease-in-out both slide;
}
::view-transition-old(.nav-back) {
--slide-offset: 60px;
animation:
var(--duration-exit) ease-in both fade reverse,
var(--duration-move) ease-in-out both slide reverse;
}
::view-transition-new(.nav-back) {
--slide-offset: -60px;
animation:
var(--duration-enter) ease-out var(--duration-exit) both fade,
var(--duration-move) ease-in-out both slide;
}
```
---
## Shared Element Morph
```css
::view-transition-group(.morph) {
animation-duration: var(--duration-move);
}
::view-transition-image-pair(.morph) {
animation-name: via-blur;
}
@keyframes via-blur {
30% { filter: blur(3px); }
}
```
Usage: `<ViewTransition name={`product-${id}`} share="morph" />`
**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.
## Text Morph
Avoids raster scaling artifacts on text by hiding the old snapshot and showing the new text at full resolution:
```css
::view-transition-group(.text-morph) {
animation-duration: var(--duration-move);
}
::view-transition-old(.text-morph) {
display: none;
}
::view-transition-new(.text-morph) {
animation: none;
object-fit: none;
object-position: left top;
}
```
Usage: `<ViewTransition name={`title-${id}`} share="text-morph" />`
---
## Scale
```css
::view-transition-old(.scale-out) {
animation: var(--duration-exit) ease-in scale-down;
}
::view-transition-new(.scale-in) {
animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;
}
@keyframes scale-down {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.85); opacity: 0; }
}
@keyframes scale-up {
from { transform: scale(0.85); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
```
Usage: `<ViewTransition enter="scale-in" exit="scale-out" />`
---
## Persistent Element Isolation
```css
::view-transition-group(persistent-nav) {
animation: none;
z-index: 100;
}
```
### Backdrop-Blur Workaround
For elements with `backdrop-filter`, hide the old snapshot to avoid flash:
```css
::view-transition-old(persistent-nav) {
display: none;
}
::view-transition-new(persistent-nav) {
animation: none;
}
```
---
## Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
```
@@ -0,0 +1,182 @@
# Implementation Workflow
Follow these steps in order when adding view transitions to an app. Each step builds on the previous one.
## Step 1: Audit the App
Before writing any code, scan the codebase thoroughly. Search for:
- **Every `<Link>` and `router.push`** — these are your navigation triggers. Open every file that contains one.
- **Every `<Suspense>` boundary** — each one is a candidate for a reveal animation. Check what its fallback renders.
- **Every page/route component** — list them all. Each page needs a VT placement decision.
- **Persistent elements** — headers, navbars, sidebars, sticky controls that stay on screen across navigations. These need `viewTransitionName` isolation.
- **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).
- **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`.
Then classify every navigation and produce a navigation map:
```
| Route | Navigates to | Direction | VT pattern |
|-----------------|----------------------|--------------|-----------------------|
| / | /detail/[id] | forward | directional slide |
| /detail/[id] | / | back | directional slide |
| /detail/[id] | /detail/[other] | sequential | directional slide (ordered prev/next) or key+share crossfade |
| /tab/[a] | /tab/[b] | lateral | key+share crossfade |
| (Suspense) | (content loads) | — | slide-up reveal |
```
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`.
## Step 2: Add CSS Recipes
Copy the **complete** CSS recipe set from `css-recipes.md` into your global stylesheet. This includes timing variables, shared keyframes, fade, slide (vertical), directional navigation (forward/back), shared element morph, persistent element isolation, and reduced motion.
Do not write your own animation CSS — the recipes handle staggered timing, motion blur on morphs, and reduced motion that are easy to get wrong. You can customize timing variables (`--duration-exit`, `--duration-enter`, `--duration-move`) after the initial setup.
## Step 3: Isolate Persistent Elements
For every persistent element identified in Step 1, add a `viewTransitionName` style to pull it out of the page content's transition snapshot:
```jsx
<header style={{ viewTransitionName: "site-header" }}>...</header>
```
Then add the persistent element isolation CSS from `css-recipes.md` (prevents the element from animating during page transitions). If the element uses `backdrop-blur` or `backdrop-filter`, use the backdrop-blur workaround from `css-recipes.md` instead.
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.
## Step 4: Add Directional Page Transitions
For hierarchical navigations identified in Step 1, tag the navigation direction using `addTransitionType` inside `startTransition`:
```jsx
startTransition(() => {
addTransitionType('nav-forward');
router.push('/detail/1');
});
```
Then wrap each **page component** (not layout) in a type-keyed `<ViewTransition>`:
```jsx
<ViewTransition
enter={{
"nav-forward": "nav-forward",
"nav-back": "nav-back",
default: "none",
}}
exit={{
"nav-forward": "nav-forward",
"nav-back": "nav-back",
default: "none",
}}
default="none"
>
<div>...page content...</div>
</ViewTransition>
```
The `nav-forward` and `nav-back` CSS classes from `css-recipes.md` 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.
Extract this into a reusable component so every page doesn't repeat the verbose type map:
```jsx
export function DirectionalTransition({ children }: { children: React.ReactNode }) {
return (
<ViewTransition
enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
default="none"
>
{children}
</ViewTransition>
);
}
```
This also becomes the single place to adjust if you add new transition types later.
**Rules:**
- Always pair `enter` with `exit` — without an exit animation, the old page disappears instantly while the new one animates in.
- Always include `default: "none"` in type map objects and `default="none"` on the component — otherwise it fires on every transition.
- Place the directional `<ViewTransition>` in each page component, not in a layout. Layouts persist across navigations and never trigger enter/exit.
- 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"`.
## Step 5: Add Suspense Reveals
For every `<Suspense>` boundary identified in Step 1, wrap the fallback and content in separate `<ViewTransition>`s:
```jsx
<Suspense
fallback={
<ViewTransition exit="slide-down">
<Skeleton />
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<AsyncContent />
</ViewTransition>
</Suspense>
```
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 — consult the "Choosing the Right Animation Style" table in the main skill file.
**Rules:**
- Always use `default="none"` on the content `<ViewTransition>` to prevent re-animation on revalidation or unrelated transitions.
- 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.
## Step 6: Add Shared Element Transitions
For every shared visual element identified in Step 1, add matching named `<ViewTransition>` wrappers on both the source and target views:
```jsx
// On the source view (e.g., list/grid page)
<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
<Image src={photo.src} ... />
</ViewTransition>
// On the target view (e.g., detail page) — same name
<ViewTransition name={`photo-${photo.id}`} share="morph">
<Image src={photo.src} ... />
</ViewTransition>
```
The `share="morph"` class uses the morph recipe from `css-recipes.md` (controlled duration + motion blur). For a simpler cross-fade, use `share="auto"` (browser default).
When list items contain shared elements, compose both patterns with two nested `<ViewTransition>` layers — see "Composing Shared Elements with List Identity" in `SKILL.md`.
**Rules:**
- Names must be globally unique — use prefixes like `photo-${id}`.
- Add `default="none"` on list-side shared elements to prevent per-item cross-fades on filter/search updates.
## Step 7: Verify Each Navigation Path
Walk through every row in the navigation map from Step 1 and confirm:
- Does the VT mount/unmount on this navigation, or does it stay mounted (same-route)?
- For named VTs: does a shared pair form? If not, does `enter`/`exit` provide a fallback?
- Does `default="none"` block an animation you actually want?
- Do persistent elements stay static (not sliding with page content)?
- Do Suspense reveals animate independently from directional navigations?
If any path produces no animation or competing animations, revisit the relevant step.
---
## Common Mistakes
- **Bare `<ViewTransition>` without props** — without `default="none"`, it fires the browser's default cross-fade on every transition (every navigation, every Suspense resolve, every revalidation). Always set `default="none"` and explicitly enable only the triggers you want.
- **Directional `<ViewTransition>` in a layout** — layouts persist across navigations and never unmount/remount. `enter`/`exit` props won't fire on route changes. Place the outer type-keyed `<ViewTransition>` in each page component.
- **Fade-out exit with shared element morphs** — the page dissolving conflicts with the morph. Use a directional slide exit instead.
- **Writing custom animation CSS** — the recipes in `css-recipes.md` handle staggered timing, motion blur on morphs, and reduced motion. Copy them; don't reinvent them.
- **Missing `default: "none"` in type-keyed objects** — TypeScript requires a `default` key, and without it the fallback is `"auto"` which fires on every transition.
- **Type maps on Suspense reveals** — Suspense resolves fire as separate transitions with no type. Type-keyed props won't match — use simple string props instead.
- **Raw `viewTransitionName` CSS to trigger animations** — React only calls `document.startViewTransition` when `<ViewTransition>` components are in the tree. A bare `viewTransitionName` style is for isolating elements from a parent's snapshot, not for triggering animations.
- **`update` trigger for same-route navigations** — nested VTs inside the content steal the mutation from the parent, so `update` never fires on the outer VT. Use `key` + `name` + `share` instead.
- **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.
- **`router.back()` for back navigation** — `router.back()` triggers synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.
---
For Next.js-specific implementation steps (config flag, `transitionTypes` on `<Link>`, same-route dynamic segments), see `nextjs.md`.
@@ -0,0 +1,176 @@
# View Transitions in Next.js
## Setup
`<ViewTransition>` works out of the box for `startTransition`/`Suspense` updates. To also animate `<Link>` navigations:
```js
// next.config.js
const nextConfig = {
experimental: { viewTransition: true },
};
module.exports = nextConfig;
```
This wraps every `<Link>` navigation in `document.startViewTransition`. Any VT with `default="auto"` fires on **every** link click — use `default="none"` to prevent competing animations.
Do **not** install `react@canary` — see SKILL.md "Availability" for details.
---
## Next.js Implementation Additions
When following `implementation.md`, apply these additions:
**After Step 2:** Enable the experimental flag above.
**Step 4:** Use `transitionTypes` on `<Link>` — see "The `transitionTypes` Prop" section below for usage and availability.
**After Step 6:** For same-route dynamic segments (e.g., `/collection/[slug]`), use the `key` + `name` + `share` pattern — see Same-Route Dynamic Segment Transitions below.
---
## Layout-Level ViewTransition
**Do NOT add a layout-level VT wrapping `{children}` if pages have their own VTs.** Nested VTs never fire enter/exit when inside a parent VT — page-level enter/exit will silently not work. Remove the layout VT entirely.
A bare `<ViewTransition>` in layout works only if pages have **no** VTs of their own.
**Layouts persist across navigations**`enter`/`exit` only fire on initial mount, not on route changes. Don't use type-keyed maps in layouts.
---
## The `transitionTypes` Prop on `next/link`
No wrapper component needed, works in Server Components:
```tsx
<Link href="/products/1" transitionTypes={['transition-to-detail']}>View Product</Link>
```
Replaces the manual pattern of `onNavigate` + `startTransition` + `addTransitionType` + `router.push()`. Reserve manual `startTransition` for non-link interactions (buttons, forms).
**Availability:** `transitionTypes` requires `experimental.viewTransition: true` and is available in Next.js 15+ canary builds and Next.js 16+. If unavailable, use `startTransition` + `addTransitionType` + `router.push()` (see Programmatic Navigation below). To check: `grep -r "transitionTypes" node_modules/next/dist/` — if no results, fall back to programmatic navigation.
---
## Programmatic Navigation
```tsx
'use client';
import { useRouter } from 'next/navigation';
import { startTransition, addTransitionType } from 'react';
function handleNavigate(href: string) {
const router = useRouter();
startTransition(() => {
addTransitionType('nav-forward');
router.push(href);
});
}
```
---
## Server-Side Filtering with `router.replace`
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`:
```tsx
'use client';
import { useRouter } from 'next/navigation';
import { startTransition } from 'react';
function handleSort(sort: string) {
const router = useRouter();
startTransition(() => {
router.replace(`?sort=${sort}`);
});
}
```
List items wrapped in `<ViewTransition key={item.id}>` will animate reorder. This is the server-component alternative to the client-side `useDeferredValue` pattern in `patterns.md`.
---
## Two-Layer Pattern (Directional + Suspense)
Directional slides + Suspense reveals coexist because they fire at different moments. Place the directional VT in the **page component** (not layout):
```tsx
<ViewTransition
enter={{ "nav-forward": "slide-from-right", default: "none" }}
exit={{ "nav-forward": "slide-to-left", default: "none" }}
default="none"
>
<div>
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
<ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
</Suspense>
</div>
</ViewTransition>
```
---
## `loading.tsx` as Suspense Boundary
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:
```tsx
// loading.tsx
<ViewTransition exit="slide-down"><PhotoGridSkeleton /></ViewTransition>
// page.tsx
<ViewTransition enter="slide-up" default="none"><PhotoGrid photos={photos} /></ViewTransition>
```
Same rules as explicit `<Suspense>`: use simple string props (not type maps) since Suspense reveals fire without transition types.
---
## Shared Elements Across Routes
```tsx
// List page
{products.map((product) => (
<Link key={product.id} href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
<ViewTransition name={`product-${product.id}`}>
<Image src={product.image} alt={product.name} width={400} height={300} />
</ViewTransition>
</Link>
))}
// Detail page — same name
<ViewTransition name={`product-${product.id}`}>
<Image src={product.image} alt={product.name} width={800} height={600} />
</ViewTransition>
```
---
## Same-Route Dynamic Segment Transitions
When navigating between dynamic segments of the same route (e.g., `/collection/[slug]`), the page stays mounted — enter/exit never fire. Use `key` + `name` + `share`:
```tsx
<Suspense fallback={<Skeleton />}>
<ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
<Content slug={slug} />
</ViewTransition>
</Suspense>
```
- `key={slug}` forces unmount/remount on change
- `name` + `share="auto"` creates a shared element crossfade
- VT inside `<Suspense>` (without keying Suspense) keeps old content visible during loading
---
## Server Components
- `<ViewTransition>` works in both Server and Client Components
- `<Link transitionTypes>` works in Server Components — no `'use client'` needed
- `addTransitionType` and `startTransition` for programmatic nav require Client Components
@@ -0,0 +1,262 @@
# Patterns and Guidelines
## Searchable Grid with `useDeferredValue`
`useDeferredValue` makes filter updates a transition, activating `<ViewTransition>`:
```tsx
'use client';
import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';
export default function SearchableGrid({ itemsPromise }) {
const [search, setSearch] = useState('');
const deferredSearch = useDeferredValue(search);
return (
<>
<input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<ViewTransition>
<Suspense fallback={<GridSkeleton />}>
<ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />
</Suspense>
</ViewTransition>
</>
);
}
```
Per-item `<ViewTransition name={...}>` inside a deferred list triggers cross-fades on every keystroke. Fix with `default="none"`:
```tsx
{filteredItems.map(item => (
<ViewTransition key={item.id} name={`item-${item.id}`} share="morph" default="none">
<ItemCard item={item} />
</ViewTransition>
))}
```
## Card Expand/Collapse with `startTransition`
Toggle between grid and detail view with shared element morph:
```tsx
'use client';
import { useState, useRef, startTransition, ViewTransition } from 'react';
export default function ItemGrid({ items }) {
const [expandedId, setExpandedId] = useState(null);
const scrollRef = useRef(0);
return expandedId ? (
<ViewTransition enter="slide-in" name={`item-${expandedId}`}>
<ItemDetail
item={items.find(i => i.id === expandedId)}
onClose={() => {
startTransition(() => {
setExpandedId(null);
setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);
});
}}
/>
</ViewTransition>
) : (
<div className="grid grid-cols-3 gap-4">
{items.map(item => (
<ViewTransition key={item.id} name={`item-${item.id}`}>
<ItemCard
item={item}
onSelect={() => {
scrollRef.current = window.scrollY;
startTransition(() => setExpandedId(item.id));
}}
/>
</ViewTransition>
))}
</div>
);
}
```
## Type-Safe Transition Helpers
Use `as const` arrays and derived types to prevent ID clashes:
```tsx
const transitionTypes = ['default', 'transition-to-detail', 'transition-to-list'] as const;
const animationTypes = ['auto', 'none', 'animate-slide-from-left', 'animate-slide-from-right'] as const;
type TransitionType = (typeof transitionTypes)[number];
type AnimationType = (typeof animationTypes)[number];
type TransitionMap = { default: AnimationType } & Partial<Record<Exclude<TransitionType, 'default'>, AnimationType>>;
export function HorizontalTransition({ children, enter, exit }: {
children: React.ReactNode;
enter: TransitionMap;
exit: TransitionMap;
}) {
return <ViewTransition enter={enter} exit={exit}>{children}</ViewTransition>;
}
```
## Cross-Fade Without Remount
Omit `key` to trigger an update (cross-fade) instead of exit + enter. Avoids Suspense remount/refetch:
```jsx
<ViewTransition>
<TabPanel tab={activeTab} />
</ViewTransition>
```
Use `key` when content identity changes (state resets). Omit for cross-fades (tabs, panels, carousel).
## Isolate Elements from Parent Animations
### Persistent Layout Elements
Persistent elements (headers, navbars, sidebars) get captured in the page's transition snapshot. Fix with `viewTransitionName`:
```jsx
<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
```
Then add the persistent element isolation CSS from `css-recipes.md`. For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround from `css-recipes.md`.
### Floating Elements
Give popovers/tooltips their own `viewTransitionName`:
```jsx
<SelectPopover style={{ viewTransitionName: 'popover' }}>{options}</SelectPopover>
```
Global fix: see persistent element isolation in `css-recipes.md`.
## Shared Controls Between Skeleton and Content
Give matching controls in fallback and content the same `viewTransitionName`:
```jsx
// Fallback
<input disabled placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
// Content
<input placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
```
Don't put manual `viewTransitionName` on the root DOM node inside `<ViewTransition>` — React's auto-generated name overrides it.
## Reusable Animated Collapse
```jsx
function AnimatedCollapse({ open, children }) {
if (!open) return null;
return (
<ViewTransition enter="expand-in" exit="collapse-out">
{children}
</ViewTransition>
);
}
// Usage: toggle with startTransition
<button onClick={() => startTransition(() => setOpen(o => !o))}>Toggle</button>
<AnimatedCollapse open={open}><SectionContent /></AnimatedCollapse>
```
## Preserve State with Activity
```jsx
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<ViewTransition enter="slide-in" exit="slide-out">
<Sidebar />
</ViewTransition>
</Activity>
```
## Exclude Elements with `useOptimistic`
`useOptimistic` values update before the transition snapshot, excluding them from animation. Use for controls (labels); use committed state for animated content:
```tsx
const [sort, setSort] = useState('newest');
const [optimisticSort, setOptimisticSort] = useOptimistic(sort);
function cycleSort() {
const nextSort = getNextSort(optimisticSort);
startTransition(() => {
setOptimisticSort(nextSort); // before snapshot — no animation
setSort(nextSort); // between snapshots — animates
});
}
<button>Sort: {LABELS[optimisticSort]}</button>
{items.sort(comparators[sort]).map(item => (
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
))}
```
---
## View Transition Events
Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return a cleanup function. `onShare` takes precedence over `onEnter`/`onExit`.
```jsx
<ViewTransition
onEnter={(instance, types) => {
const anim = instance.new.animate(
[{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
{ duration: 300, easing: 'ease-out' }
);
return () => anim.cancel();
}}
>
<Component />
</ViewTransition>
```
The `instance` object: `instance.old`, `instance.new`, `instance.group`, `instance.imagePair`, `instance.name`.
The `types` array (second argument) lets you vary animation based on transition type.
---
## Animation Timing
| Interaction | Duration |
|------------|----------|
| Direct toggle (expand/collapse) | 100200ms |
| Route transition (slide) | 150250ms |
| Suspense reveal (skeleton → content) | 200400ms |
| Shared element morph | 300500ms |
---
## Troubleshooting
**VT not activating:** Ensure `<ViewTransition>` comes before any DOM node. Ensure state update is inside `startTransition`.
**"Two ViewTransition components with the same name":** Names must be globally unique. Use IDs: `name={`hero-${item.id}`}`.
**`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead. See SKILL.md "router.back() and Browser Back Button."
**`flushSync` skips animations:** Use `startTransition` instead.
**Only updates animate (no enter/exit):** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.
**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.
**List reorder not animating with `useOptimistic`:** Optimistic values resolve before snapshot. Use committed state for list order.
**TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.
**Hash fragments cause scroll jumps:** Navigate without hash; scroll programmatically after navigation.
**Backdrop-blur flickers:** Use the backdrop-blur workaround from `css-recipes.md`.
**`border-radius` lost during transitions:** Apply `border-radius` directly to the captured element.
**Skeleton controls slide away:** Give matching controls the same `viewTransitionName`.
**Batching:** Multiple updates during animation are batched. A→B→C→D becomes B→D.