View Transitions in Modern React: Declarative Element and Route Morphing
Creating fluid, native-feeling transitions between application views—such as expanding a card into a full-page modal, cross-fading tab panels, or morphing a product thumbnail into an image banner—has long been one of frontend development's most complex challenges.
Historically, achieving these effects required heavy JavaScript animation libraries (like Framer Motion or React Spring) or fragile layout calculation algorithms (FLIP techniques). While powerful, JavaScript-driven animations frequently conflict with React's concurrent scheduler, drop frames under heavy CPU load, and require maintaining complex coordinate caches across DOM mutations.
The web platform addressed this with the native browser View Transitions API (MDN documentation). To bring this capability natively into the React component model, React introduced the <ViewTransition> component (React documentation).
In this deep dive, we explore how <ViewTransition> integrates with React Concurrent Transitions and Suspense, how to choreograph Enter, Exit, and Update animations, and how to build seamless Shared Element Transitions without external libraries.
What is the <ViewTransition> Component?
<ViewTransition> is a built-in React component that lets you animate a component tree using browser-native View Transitions (React documentation):
import { ViewTransition } from 'react';
function ProductCard({ isExpanded }: { isExpanded: boolean }) {
return (
<ViewTransition>
<div className={isExpanded ? 'card-fullscreen' : 'card-compact'}>
<img src="/photo.jpg" alt="Product" />
<h2>Product Details</h2>
</div>
</ViewTransition>
);
}
Instead of running custom JavaScript animation loops to interpolate top, left, width, and height properties, <ViewTransition> captures screenshots of the old and new states using the browser engine. The browser animates the transition on the compositor thread at high refresh rates (120 Hz) with zero JavaScript execution overhead.
Activating View Transitions: The Transition Rule
A critical architectural constraint of <ViewTransition> is that standard, synchronous setState updates do not trigger animations (React documentation).
To activate a View Transition, the state change must be wrapped in a React Transition (startTransition, useTransition, or useDeferredValue):
'use client';
import { useState, useTransition, ViewTransition } from 'react';
export function TabNavigator() {
const [tab, setTab] = useState<'feed' | 'profile'>('feed');
const [isPending, startTransition] = useTransition();
function handleTabChange(nextTab: 'feed' | 'profile') {
// ✅ Wrapping the update in a Transition activates <ViewTransition>
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<nav>
<button onClick={() => handleTabChange('feed')}>Feed</button>
<button onClick={() => handleTabChange('profile')}>Profile</button>
</nav>
<main>
<ViewTransition>
{tab === 'feed' ? <FeedView /> : <ProfileView />}
</ViewTransition>
</main>
</div>
);
}
By default, React applies a smooth cross-fade between <FeedView /> and <ProfileView />.
The Four Animation Triggers
React automatically analyzes DOM mutations and categorizes them into four distinct View Transition triggers (React documentation):
enter: Activated when a<ViewTransition>is newly inserted into the DOM.exit: Activated when a<ViewTransition>is deleted or unmounted from the DOM.update: Activated when an existing<ViewTransition>boundary changes size, changes position, or has internal DOM mutations.share(Shared Element Transition): Activated when an element being deleted and an element being inserted share the exact samenameproperty.
Building a Shared Element Transition: Hero Card Expansion
A classic app pattern is clicking a thumbnail in a grid and watching it smoothly expand into a hero banner on a details page.
With <ViewTransition>, implementing this takes just a shared name prop:
'use client';
import { useState, useTransition, ViewTransition } from 'react';
interface Item {
id: string;
title: string;
imgUrl: string;
}
export function Gallery({ items }: { items: Item[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [, startTransition] = useTransition();
const selectedItem = items.find(i => i.id === selectedId);
return (
<div className="gallery-container">
{selectedItem ? (
// Detailed View
<div className="detail-view">
<ViewTransition name={`hero-${selectedItem.id}`}>
<img src={selectedItem.imgUrl} className="hero-banner" alt={selectedItem.title} />
</ViewTransition>
<h1>{selectedItem.title}</h1>
<button onClick={() => startTransition(() => setSelectedId(null))}>Back</button>
</div>
) : (
// Grid View
<div className="thumbnail-grid">
{items.map(item => (
<div key={item.id} onClick={() => startTransition(() => setSelectedId(item.id))}>
<ViewTransition name={`hero-${item.id}`}>
<img src={item.imgUrl} className="thumbnail" alt={item.title} />
</ViewTransition>
<p>{item.title}</p>
</div>
))}
</div>
)}
</div>
);
}
How React Coordinates the Morph:
When selectedId updates inside startTransition:
- React matches the thumbnail’s
name="hero-1"with the detail view'sname="hero-1". - The browser automatically translates, scales, and clips the image from its initial grid dimensions to its final hero dimensions on the screen.
- No coordinate calculations or layout measurements are required in application code!
Coordinating with Suspense and Asset Loading
Standard browser View Transitions take an immediate snapshot of the DOM, which can cause layout glitches if the incoming view is still waiting on data, web fonts, or stylesheets.
React’s <ViewTransition> solves this through deep integration with Suspense and asset preloading (React documentation):
- Waits for Data: If the incoming component suspends on a Promise, React displays the fallback or defers starting the transition until the data resolves.
- Waits for Stylesheets: React halts the transition until external
<link rel="stylesheet" precedence="...">tags finish parsing. - Waits up to 500ms for Images and Fonts: React prevents pop-in by waiting up to 500ms for visible images and custom web fonts to arrive before starting the transition.
Styling and Customizing Transitions in CSS
While React defaults to cross-fading, you can provide custom animation classes via CSS:
/* Custom View Transition pseudo-elements */
::view-transition-old(hero-1),
::view-transition-new(hero-1) {
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
Conclusion
<ViewTransition> represents a quantum leap in React UI animation. By tapping into browser-native compositor pipelines and unifying animations with React Transitions and Suspense, frontend engineers can deliver fluid, 120 FPS shared-element morphing with declarative simplicity and zero JavaScript bundle bloat.