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.
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.
Every <ViewTransition> should communicate a spatial relationship or continuity. If you can't articulate what it communicates, don't add it.
| 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 (/) |
enterexit| "Something appeared/disappeared" |
| 5 | Route change (layout-level) | "Going to a new place" |
Prefer #1–#4 over ambient route transitions. Only one tree level should animate at a time — adding a layout-level transition on top of per-page animations produces competing double-animation.
| 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 only. Directional slides on sibling links falsely imply spatial depth.
react@canary or react@experimental — not in stable React (including 19.x). Verify with npm ls react.When adding view transitions to an existing app, follow the step-by-step guide in references/implementation.md.
<ViewTransition> Componentimport { 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.
| 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.
<ViewTransition> only activates enter/exit if it appears before any DOM nodes:
// 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>
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.
::view-transition-old(.class) — outgoing snapshot::view-transition-new(.class) — incoming snapshot::view-transition-group(.class) — container::view-transition-image-pair(.class) — old + new pairSee references/css-recipes.md for ready-to-use animation recipes.
Tag transitions with addTransitionType so VTs can pick different animations based on context:
startTransition(() => {
addTransitionType('nav-forward');
router.push('/detail/1');
});
Pass an object to map types to CSS classes:
<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' }}
default="none"
>
<Page />
</ViewTransition>
TypeScript: ViewTransitionClassPerType requires a default key in the object.
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.
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>
// On the other view — same name
<ViewTransition name="hero-image">
<img src="/full.jpg" />
</ViewTransition>
name can be mounted at a time — use unique names (photo-${id}).share takes precedence over enter/exit.{show && (
<ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>
)}
{items.map(item => (
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
))}
Trigger inside startTransition. Avoid wrapper <div>s between list and VT.
key<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">
<ResultsGrid />
</ViewTransition>
Caution: If wrapping <Suspense>, changing key remounts the boundary and refetches.
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>
For more patterns, see references/patterns.md.
Every VT matching the trigger fires simultaneously in a single document.startViewTransition. VTs in different transitions (navigation vs later Suspense resolve) don't compete.
default="none" LiberallyWithout 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.
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.
<ViewTransition> works out of the box for startTransition/Suspense updates. To also animate <Link> navigations:
// next.config.js