# React View Transitions **Version 1.0.0** Vercel Engineering March 2026 > **Note:** > This document is mainly for agents and LLMs to follow when implementing > view transitions in React applications. Humans may also find it useful, > but guidance here is optimized for automation and consistency by > AI-assisted workflows. --- ## Abstract Guide for implementing smooth, native-feeling animations using React's View Transition API. Covers the `` component, `addTransitionType`, CSS view transition pseudo-elements, shared element transitions, Suspense reveals, list reorder, directional navigation, and Next.js integration. Includes a step-by-step implementation workflow, ready-to-use CSS animation recipes, and common mistake warnings. --- ## Table of Contents 1. [Core Reference](#when-to-animate) - [When to Animate](#when-to-animate) - [Availability](#availability) - [Core Concepts](#core-concepts) - [Styling with View Transition Classes](#styling-with-view-transition-classes) - [Transition Types](#transition-types) - [Shared Element Transitions](#shared-element-transitions) - [Common Patterns](#common-patterns) - [How Multiple VTs Interact](#how-multiple-vts-interact) - [Next.js Integration](#nextjs-integration) - [Accessibility](#accessibility) 2. [Implementation Workflow](#implementation-workflow) - [Step 1: Audit the App](#step-1-audit-the-app) - [Step 2: Add CSS Recipes](#step-2-add-css-recipes) - [Step 3: Isolate Persistent Elements](#step-3-isolate-persistent-elements) - [Step 4: Add Directional Page Transitions](#step-4-add-directional-page-transitions) - [Step 5: Add Suspense Reveals](#step-5-add-suspense-reveals) - [Step 6: Add Shared Element Transitions](#step-6-add-shared-element-transitions) - [Step 7: Verify Each Navigation Path](#step-7-verify-each-navigation-path) - [Common Mistakes](#common-mistakes) 3. [Patterns and Guidelines](#patterns-and-guidelines) 4. [CSS Animation Recipes](#css-animation-recipes) 5. [View Transitions in Next.js](#view-transitions-in-nextjs) --- Animate between UI states using the browser's native `document.startViewTransition`. Declare *what* with ``, trigger *when* with `startTransition` / `useDeferredValue` / `Suspense`, control *how* with CSS classes. Unsupported browsers skip animations gracefully. ## When to Animate Every `` 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 `` (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. --- ## Core Concepts ### The `` Component ```jsx import { ViewTransition } from 'react'; ``` 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** | VT first inserted during a Transition | | **exit** | VT first removed during a Transition | | **update** | DOM mutations inside a VT. With nested VTs, mutation applies to the innermost one | | **share** | Named VT unmounts and another with same `name` mounts in same Transition | Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate. ### Critical Placement Rule VT only activates enter/exit if it appears **before any DOM nodes**: ```jsx // Works
Content
// Broken — div wraps the VT
Content
``` --- ## Styling with View Transition Classes Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations. ```jsx ``` 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. Call it multiple times to stack types — different VTs in the tree react to different types: ```jsx startTransition(() => { addTransitionType('nav-forward'); addTransitionType('select-item'); router.push('/detail/1'); }); ``` Map types to CSS classes. Works on `enter`, `exit`, **and** `share`: ```jsx ``` `enter` and `exit` don't have to be symmetric. For example, fade in but slide out directionally: ```jsx ``` **TypeScript:** `ViewTransitionClassPerType` requires a `default` key. ### `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`). This is a current platform limitation — back/forward navigations will work but skip animation. **Do not** replace `router.back()` with `router.push()` to force an animation. That corrupts browser history: every "back" action becomes a new history entry, trapping users in duplicate pages. If an animated back-navigation is critical to the UX, use `router.push()` with the explicit previous URL **and** document it as a deliberate history tradeoff, not the default pattern. ### Types and Suspense 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. --- ## Shared Element Transitions Same `name` on two VTs — one unmounting, one mounting — creates a shared element morph: ```jsx startTransition(() => onSelect())} /> // Other view — same name ``` - Only one VT with a given `name` can be mounted at a time — use unique names. Watch for reusable components: if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Either make the name conditional (via a prop) or move the named VT out of the shared component into the specific consumer. - `share` takes precedence over `enter`/`exit`. Think through each navigation path: when no pair forms, `enter`/`exit` fires instead. Consider whether the element needs a fallback animation for those paths. - Never use fade-out exit on pages with shared morphs — use directional slide. --- ## Common Patterns ### Enter/Exit ```jsx {show && ( )} ``` ### List Reorder ```jsx {items.map(item => ( ))} ``` Trigger inside `startTransition`. Avoid wrapper `
`s between list and VT. ### Composing Shared Elements with List Identity Shared elements and list identity are independent concerns — don't confuse one for the other. When a list item contains a shared element, use two nested `` boundaries: ```jsx {items.map(item => ( {/* list identity */} {/* shared element */}

{item.name}

))} ``` The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen. ### Force Re-Enter with `key` ```jsx ``` **Caution:** Wrapping `` with key remounts the boundary and refetches. ### Suspense Fallback to Content Simple cross-fade: ```jsx }> ``` Directional reveal: ```jsx
}> ``` --- ## How Multiple VTs Interact Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions don't compete. ### Use `default="none"` Liberally Without it, every VT fires the browser cross-fade on **every** transition. 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. See [react#36135](https://github.com/facebook/react/pull/36135) for an experimental opt-in fix. --- ## Next.js Integration See the [View Transitions in Next.js](#view-transitions-in-nextjs) section below. --- ## Accessibility Always add reduced motion CSS to your global stylesheet: ```css @media (prefers-reduced-motion: reduce) { ::view-transition-old(*), ::view-transition-new(*), ::view-transition-group(*) { animation-duration: 0s !important; animation-delay: 0s !important; } } ``` --- # Implementation Workflow **Follow these steps in order.** Start with the audit — do not skip it. Copy the CSS recipes from the CSS Recipes section below — do not write your own animation CSS. ## Step 1: Audit the App Before writing any code, scan the codebase thoroughly. Search for: - **Every `` and `router.push`** — open every file that contains one - **Every `` boundary** — check what its fallback renders - **Every page/route component** — each needs a VT placement decision - **Persistent elements** (headers, navbars, sidebars) — need `viewTransitionName` isolation - **Shared visual elements** on both source and target views - **Skeleton-to-content control pairs** — if a fallback renders a control 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 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 the CSS Animation Recipes section below into your global stylesheet. Don't write your own — the recipes handle staggered timing, motion blur, and reduced motion. ## Step 3: Isolate Persistent Elements ```jsx
...
``` ```css ::view-transition-group(site-header) { animation: none; z-index: 100; } ``` For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround instead. ## Step 4: Add Directional Page Transitions ```jsx startTransition(() => { addTransitionType('nav-forward'); router.push('/detail/1'); }); ``` Wrap each **page component** (not layout) in a type-keyed VT: ```jsx
...page content...
``` Extract into a reusable component so every page doesn't repeat the type map: ```jsx export function DirectionalTransition({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` **Rules:** Always pair `enter` with `exit`. Always include `default: "none"`. Place in page components, not layouts. Only use directional slides for hierarchical navigation or ordered sequences (prev/next). ## Step 5: Add Suspense Reveals ```jsx }> ``` Use `default="none"` on content VT. Use simple string props (not type maps) — Suspense resolves have no type. ## Step 6: Add Shared Element Transitions ```jsx // Source view // Target view — same name ``` When list items contain shared elements, compose both patterns — two independent layers: ```jsx {items.map(item => ( {/* list identity */} {/* shared element */} ))} ``` The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen. **Rules:** Names must be globally unique. Add `default="none"` on list-side shared elements. ## Step 7: Verify Each Navigation Path Walk through every row in the navigation map from Step 1: - Does the VT mount/unmount, or 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? - Do Suspense reveals animate independently from directional navigations? --- ## Common Mistakes - **Bare VT without `default="none"`** — fires cross-fade on every transition - **Directional VT in a layout** — layouts persist, enter/exit won't fire on route changes - **Fade-out exit with shared morphs** — conflicts with morph, use directional slide - **Writing custom animation CSS** — use the recipes - **Missing `default: "none"` in type-keyed objects** — TypeScript requires it, fallback is `"auto"` - **Type maps on Suspense reveals** — Suspense resolves have no type, use string props - **Raw `viewTransitionName` CSS to trigger animations** — React only starts view transitions when `` components are in the tree. Bare `viewTransitionName` is for isolating elements, not triggering animations. - **`update` trigger for same-route navigations** — nested VTs steal the mutation from the parent. 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. Back/forward navigations work but skip animation. Do not replace `router.back()` with `router.push()` — that corrupts browser history. For Next.js-specific steps, see the Next.js section below. --- # Patterns and Guidelines ## Searchable Grid with `useDeferredValue` ```tsx 'use client'; import { useDeferredValue, useState, ViewTransition, Suspense } from 'react'; export function SearchableGrid({ itemsPromise }) { const [search, setSearch] = useState(''); const deferredSearch = useDeferredValue(search); return ( <> setSearch(e.currentTarget.value)} /> }> ); } ``` Per-item named VTs in deferred lists trigger cross-fades on every keystroke. Fix with `default="none"`. ## Card Expand/Collapse with `startTransition` ```tsx 'use client'; import { useState, useRef, startTransition, ViewTransition } from 'react'; export function ItemGrid({ items }) { const [expandedId, setExpandedId] = useState(null); const scrollRef = useRef(0); return expandedId ? ( i.id === expandedId)} onClose={() => { startTransition(() => { setExpandedId(null); setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100); }); }} /> ) : (
{items.map(item => ( { scrollRef.current = window.scrollY; startTransition(() => setExpandedId(item.id)); }} /> ))}
); } ``` ## Cross-Fade Without Remount Omit `key` to trigger update (cross-fade) instead of exit + enter. Avoids Suspense remount: ```jsx ``` ## Isolate Elements from Parent Animations Persistent elements get captured in page's transition snapshot. Fix with `viewTransitionName`: ```jsx ``` ```css ::view-transition-group(persistent-nav) { animation: none; z-index: 100; } ``` Same for floating elements (popovers, tooltips). Global fix: `::view-transition-group(*) { z-index: 100; }` ## Shared Controls Between Skeleton and Content Give matching controls the same `viewTransitionName`. Don't put manual `viewTransitionName` on root DOM node inside ``. ## Reusable Animated Collapse ```jsx function AnimatedCollapse({ open, children }) { if (!open) return null; return {children}; } ``` ## Preserve State with Activity ```jsx ``` ## Exclude Elements with `useOptimistic` `useOptimistic` values update before snapshot, excluding them from animation. Use for controls; use committed state for animated content. --- ## View Transition Events Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return cleanup. `onShare` takes precedence. ```jsx { const anim = instance.new.animate( [{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }], { duration: 300, easing: 'ease-out' } ); return () => anim.cancel(); }} > ``` `instance`: `.old`, `.new`, `.group`, `.imagePair`, `.name` --- ## Animation Timing | Interaction | Duration | |------------|----------| | Direct toggle | 100–200ms | | Route transition | 150–250ms | | Suspense reveal | 200–400ms | | Shared element morph | 300–500ms | --- ## Troubleshooting **VT not activating:** Ensure VT comes before any DOM node. Ensure `startTransition`. **"Two VTs with same name":** Names must be globally unique. Use IDs. **`router.back()` and browser back/forward skip animation:** This is a platform limitation — the navigation works but skips animation. Do not replace `router.back()` with `router.push()` as it corrupts browser history. **Only updates animate:** Without ``, React treats swaps as updates. Conditionally render the VT itself, or wrap in ``. **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. **TS error "Property 'default' is missing":** Type-keyed objects require a `default` key. **Backdrop-blur flickers:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`. **`border-radius` lost:** Apply `border-radius` directly to captured element. **Batching:** Multiple updates during animation are batched (A→B→C→D becomes B→D). --- # CSS Animation Recipes Ready-to-use CSS for `` props. Copy into 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; } ``` ## 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; } ``` ## Directional Navigation ### 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; } ``` ### 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; } ``` ## 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); } } ``` **Note:** Shared element transitions take raster snapshots. For text with significant size differences (e.g., `

