Sep 20, 2026 First publish on Mar 17, 2023
Using View Transition API in React App
The View Transition API is a new feature on web that simplifies the process of creating animated transitions for shared element. Previously, achieving smooth transitions for shared element on the web was a complex task. However, with the introduction of this API, we can now easily animate shared element, allows us to create engaging and fluid navigational experiences, similar to those found in mobile applications.
In this article, I will provide a brief overview of how to start using View Transition API in your React applications. For a more comprehensive guide, please refer to this article authored by Jake Archibald, who championed this feature.
A simple example
In the example below, when the Move button is clicked, the cat image will switch from top right to bottom left (or vice versa) abruptly, as they are technically two separate elements.
Use the play button on the right to run the example.
document.getElementById('moveBtn').addEventListener('click', () => {
document
.querySelectorAll('.thumbnail, .cat-details')
.forEach((target) => target.classList.toggle('hidden'));
});<div class="top-bar">
<div class="top-bar-content">
<h1>Move Cat</h1>
<button id="moveBtn">Move</button>
</div>
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="300"
height="300"
class="cat-img thumbnail"
/>
</div>
<div class="cat-details hidden">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="500"
height="500"
class="cat-img detailed-img"
/>
<div class="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
<style>
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>document.getElementById('moveBtn').addEventListener('click', () => {
document
.querySelectorAll('.thumbnail, .cat-details')
.forEach((target) => target.classList.toggle('hidden'));
});<div class="top-bar">
<div class="top-bar-content">
<h1>Move Cat</h1>
<button id="moveBtn">Move</button>
</div>
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="300"
height="300"
class="cat-img thumbnail"
/>
</div>
<div class="cat-details hidden">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="500"
height="500"
class="cat-img detailed-img"
/>
<div class="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
<style>
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>Using View Transition API
Using View Transition API, we can animate that movement with the following changes:
- wrap the code that update the DOM element with
document.startViewTransition. - add
view-transition-nameCSS property with the same value to the two cat images that we want to animate.
If you’re using latest version of Chrome, you’ll see the following changes when the Move button is clicked:
- The cat image will transition between two positions with smooth animation.
- There is a subtle fade animation for the “Cat Details” text.
document.getElementById('moveBtn').addEventListener('click', () => {
document.startViewTransition(() => {
document
.querySelectorAll('.thumbnail, .cat-details')
.forEach((target) => target.classList.toggle('hidden'));
});
});<div class="top-bar">
<div class="top-bar-content">
<h1>Move Cat</h1>
<button id="moveBtn">Move</button>
</div>
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="300"
height="300"
class="cat-img thumbnail"
/>
</div>
<div class="cat-details hidden">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="500"
height="500"
class="cat-img detailed-img"
/>
<div class="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
<style>
.cat-img {
view-transition-name: meow-image;
}
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>document.getElementById('moveBtn').addEventListener('click', () => {
document.startViewTransition(() => {
document
.querySelectorAll('.thumbnail, .cat-details')
.forEach((target) => target.classList.toggle('hidden'));
});
});<div class="top-bar">
<div class="top-bar-content">
<h1>Move Cat</h1>
<button id="moveBtn">Move</button>
</div>
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="300"
height="300"
class="cat-img thumbnail"
/>
</div>
<div class="cat-details hidden">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
width="500"
height="500"
class="cat-img detailed-img"
/>
<div class="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
<style>
.cat-img {
view-transition-name: meow-image;
}
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>How it works
You can understand how the API works in this way:
-
When
document.startViewTransitionis called, browser- takes a screenshot of the entire page
- finds all the elements with
view-transition-nameCSS property declared, and takes a screenshot of them
In the example above, it takes a screenshot of the entire page, and then a screenshot of the cat image.
-
Once the screenshots are done, it will invoke the callback that you passed to it. In the example above, the callback will hide the cat image on top right and display the cat image on bottom left.
-
Once the callback is done, it then take another round of screenshots like step 1, then figures out the difference and perform the animation accordingly.
- by default, the animation is fading effect, which is why the “Cat Details” has the fade animation. (To be more precise, entire page has the fade animation, but for parts that did not change, we can’t observe the fade animation)
- for elements with
view-transition-nameproperty defined, it will figure out the difference of the position, and animate accordingly.
If you want to understand it in a more accurate and detailed way, refer to this section of Jake Archibald’s article.
Usage View Transition API with React
Because React renders state changes asynchronously, we need to wrap the state-setter function with flushSync to force the state changes to be applied synchronously.
import * as React from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
const App = () => {
const [isThumbnail, setIsThumbnail] = React.useState(true);
const handleMove = () => {
document.startViewTransition(() => {
flushSync(() => {
setIsThumbnail((prev) => !prev);
});
});
};
return (
<div>
<div className="top-bar">
<div className="top-bar-content">
<h1>Move Cat</h1>
<button onClick={handleMove}>Move</button>
</div>
{isThumbnail && (
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img thumbnail"
/>
)}
</div>
{!isThumbnail && (
<div className="cat-details">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img detailed-img"
/>
<div className="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
)}
</div>
);
};
createRoot(document.getElementById('root')).render(<App />);<div id="root"></div>
<style>
.cat-img {
view-transition-name: meow-image;
}
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>import * as React from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
const App = () => {
const [isThumbnail, setIsThumbnail] = React.useState(true);
const handleMove = () => {
document.startViewTransition(() => {
flushSync(() => {
setIsThumbnail((prev) => !prev);
});
});
};
return (
<div>
<div className="top-bar">
<div className="top-bar-content">
<h1>Move Cat</h1>
<button onClick={handleMove}>Move</button>
</div>
{isThumbnail && (
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img thumbnail"
/>
)}
</div>
{!isThumbnail && (
<div className="cat-details">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img detailed-img"
/>
<div className="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
)}
</div>
);
};
createRoot(document.getElementById('root')).render(<App />);<div id="root"></div>
<style>
.cat-img {
view-transition-name: meow-image;
}
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>React first-class support: ViewTransition component
As part of React 19.3, ViewTransition component is available from React directly.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
const [isThumbnail, setIsThumbnail] = React.useState(true);
const handleMove = () => {
React.startTransition(() => {
setIsThumbnail((prev) => !prev);
});
};
return (
<div>
<div className="top-bar">
<div className="top-bar-content">
<h1>Move Cat</h1>
<button onClick={handleMove}>Move</button>
</div>
{isThumbnail && (
<React.ViewTransition name="meow-image">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="thumbnail"
/>
</React.ViewTransition>
)}
</div>
{!isThumbnail && (
<div className="cat-details">
<React.ViewTransition name="meow-image">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="detailed-img"
/>
</React.ViewTransition>
<div className="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
)}
</div>
);
};
createRoot(document.getElementById('root')).render(<App />);<div id="root"></div>
<style>
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>import * as React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
const [isThumbnail, setIsThumbnail] = React.useState(true);
const handleMove = () => {
React.startTransition(() => {
setIsThumbnail((prev) => !prev);
});
};
return (
<div>
<div className="top-bar">
<div className="top-bar-content">
<h1>Move Cat</h1>
<button onClick={handleMove}>Move</button>
</div>
{isThumbnail && (
<React.ViewTransition name="meow-image">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="thumbnail"
/>
</React.ViewTransition>
)}
</div>
{!isThumbnail && (
<div className="cat-details">
<React.ViewTransition name="meow-image">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="detailed-img"
/>
</React.ViewTransition>
<div className="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
)}
</div>
);
};
createRoot(document.getElementById('root')).render(<App />);<div id="root"></div>
<style>
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>With the ViewTransition component:
- we no longer need to write custom CSS to define the
view-transition-nameproperty. Instead, we can use thenameprop as a replacement. - we replace
flushSyncanddocument.startViewTransitionwithReact.startTransition. Not only it’s more concise, by allowing React to handle the view transition, the behavior integrates well with React’s Suspense. For instance, we can use it withuseDeferredValueto create animated list.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
const [search, setSearch] = React.useState('');
const deferredSearch = React.useDeferredValue(search);
const displayedCats = deferredSearch
? catData.filter((cat) => cat.name.toUpperCase().includes(deferredSearch.toUpperCase()))
: catData;
return (
<div>
<div className="search-container">
<input
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Type to search"
className="search-input"
/>
</div>
{displayedCats.length === 0 && (
<React.ViewTransition name="error-message">
<div className="error-message">
<p>No cats found</p>
</div>
</React.ViewTransition>
)}
<ul className="cat-list">
{displayedCats.map((cat) => (
<li key={cat.id}>
<React.ViewTransition name={`cat-${cat.id}`}>
<div className="cat-item">
<img src={cat.image} alt={cat.name} className="cat-thumbnail" />
<div className="cat-item-content">
<p className="cat-name">{cat.name}</p>
</div>
</div>
</React.ViewTransition>
</li>
))}
</ul>
</div>
);
};
const catData = [
{
id: 'meow',
name: 'Meow',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png',
},
{
id: 'angie',
name: 'Angie',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/angry_cat.png',
},
{
id: 'milky',
name: 'Milky',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/milky.png',
},
{
id: 'spinner',
name: 'Spinner',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/spinning_cat.png',
},
];
createRoot(document.getElementById('root')).render(<App />);<link rel="stylesheet" href="https://unpkg.com/modern-normalize/modern-normalize.css" />
<div id="root"></div>
<style>
.search-container {
padding: 0.25rem 0.5rem;
}
.search-input {
display: block;
width: 100%;
padding: 4px 8px;
border: 1px solid #cecece;
border-radius: 0.25rem;
}
.cat-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
list-style: none;
padding: 0;
margin: 0;
}
.cat-item {
display: flex;
gap: 1rem;
padding: 0.25rem 0.5rem;
}
.cat-thumbnail {
width: 100px;
height: 100px;
object-fit: contain;
border-radius: 0.25rem;
border: 1px solid #ccc;
}
.cat-item-content {
flex: 1;
}
.cat-name {
font-size: 1.5rem;
font-weight: 500;
}
.error-message {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
</style>import * as React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
const [search, setSearch] = React.useState('');
const deferredSearch = React.useDeferredValue(search);
const displayedCats = deferredSearch
? catData.filter((cat) => cat.name.toUpperCase().includes(deferredSearch.toUpperCase()))
: catData;
return (
<div>
<div className="search-container">
<input
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Type to search"
className="search-input"
/>
</div>
{displayedCats.length === 0 && (
<React.ViewTransition name="error-message">
<div className="error-message">
<p>No cats found</p>
</div>
</React.ViewTransition>
)}
<ul className="cat-list">
{displayedCats.map((cat) => (
<li key={cat.id}>
<React.ViewTransition name={`cat-${cat.id}`}>
<div className="cat-item">
<img src={cat.image} alt={cat.name} className="cat-thumbnail" />
<div className="cat-item-content">
<p className="cat-name">{cat.name}</p>
</div>
</div>
</React.ViewTransition>
</li>
))}
</ul>
</div>
);
};
const catData = [
{
id: 'meow',
name: 'Meow',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png',
},
{
id: 'angie',
name: 'Angie',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/angry_cat.png',
},
{
id: 'milky',
name: 'Milky',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/milky.png',
},
{
id: 'spinner',
name: 'Spinner',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/spinning_cat.png',
},
];
createRoot(document.getElementById('root')).render(<App />);<link rel="stylesheet" href="https://unpkg.com/modern-normalize/modern-normalize.css" />
<div id="root"></div>
<style>
.search-container {
padding: 0.25rem 0.5rem;
}
.search-input {
display: block;
width: 100%;
padding: 4px 8px;
border: 1px solid #cecece;
border-radius: 0.25rem;
}
.cat-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
list-style: none;
padding: 0;
margin: 0;
}
.cat-item {
display: flex;
gap: 1rem;
padding: 0.25rem 0.5rem;
}
.cat-thumbnail {
width: 100px;
height: 100px;
object-fit: contain;
border-radius: 0.25rem;
border: 1px solid #ccc;
}
.cat-item-content {
flex: 1;
}
.cat-name {
font-size: 1.5rem;
font-weight: 500;
}
.error-message {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
</style>Shared elements must be in the viewport
React only forms a shared-element pair when both the element being removed and the element being inserted are inside the viewport. If either side is outside the viewport, React treats each side as a regular enter or exit instead. This prevents an element from unexpectedly flying into or out of the screen when the page is scrolled.
This often shows up in a listing-to-detail transition. Try opening the first cat without scrolling: the thumbnail and detail hero are both visible, so React forms a shared-element transition between them. Then go back, scroll to the last cat, and open it. The detail page keeps the scroll position, leaving its hero image above the viewport, so React does not pair the two images.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
const [selectedCatId, setSelectedCatId] = React.useState(null);
const selectedCat = cats.find((cat) => cat.id === selectedCatId);
const openCat = (catId) => {
React.startTransition(() => setSelectedCatId(catId));
};
const goBack = () => {
React.startTransition(() => setSelectedCatId(null));
};
return (
<main>
<header className="demo-toolbar">
{selectedCat ? <button onClick={goBack}>← Back to cats</button> : <b>Cat directory</b>}
{selectedCat ? (
<small>The detail hero is at the top; your scroll position was preserved</small>
) : (
<small>Try the first card, then scroll down and try the last one</small>
)}
</header>
{selectedCat ? (
<article className="cat-detail">
<div className="detail-header">
<div>
<p>Featured cat</p>
<h1>{selectedCat.name}</h1>
</div>
<React.ViewTransition name={`cat-image-${selectedCat.id}`}>
<img src={selectedCat.image} alt={selectedCat.name} className="detail-image" />
</React.ViewTransition>
</div>
<div className="detail-copy">
<h2>About {selectedCat.name}</h2>
<p>
This detail page is intentionally tall, like a real product or profile page. That lets
it preserve the listing's scroll position during navigation.
</p>
</div>
</article>
) : (
<ul className="cat-list">
{cats.map((cat) => (
<li key={cat.id}>
<button className="cat-card" onClick={() => openCat(cat.id)}>
<React.ViewTransition name={`cat-image-${cat.id}`}>
<img src={cat.image} alt="" />
</React.ViewTransition>
<span>
<b>{cat.name}</b>
<small>View profile →</small>
</span>
</button>
</li>
))}
</ul>
)}
</main>
);
};
const cats = [
{
id: 'meow',
name: 'Meow',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png',
},
{
id: 'angie',
name: 'Angie',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/angry_cat.png',
},
{
id: 'milky',
name: 'Milky',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/milky.png',
},
{
id: 'spinner',
name: 'Spinner',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/spinning_cat.png',
},
];
createRoot(document.getElementById('root')).render(<App />);<div id="root"></div>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
}
.demo-toolbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
background: white;
border-bottom: 1px solid #ddd;
}
.demo-toolbar small {
max-width: 22rem;
text-align: right;
}
.cat-list {
display: grid;
gap: 1rem;
padding: 1rem;
margin: 0;
list-style: none;
}
.cat-card {
display: grid;
grid-template-columns: 110px 1fr;
align-items: center;
width: 100%;
min-height: 210px;
padding: 1rem;
color: inherit;
text-align: left;
background: white;
border: 1px solid #ddd;
border-radius: 0.75rem;
cursor: pointer;
}
.cat-card img {
width: 96px;
height: 96px;
object-fit: contain;
}
.cat-card span {
display: grid;
gap: 0.5rem;
}
.cat-card b {
font-size: 1.5rem;
}
.cat-detail {
min-height: 180vh;
padding: 1rem;
}
.detail-header {
display: grid;
grid-template-columns: 1fr minmax(150px, 280px);
align-items: center;
gap: 1rem;
}
.detail-header h1 {
margin-block: 0.25rem;
font-size: 2.5rem;
}
.detail-image {
width: 100%;
max-height: 280px;
object-fit: contain;
}
.detail-copy {
max-width: 36rem;
margin-top: 2rem;
line-height: 1.6;
}
</style>import * as React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
const [selectedCatId, setSelectedCatId] = React.useState(null);
const selectedCat = cats.find((cat) => cat.id === selectedCatId);
const openCat = (catId) => {
React.startTransition(() => setSelectedCatId(catId));
};
const goBack = () => {
React.startTransition(() => setSelectedCatId(null));
};
return (
<main>
<header className="demo-toolbar">
{selectedCat ? <button onClick={goBack}>← Back to cats</button> : <b>Cat directory</b>}
{selectedCat ? (
<small>The detail hero is at the top; your scroll position was preserved</small>
) : (
<small>Try the first card, then scroll down and try the last one</small>
)}
</header>
{selectedCat ? (
<article className="cat-detail">
<div className="detail-header">
<div>
<p>Featured cat</p>
<h1>{selectedCat.name}</h1>
</div>
<React.ViewTransition name={`cat-image-${selectedCat.id}`}>
<img src={selectedCat.image} alt={selectedCat.name} className="detail-image" />
</React.ViewTransition>
</div>
<div className="detail-copy">
<h2>About {selectedCat.name}</h2>
<p>
This detail page is intentionally tall, like a real product or profile page. That lets
it preserve the listing's scroll position during navigation.
</p>
</div>
</article>
) : (
<ul className="cat-list">
{cats.map((cat) => (
<li key={cat.id}>
<button className="cat-card" onClick={() => openCat(cat.id)}>
<React.ViewTransition name={`cat-image-${cat.id}`}>
<img src={cat.image} alt="" />
</React.ViewTransition>
<span>
<b>{cat.name}</b>
<small>View profile →</small>
</span>
</button>
</li>
))}
</ul>
)}
</main>
);
};
const cats = [
{
id: 'meow',
name: 'Meow',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png',
},
{
id: 'angie',
name: 'Angie',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/angry_cat.png',
},
{
id: 'milky',
name: 'Milky',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/milky.png',
},
{
id: 'spinner',
name: 'Spinner',
image:
'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/spinning_cat.png',
},
];
createRoot(document.getElementById('root')).render(<App />);<div id="root"></div>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
}
.demo-toolbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
background: white;
border-bottom: 1px solid #ddd;
}
.demo-toolbar small {
max-width: 22rem;
text-align: right;
}
.cat-list {
display: grid;
gap: 1rem;
padding: 1rem;
margin: 0;
list-style: none;
}
.cat-card {
display: grid;
grid-template-columns: 110px 1fr;
align-items: center;
width: 100%;
min-height: 210px;
padding: 1rem;
color: inherit;
text-align: left;
background: white;
border: 1px solid #ddd;
border-radius: 0.75rem;
cursor: pointer;
}
.cat-card img {
width: 96px;
height: 96px;
object-fit: contain;
}
.cat-card span {
display: grid;
gap: 0.5rem;
}
.cat-card b {
font-size: 1.5rem;
}
.cat-detail {
min-height: 180vh;
padding: 1rem;
}
.detail-header {
display: grid;
grid-template-columns: 1fr minmax(150px, 280px);
align-items: center;
gap: 1rem;
}
.detail-header h1 {
margin-block: 0.25rem;
font-size: 2.5rem;
}
.detail-image {
width: 100%;
max-height: 280px;
object-fit: contain;
}
.detail-copy {
max-width: 36rem;
margin-top: 2rem;
line-height: 1.6;
}
</style>This viewport check applies specifically to a named share transition where one component is removed and another is inserted. If the same component instance changes position, React classifies it as an update, and it can still animate even when one position is outside the viewport. See the ViewTransition documentation for the full caveat.
Using View Transition API with React Router
Since React Router version 6.27.0, using View Transition API requires only adding viewTransition prop to Link component. Note that the View Transition API support of React Router is not based on the React’s ViewTransition component, therefore you can use it today as long as your React Router version is 6.27.0 or above.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { useNavigate, createBrowserRouter, Link, RouterProvider } from 'react-router-dom';
const TopBar = ({ link, rightContent }) => (
<div className="top-bar">
<div className="top-bar-content">
<h1>Move Cat</h1>
{link}
</div>
{rightContent}
</div>
);
const router = createBrowserRouter([
{
index: true,
element: (
<div>
<TopBar
link={
<Link to="/details" viewTransition>
Details
</Link>
}
rightContent={
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img thumbnail"
/>
}
/>
</div>
),
},
{
path: '/details',
element: (
<div>
<TopBar
link={
<Link to="/" viewTransition>
Home
</Link>
}
/>
<div className="cat-details">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img detailed-img"
/>
<div className="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
</div>
),
},
]);
createRoot(document.getElementById('root')).render(<RouterProvider router={router} />);<div id="root"></div>
<style>
.cat-img {
view-transition-name: meow-image;
}
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { useNavigate, createBrowserRouter, Link, RouterProvider } from 'react-router-dom';
const TopBar = ({ link, rightContent }) => (
<div className="top-bar">
<div className="top-bar-content">
<h1>Move Cat</h1>
{link}
</div>
{rightContent}
</div>
);
const router = createBrowserRouter([
{
index: true,
element: (
<div>
<TopBar
link={
<Link to="/details" viewTransition>
Details
</Link>
}
rightContent={
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img thumbnail"
/>
}
/>
</div>
),
},
{
path: '/details',
element: (
<div>
<TopBar
link={
<Link to="/" viewTransition>
Home
</Link>
}
/>
<div className="cat-details">
<img
src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
alt="cat"
className="cat-img detailed-img"
/>
<div className="cat-desc">
<h2>Cat Details</h2>
</div>
</div>
</div>
),
},
]);
createRoot(document.getElementById('root')).render(<RouterProvider router={router} />);<div id="root"></div>
<style>
.cat-img {
view-transition-name: meow-image;
}
.cat-details {
display: flex;
}
.hidden {
display: none;
}
.cat-desc {
flex: 1;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: start;
}
.top-bar-content {
display: flex;
align-items: center;
gap: 2rem;
}
.thumbnail {
width: 100px;
height: 100px;
}
.detailed-img {
width: 50vw;
height: auto;
}
</style>Practical Use Cases
Other than transitioning between pages, some practical use cases of the View Transition API are:
-
Animating an element when open modal: When a user opens/closes a modal, transition the button to the entire modal to produce an expansion effect.
-
Animating focus ring for onboarding overlay: When navigating between steps, the focus ring and the popup modal can be moved with animation.
-
Tab active indicator animation: When switching tab, animating the active indicator used to require manual computation by considering the position of the previous active tab and the new active tab. With View Transition API we can delegates to browser to perform the computation.
Some content here
Using Progressive Enhancement Technique with View Transition
To ensure that your application still functions properly on browsers that do not support the View Transition API, you can use the progressive enhancement technique. This involves checking whether document.startViewTransition is available before using it. If it’s not available, then no animation will be applied.
// create a wrapper to be used everywhere in your application
const transitionViewIfSupported = (updateCb) => {
if (document.startViewTransition) {
document.startViewTransition(updateCb);
} else {
updateCb();
}
};
Conclusion
In conclusion, the View Transition API offers a straightforward way to create smooth animations for web applications. By implementing it in your React projects and using progressive enhancement, you can cover a wider range of browsers while enhancing user experience. Give it a try and see the difference it makes!
Acknowledgements
Thanks Jake Archibald for his works on designing and championing this API, and also being extremely patient for all my questions about the API.

