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-name
CSS 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.
If you’re not using latest version of Chrome, the example is broken now, I’ll discuss how to handle that later.
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.startViewTransition
is called, browser- takes a screenshot of the entire page
- finds all the elements with
view-transition-name
CSS 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-name
property 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>
Using View Transition API with React Router
Using View Transition API with React Router is about the same, except that you call hook useNavigate
to navigate to the new page instead of setState in the callback of document.startViewTransition
.
import * as React from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { useNavigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
const AnimatedLink = ({ to, children }) => {
const navigate = useNavigate();
return (
<a
href={to}
onClick={(ev) => {
ev.preventDefault();
document.startViewTransition(() => {
flushSync(() => {
navigate(to);
});
});
}}
>
{children}
</a>
);
};
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={<AnimatedLink to="/details">Details</AnimatedLink>}
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={<AnimatedLink to="/">Home</AnimatedLink>} />
<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 { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { useNavigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
const AnimatedLink = ({ to, children }) => {
const navigate = useNavigate();
return (
<a
href={to}
onClick={(ev) => {
ev.preventDefault();
document.startViewTransition(() => {
flushSync(() => {
navigate(to);
});
});
}}
>
{children}
</a>
);
};
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={<AnimatedLink to="/details">Details</AnimatedLink>}
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={<AnimatedLink to="/">Home</AnimatedLink>} />
<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>
Update: React Router First-Class Support
Since React Router version 6.27.0, using View Transition API requires only adding viewTransition
prop to Link
component.
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.