` → `

`), 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; } ``` ## 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; } } ``` ## Persistent Element Isolation ```css ::view-transition-group(persistent-nav) { animation: none; z-index: 100; } ``` ### Backdrop-Blur Workaround ```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; } } ``` --- # View Transitions in Next.js ## Setup ```js // next.config.js experimental: { viewTransition: true } ``` Wraps every `` navigation in `document.startViewTransition`. Use `default="none"` to prevent competing animations. Do **not** install `react@canary` — the App Router already bundles it. ## Next.js Implementation Additions **After Step 2:** Enable the experimental flag. **Step 4:** Use `transitionTypes` on `` (if available — see availability note below): ```tsx View Back ``` **After Step 6:** For same-route dynamic segments, use `key` + `name` + `share` pattern. ## Layout-Level ViewTransition Don't add a layout-level VT wrapping `{children}` if pages have their own VTs — nested VTs never fire enter/exit inside a parent VT, so page-level enter/exit will silently not work. Remove the layout VT entirely. A bare VT in layout works only if pages have no VTs of their own. Layouts persist across navigations — don't use type-keyed maps in layouts. ## The `transitionTypes` Prop Works in Server Components, no wrapper needed: ```tsx View ``` **Availability:** Requires `experimental.viewTransition: true`. Available in Next.js 15+ canary builds and Next.js 16+. If unavailable, use `startTransition` + `addTransitionType` + `router.push()`. To check: `grep -r "transitionTypes" node_modules/next/dist/`. Reserve manual `startTransition` for non-link interactions. ## `loading.tsx` as Suspense Boundary Next.js `loading.tsx` files are implicit `` boundaries. Wrap the skeleton in `` in `loading.tsx`, and the content in `` in the page. This is the Next.js-idiomatic equivalent of explicit ``. Same rules apply: use simple string props (not type maps) since Suspense reveals fire without transition types. ## 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 update is inside `startTransition`. List items wrapped in `` animate reorder. This is the server-component alternative to the client-side `useDeferredValue` pattern. ## 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
}>
``` ## Shared Elements Across Routes ```tsx // List page {product.name} // Detail page — same name {product.name} ``` ## Same-Route Dynamic Segment Transitions Page stays mounted on dynamic segment change — enter/exit never fire. Use `key` + `name` + `share`: ```tsx }> ``` ## Server Components - `` works in Server and Client Components - `` works in Server Components - `addTransitionType` and programmatic nav require Client Components