📦 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,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.