| name | vercel-react-view-transitions |
| description | Guide for implementing smooth, native-feeling animations using React's View Transition API (`<ViewTransition>` component, `addTransitionType`, and CSS view transition pseudo-elements). Use this skill whenever the user wants to add page transitions, animate route changes, create shared element animations, animate enter/exit of components, animate list reorder, implement directional (forward/back) navigation animations, or integrate view transitions in Next.js. Also use when the user mentions view transitions, `startViewTransition`, `ViewTransition`, transition types, or asks about animating between UI states in React without third-party animation libraries. |
| license | MIT |
| metadata | {"author":"vercel","version":"1.0.0"} |
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 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.
Core Concepts
The <ViewTransition> Component
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:
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
<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.
<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
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:
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:
<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>
TypeScript: ViewTransitionClassPerType requires a default key in the object.
For apps with multiple pages, extract the type-keyed VT into a reusable wrapper:
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.
Shared Element Transitions
Same name on two VTs — one unmounting, one mounting — creates a shared element morph:
<ViewTransition name="hero-image">
<img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
</ViewTransition>
<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}).
share takes precedence over enter/exit. When no matching pair forms, enter/exit fires instead.
- Never use a fade-out exit on pages with shared morphs — use a directional slide instead.
Common Patterns
Enter/Exit
{show && (
<ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>
)}
List Reorder
{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
{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>
))}
Force Re-Enter with key
<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:
<ViewTransition>
<Suspense fallback={<Skeleton />}><Content /></Suspense>
</ViewTransition>
Directional reveal:
<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
<ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
</Suspense>
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.
Accessibility
Always add the reduced motion CSS to your global stylesheet:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}