📦 deps(thirdparty): update snapshots
This commit is contained in:
+242
@@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
+182
@@ -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
|
||||
+262
@@ -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) | 100–200ms |
|
||||
| Route transition (slide) | 150–250ms |
|
||||
| Suspense reveal (skeleton → content) | 200–400ms |
|
||||
| Shared element morph | 300–500ms |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user