๐ Big news! The React team just released ViewTransition - a game-changing feature for creating smooth, professional animations in React!
Ever noticed how some websites feel buttery smooth when content changes, while others feel jumpy and jarring? The secret is often in the animations. Today, we're diving into React's brand new ViewTransition feature that makes your apps feel premium with surprisingly little code.
What is ViewTransition?
Imagine you're updating content on your website. Normally, old content vanishes and new content appears instantlyโlike flipping a light switch. ViewTransition is React's way of making those changes smooth and animated instead of jarring and instant.
Think of it like this:
- Without ViewTransition: When you update something on the page (like switching between two pieces of content), it just instantly swaps - old content disappears, new content appears.
- With ViewTransition: The same update happens, but now it's animated, maybe the old content fades out while the new content fades in, or slides in from the side.
Real-world example:
Imagine you have a tab component. When you click from "Tab A" to "Tab B":
Regular way: Tab A content vanishes, Tab B content appears instantly
With <ViewTransition>
: Tab A content smoothly fades/slides out as Tab B content fades/slides in
So is basically saying: "Hey, animate the changes that happen inside me instead of making them instant."
The Basic Rules
For ViewTransition to work, you need three key ingredients:
-
<ViewTransition>
must be the FIRST thing your component returns (before any other DOM elements) - Wrap your content in
<ViewTransition>
- Update the content inside
startTransition()
Rule #1: ViewTransition Must Be First! โ ๏ธ
This is critical: <ViewTransition>
must be the very first element your component returns. If there are any other HTML elements before it, animations won't work.
Think of it like a wrapper around your entire component - it needs to be the outermost layer for React to detect when the component appears or disappears.
// โ WRONG - <div> comes before ViewTransition
function BadNotification() {
return (
<div className="container">
<h2>Notifications</h2>
<ViewTransition>
<div className="notification">
You have a new message!
</div>
</ViewTransition>
</div>
);
}
// โ WRONG - <Fragment> with <h1> before ViewTransition
function BadProfile() {
return (
<>
<h1>User Profile</h1>
<ViewTransition>
<div className="profile-card">
Profile content...
</div>
</ViewTransition>
</>
);
}
// โ
CORRECT - ViewTransition is the FIRST thing returned
function GoodNotification() {
return (
<ViewTransition>
<div className="container">
<h2>Notifications</h2>
<div className="notification">
You have a new message!
</div>
</div>
</ViewTransition>
);
}
// โ
CORRECT - ViewTransition wraps everything
function GoodProfile() {
return (
<ViewTransition>
<h1>User Profile</h1>
<div className="profile-card">
Profile content...
</div>
</ViewTransition>
);
}
Why this matters: React looks for <ViewTransition>
at the component's root to trigger enter/exit animations. If it finds other elements first, it treats your component as a regular update, not an animated transition.
Rule #2: Use startTransition for Updates
Regular setState
updates happen instantly. To animate them, wrap the update in startTransition()
:
// โ This won't animate - just regular setState
function Counter() {
const [count, setCount] = useState(0);
return (
<ViewTransition>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</ViewTransition>
);
}
// โ
This will animate - wrapped in startTransition
function Counter() {
const [count, setCount] = useState(0);
return (
<ViewTransition>
<div>{count}</div>
<button onClick={() => startTransition(() => setCount(count + 1))}>
Increment
</button>
</ViewTransition>
);
}
Why? startTransition
tells React "this is a non-urgent update that can be animated." Regular setState
is considered urgent and happens instantly without animation.
The Four Types of Animations
ViewTransition gives you four moments where animations can happen:
1. ๐ Enter - When Something Appears
This happens when the <ViewTransition>
wrapper itself didn't exist before and now it does. If the component get mounted.
function Notification() {
return (
<ViewTransition class="fade-in">
<div className="notification">Message sent!</div>
</ViewTransition>
);
}
function App() {
const [show, setShow] = useState(false);
const notify = () => {
startTransition(() => {
setShow(true);
});
};
return (
<div>
<button onClick={notify}>Send</button>
{show && <Notification />}
</div>
);
}
What you see: The notification smoothly fades in when you click "Send."
2. ๐ Exit - When Something Disappears
This happens when the <ViewTransition>
wrapper gets removed. If the component unmounts...
function Modal({ onClose }) {
return (
<ViewTransition class="fade-out">
<div className="modal">
<h2>Modal Content</h2>
<button onClick={() => startTransition(onClose)}>
Close
</button>
</div>
</ViewTransition>
);
}
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => startTransition(() => setShowModal(true))}>
Open Modal
</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
);
}
What you see: The modal smoothly fades out when you click "Close."
3. ๐ Update - When Something Changes
This happens when content inside the <ViewTransition>
changes, or when the wrapper itself moves or resizes.
function Counter() {
const [count, setCount] = useState(0);
return (
<ViewTransition class="pulse">
<div className="counter">{count}</div>
<button onClick={() => startTransition(() => setCount(count + 1))}>
Increment
</button>
</ViewTransition>
);
}
What you see: The number pulses each time it changes.
4. โจ Share - When Something Morphs Between Locations
This is the coolest one! It happens when you delete (unmount) a named ViewTransition and add (mount) another one with the same name. React treats them as the same element transforming.
function App() {
const [view, setView] = useState('grid');
const [selectedId, setSelectedId] = useState(null);
const selectImage = (id) => {
startTransition(() => {
setSelectedId(id);
setView('detail');
});
};
const goBack = () => {
startTransition(() => {
setView('grid');
});
};
if (view === 'grid') {
return (
<div className="grid">
{images.map(img => (
<ViewTransition key={img.id} name={`image-${img.id}`}>
<img
src={img.thumbnail}
onClick={() => selectImage(img.id)}
className="thumbnail"
/>
</ViewTransition>
))}
</div>
);
}
return (
<div>
<button onClick={goBack}>Back</button>
<ViewTransition name={`image-${selectedId}`}>
<img
src={images[selectedId].fullSize}
className="full-size"
/>
</ViewTransition>
</div>
);
}
What you see: Click a thumbnail and it smoothly expands into a full-size image. Click back and it shrinks back to the grid.
The magic: Both ViewTransitions have the same name
prop, so React morphs between them instead of fading out and in.
View Transition Class
The View Transition Class, is css class name that React adds to your elements during the animation, so you can style how they animate. It can be a string or an object.
By default, ViewTransition uses a simple crossfade.
Two Ways to Use It
- String - One class for everything
<ViewTransition class="my-animation">
<div>Content</div>
</ViewTransition>
.my-animation {
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
What happens: React adds the class my-animation to the element during any animation (enter, exit, update, or share).
- Object - Different classes for different animation types
<ViewTransition
class={{
enter: "slide-in",
exit: "slide-out",
update: "pulse",
share: "morph"
}}
>
<div>Content</div>
</ViewTransition>
.slide-in {
animation: slideFromRight 0.3s ease;
}
.slide-out {
animation: slideToLeft 0.3s ease;
}
.pulse {
animation: scaleUp 0.2s ease;
}
.morph {
animation: smoothTransform 0.4s ease;
}
What happens:
- When element appears โ adds class
slide-in
- When element disappears โ adds class
slide-out
- When element changes โ adds class
pulse
- When element morphs โ adds class
morph
Turn Off Specific Animations
<ViewTransition
class={{
enter: "fade-in",
exit: "none", // No animation when leaving
update: "pulse"
}}
>
<div>Content</div>
</ViewTransition>
๐ฏ Callbacks: Do Something After Animation
Sometimes you want to run code after an animation finishes:
<ViewTransition
onEnter={(element) => {
console.log("Appeared!");
element.focus(); // Focus on the new element
}}
onExit={(element) => {
console.log("Disappeared!");
}}
onUpdate={(element) => {
console.log("Changed!");
}}
>
<div>Content</div>
</ViewTransition>
Real-world example: Auto-dismiss notifications
<ViewTransition
onEnter={(element) => {
// After the notification fades in, start a timer
setTimeout(() => {
startTransition(() => setShow(false));
}, 3000);
}}
>
<div className="notification">Saved successfully!</div>
</ViewTransition>
๐ Animating Lists
The View Transition API provides smooth, animated reordering of lists without manual animation code. When list items change position, the browser automatically creates cross-fade animations that make items appear to glide to their new positions.
This makes list sorting, filtering, and reordering feel much more polished and professional with minimal code!
function TodoList() {
const [items, setItems] = useState([
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Walk dog' },
{ id: 3, text: 'Read book' }
]);
const shuffle = () => {
startTransition(() => {
setItems([...items].sort(() => Math.random() - 0.5));
});
};
return (
<div>
<button onClick={shuffle}>Shuffle</button>
{items.map(item => (
<TodoItem key={item.id} item={item} />
))}
</div>
);
}
// โ
ViewTransition at the top - will animate!
function TodoItem({ item }) {
return (
<ViewTransition class="reorder">
<div className="todo-item">
{item.text}
</div>
</ViewTransition>
);
}
What you see: Click "Shuffle" and items smoothly slide to their new positions.
Important: ViewTransition must be the first thing returned by the list component. This won't work:
// โ Won't animate - ViewTransition is wrapped
function TodoItem({ item }) {
return (
<div className="todo-item">
<ViewTransition>
<span>{item.text}</span>
</ViewTransition>
</div>
);
}
โณ Working with Suspense
ViewTransition plays nicely with Suspense for loading states. There are two ways to do it:
Method 1: Crossfade (Update Animation)
Wrap the entire Suspense with ViewTransition:
<ViewTransition class="crossfade">
<Suspense fallback={<LoadingSpinner />}>
<HeavyContent />
</Suspense>
</ViewTransition>
What happens:
- Shows loading spinner
- Content loads
- Smoothly crossfades from spinner to content
Visual: Both fade together, blending smoothly.
Method 2: Separate Enter/Exit Animations
Wrap the fallback AND content separately:
<Suspense
fallback={
<ViewTransition class="spinner-exit">
<LoadingSpinner />
</ViewTransition>
}
>
<ViewTransition class="content-enter">
<HeavyContent />
</ViewTransition>
</Suspense>
What happens:
- Spinner enters (fades in)
- Content loads
- Spinner exits (fades out)
- Content enters (slides in)
Visual: Distinct stagesโspinner leaves, then content arrives.
React Waits for Everything to Be Ready
Before animating, React intelligently waits for:
- โ Data to load
- โ CSS files to load
- โ Fonts to load (up to 500ms)
- โ Images inside ViewTransition to load
Why? To avoid flickering! You don't want to see text appear then suddenly change font, or layout shift when images pop in.
๐ฃ๏ธ Building Routers with ViewTransition
If you're building a router, there are two important things to know:
1. Unblock Navigation in useLayoutEffect
React waits for navigation to complete before animating (so scroll position can be restored correctly). Your router must unblock in useLayoutEffect
, not useEffect
:
// โ WRONG - Causes deadlock
function Router() {
const [blocked, setBlocked] = useState(true);
useEffect(() => {
setBlocked(false); // Too late!
}, []);
return blocked ? <Loading /> : <Page />;
}
// โ
CORRECT
function Router() {
const [blocked, setBlocked] = useState(true);
useLayoutEffect(() => {
setBlocked(false); // Just in time!
}, []);
return blocked ? <Loading /> : <Page />;
}
Why? useLayoutEffect
runs before the browser paints, so React can continue. useEffect
runs after, causing a deadlock.
2. Back Button Won't Animate (Yet)
Currently, the browser back button doesn't trigger animations. This is because the old popstate
event must be synchronous (to restore scroll/form data immediately), but animations take time.
Solution: Upgrade to the Navigation API (modern browsers):
// Check if supported
if ('navigation' in window) {
navigation.addEventListener('navigate', (event) => {
if (event.canIntercept) {
event.intercept({
async handler() {
// Can be async - allows animations!
await startTransition(() => {
updateRoute(event.destination.url);
});
}
});
}
});
}
With the Navigation API, back button animations work! ๐
โ ๏ธ Important Rules to always keep in mind when working with ViewTransition
1. ViewTransition Must Be First
// โ Won't work - there's a div before ViewTransition
function Bad() {
return (
<div>
<h1>Title</h1>
<ViewTransition>
<div>Content</div>
</ViewTransition>
</div>
);
}
// โ
Works - ViewTransition is first
function Good() {
return (
<ViewTransition>
<div>
<h1>Title</h1>
<div>Content</div>
</div>
</ViewTransition>
);
}
2. It Animates as a Group, Not Individual Pieces
ViewTransition takes a "snapshot" of everything inside and animates that snapshot. Individual elements don't move separately.
<ViewTransition>
<div>
<h1>Title</h1>
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</div>
</ViewTransition>
What happens: Everything moves together as one unit.
If you want individual animations:
<div>
<ViewTransition><h1>Title</h1></ViewTransition>
<ViewTransition><p>Paragraph 1</p></ViewTransition>
<ViewTransition><p>Paragraph 2</p></ViewTransition>
</div>
Now each piece can animate independently!
3. Shared Names Must Be Unique
For "share" animations (morphing), each name must be unique at any given time:
// โ BAD - Two elements with same name exist simultaneously
<div>
<ViewTransition name="avatar">
<img src="user1.jpg" />
</ViewTransition>
<ViewTransition name="avatar">
<img src="user2.jpg" />
</ViewTransition>
</div>
// โ
GOOD - Only one exists at a time
{currentUser === 'user1' && (
<ViewTransition name="avatar">
<img src="user1.jpg" />
</ViewTransition>
)}
{currentUser === 'user2' && (
<ViewTransition name="avatar">
<img src="user2.jpg" />
</ViewTransition>
)}
4. Both Elements Must Be Visible for Share Animation
If you're morphing between two elements (share animation) and one is scrolled off-screen, React won't morph them. Instead, they'll fade out/in separately.
Why? To prevent weird effects where images fly across your entire screen from way down below.
5. โฟ Accessibility: Respect User Preferences
Some users get motion sickness from animations. Always add this CSS:
.my-animation {
animation: slideIn 0.3s ease;
}
/* Disable for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.my-animation {
animation: none;
}
}
React doesn't do this automaticallyโyou must add it yourself!
6. ๐ซ NEVER Use localStorage or sessionStorage
Critical: Browser storage APIs are not supported in React artifacts and will cause failures.
// โ NEVER DO THIS - Will fail!
function BadComponent() {
const [data, setData] = useState(
JSON.parse(localStorage.getItem('data'))
);
return <ViewTransition>...</ViewTransition>;
}
// โ
Use React state instead
function GoodComponent() {
const [data, setData] = useState(initialData);
return <ViewTransition>...</ViewTransition>;
}
๐ Real-World Example: Complete App
Let's put it all together with a simple image gallery:
import { useState, startTransition, Suspense } from 'react';
function ImageGallery() {
const [view, setView] = useState('grid');
const [selectedId, setSelectedId] = useState(null);
const images = [
{ id: 1, thumb: 'thumb1.jpg', full: 'full1.jpg', title: 'Sunset' },
{ id: 2, thumb: 'thumb2.jpg', full: 'full2.jpg', title: 'Mountain' },
{ id: 3, thumb: 'thumb3.jpg', full: 'full3.jpg', title: 'Ocean' }
];
const selectImage = (id) => {
startTransition(() => {
setSelectedId(id);
setView('detail');
});
};
const goBack = () => {
startTransition(() => {
setView('grid');
});
};
if (view === 'grid') {
return (
<div className="grid">
{images.map(img => (
<ViewTransition key={img.id} name={`image-${img.id}`}>
<img
src={img.thumb}
alt={img.title}
onClick={() => selectImage(img.id)}
className="thumbnail"
/>
</ViewTransition>
))}
</div>
);
}
const selected = images.find(img => img.id === selectedId);
return (
<div className="detail-view">
<button onClick={goBack}>โ Back</button>
<Suspense
fallback={
<ViewTransition class="skeleton-animation">
<div className="skeleton" />
</ViewTransition>
}
>
<ViewTransition name={`image-${selectedId}`}>
<img
src={selected.full}
alt={selected.title}
className="full-image"
/>
</ViewTransition>
</Suspense>
<h2>{selected.title}</h2>
</div>
);
}
CSS:
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.thumbnail {
width: 100%;
cursor: pointer;
border-radius: 8px;
transition: transform 0.2s;
}
.thumbnail:hover {
transform: scale(1.05);
}
.detail-view {
max-width: 800px;
margin: 0 auto;
}
.full-image {
width: 100%;
border-radius: 12px;
}
.skeleton {
width: 100%;
height: 500px;
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12px;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
.thumbnail {
transition: none;
}
.skeleton {
animation: none;
}
}
What this app does:
- Shows a grid of thumbnail images
- Click a thumbnail โ It smoothly expands to full size (share animation)
- Shows a skeleton loader while the full image loads (suspense)
- Click back โ Image smoothly shrinks back to grid position
๐ Key Takeaways
-
Always wrap updates in
startTransition()
- Regular setState won't animate -
Put
<ViewTransition>
first - It must be the first thing your component returns -
Use
name
prop for morphing - Same name = elements morph between positions -
Customize with
class
prop - Different animations for enter/exit/update/share -
Respect accessibility - Add
prefers-reduced-motion
media queries - Works with Suspense - Two methods: crossfade or separate enter/exit
- Never use browser storage - Use React state instead
-
Router builders - Use
useLayoutEffect
and Navigation API for best results
๐ Browser Support
ViewTransition currently works in:
- โ Modern browsers with View Transitions API support
- โ Web applications (React DOM)
- โ React Native (coming in the future)
๐ฌ Conclusion
ViewTransition is React's brand new powerful tool for creating smooth, professional animations with minimal code. Now that it's officially released, you can start using it in your projects today!
Instead of managing complex animation libraries, you simply:
- Wrap content in
<ViewTransition>
- Update inside
startTransition()
- Style with CSS
That's it! React handles the restโsnapshots, timing, coordination, and cleanup.
Start with simple enter/exit animations, then level up to shared element transitions for that premium app feel. Your users will love the polish! โจ
This is a brand new feature - what animations are you excited to build with ViewTransition? Drop a comment below! ๐
If you found this helpful, give it a โค๏ธ and share with your fellow developers!
Happy coding! ๐
Top comments (0)