playbook/antigravity-awesome-skills/skills/vercel-react-view-transitions/references/patterns.md

263 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.