For years we built a beautiful UI, the user clicked a button, React destroyed one piece of the DOM and created another — and the screen jumped. This is a guide to the API that fixes that, and to React's Activity + ViewTransition + startTransition model on top of it. Every demo below is live: real DOM, real transitions, running in your browser as you read.
For years, frontend developers have been doing something slightly strange. We build a beautiful UI. Then the user clicks a button. React destroys one piece of the DOM, creates another piece, and suddenly the screen jumps from one state to another.
Want a smooth transition? Then you need all of this. Tap each one you've written by hand this year.
The browser knew how to render pixels. But it didn't really know how to animate from one UI state to another. Here's what that looks like — a product dashboard, no transition, exactly what the browser does on its own:
setState. One tree is destroyed, another is created, and the screen jumps. Nothing here is broken — it just doesn't mean anything.Without a view transition, the browser essentially sees an old DOM, a React update, and a new DOM. There is no inherent relationship between the old MacBook card and the new MacBook heading — they're just two different DOM states. The browser doesn't know that oh, this is the same product moving from here to there.
It gives the browser a much better mental model: here is what my application looked like before the update, here is what it looks like after — you figure out the transition.
It's a browser API for animating transitions between different visual states of a page. It works with SPA state changes, DOM updates, element-to-element transitions, and — increasingly — cross-document navigation. MDN describes it as a mechanism for creating animated transitions between different website and element views.[1]
The core API for a same-document transition is surprisingly small:
document.startViewTransition(() => {
// Update the DOM
});For example:
button.addEventListener("click", () => {
document.startViewTransition(() => {
document.body.classList.toggle("dark");
});
});That example is four lines long and it genuinely works. Here it is, running — the only difference is that the class toggle lands on a card instead of document.body, so it doesn't repaint the whole article:
::view-transition-old fading out over ::view-transition-new.Conceptually, the browser takes a snapshot of the old UI state, lets you update the DOM, renders the new UI state, and animates from one to the other. Drag through it:
::view-transition-old is a picture, ::view-transition-new is the real thing.This is fundamentally different from manually animating every DOM node. The browser owns the transition.
This is where the API becomes really interesting. During a view transition, the browser creates a transition representation — a whole tree of pseudo-elements that exists only while the animation runs. Tap any node to see what it's for:
For reference, that tree in its original shape:
::view-transition
│
└── ::view-transition-group(...)
│
└── ::view-transition-image-pair(...)
│
├── ::view-transition-old(...)
│
└── ::view-transition-new(...)MDN documents ::view-transition-old() as the static snapshot of the old view and ::view-transition-new() as the representation of the new view.[3] So instead of saying animate this DOM node from opacity 0 to opacity 1, we can say when this UI changes, animate the old visual representation into the new visual representation. That's a much more powerful abstraction.
Suppose we have this:
<div id="app">
<h1>Hello</h1>
</div>and we want to replace it with this:
<div id="app">
<h1>Hello World</h1>
</div>We can do:
document.startViewTransition(() => {
document.querySelector("#app").innerHTML = `
<h1>Hello World</h1>
`;
});<h1> is swapped inside document.startViewTransition() and the browser does the rest.The important part isn't the animation code. There isn't any. The browser handles the transition lifecycle. That's the magic.
Now things become more interesting. React already owns the DOM, so we don't normally want to write document.querySelector(...) inside a React application. React needs to coordinate the whole chain — tap through it:
This is where React's <ViewTransition> component comes in:
import { ViewTransition } from "react";
<ViewTransition>
<Product />
</ViewTransition>The component tells React: when this part of the React tree changes as part of a Transition, make this subtree participate in a browser View Transition. React internally integrates this with the browser View Transition mechanism — the React documentation explains that React applies view-transition-name to the relevant DOM node when the transition boundary participates in an animation.[7]
This distinction is extremely useful. Think of:
<ViewTransition>
<ProductCard />
</ViewTransition>as answering one question, and one question only. Tap the questions below — only one of them belongs to <ViewTransition>:
Visibility, state preservation and whether React renders at all are different concerns. And that's where <Activity> enters the picture.
React 19.2 introduced <Activity> as a stable React feature.[8] At first glance it looks almost trivial:
<Activity mode="visible">
<Page />
</Activity><Activity mode="hidden">
<Page />
</Activity>But it solves a very different problem. Consider the way we've all written this for a decade:
{isOpen && <Panel />}When isOpen becomes false, the Panel is unmounted and its state disappears. If the user opens it again, React creates it from scratch. Activity lets us instead say:
<Activity mode={isOpen ? "visible" : "hidden"}>
<Panel />
</Activity>And this is not a diagram. Type something into both panels below, hide them, and bring them back — the left one is conditional rendering, the right one is an Activity. A caveat worth stating: this site renders on Next's vendored React 19.1.0-canary-029e8bd6-20250306, which predates <Activity>, so the right-hand panel falls back to hiding the subtree with display: none — the same preserved state, without the Effect cleanup React does for you.
<Activity> yet, so the right panel is the CSS fallback described above — same preserved state, without the Effect cleanup.When hidden, React preserves the Activity's state while hiding its UI and cleaning up its Effects. When it becomes visible again, React restores the previous state and recreates its Effects. So the split is: ViewTransition asks how the UI should change visually; Activity asks whether this UI should remain an activity even when hidden.
Now combine them:
<Activity mode={isDetailsOpen ? "visible" : "hidden"}>
<ViewTransition>
<ProductDetails />
</ViewTransition>
</Activity>Now we have two layers of behaviour. Activity controls the lifecycle — visible ⇄ hidden, while preserving UI state. ViewTransition controls the visual transition — old visual state, animate, new visual state.
This combination is particularly powerful for tabs, side panels, dialogs, dashboards, product detail views, navigation, master/detail layouts and mobile-style page transitions. React explicitly documents using <ViewTransition> inside <Activity> for enter/exit animations[7] — when an Activity becomes visible or hidden as part of a Transition, the ViewTransition can trigger its enter or exit animation.
A small product browser. A list of products; the user clicks one; we want the card to become the details view instead of being replaced by it.
Here it is — the same UI you saw jumping in section 01, now with the transition switched on. Open a product, go back, and try the toggles: morph the thumbnail turns the shared element identity on and off, and the preset picker changes the choreography.
view-transition-name: vtx-stage; the thumbnail and the hero share vtx-hero. Two names, two groups, one click.Everything below is what makes that work — first in React's API as it is documented, then in the code actually running on this page.
Here's the basic structure:
import {
Activity,
ViewTransition,
startTransition,
useState
} from "react";
function ProductCard({ product, onSelect }) {
return (
<button onClick={() => {
startTransition(() => {
onSelect(product);
});
}}>
<ViewTransition>
<div className="product-card">
<h2>{product.name}</h2>
<p>${product.price}</p>
</div>
</ViewTransition>
</button>
);
}The important parts are the <ViewTransition> boundary and the startTransition around the state update. React's ViewTransition system is designed to activate around React Transitions; the React docs demonstrate triggering ViewTransition enter/exit behaviour by placing the state update inside startTransition.[7]
Because <ViewTransition> is Canary-only today (more on that in section 26), the demo on this page drives the browser API directly — which is precisely the work React's component does for you:
// Every live demo in this article runs on this helper.
// React's <ViewTransition> is Canary-only, so the demos drive the
// browser API directly — which is what React does under the hood.
function run(update, { dir = "fwd", preset } = {}) {
const supported = typeof document.startViewTransition === "function";
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
// progressive enhancement: no support, no problem
if (!supported || reduced || !enabled) return update();
const root = document.documentElement;
root.dataset.vtxDir = dir; // slide left or right
if (preset) root.dataset.vtxPreset = preset;
const t = document.startViewTransition(() =>
flushSync(update) // commit React synchronously
);
t.finished.then(restorePreset, restorePreset);
}function ProductDetails({ product, onBack }) {
return (
<ViewTransition>
<article className="product-details">
<button onClick={onBack}>
← Back
</button>
<h1>{product.name}</h1>
<p>
${product.price}
</p>
<p>
{product.description}
</p>
<button>
Add to cart
</button>
</article>
</ViewTransition>
);
}And the parent:
function ProductApp() {
const [selectedProduct, setSelectedProduct] = useState(null);
return (
<main>
<Activity
mode={selectedProduct ? "hidden" : "visible"}
>
<ProductList
onSelect={setSelectedProduct}
/>
</Activity>
<Activity
mode={selectedProduct ? "visible" : "hidden"}
>
{selectedProduct && (
<ProductDetails
product={selectedProduct}
onBack={() => {
startTransition(() => {
setSelectedProduct(null);
});
}}
/>
)}
</Activity>
</main>
);
}This gives us an architecture where the Activities manage visibility and state preservation and the ViewTransitions manage visual transitions — one ProductApp forking into two Activities, each wrapping a ViewTransition:
selectedProduct. The only piece of state in the whole feature.The real power appears when we give an element a persistent identity. A small card becomes a large hero, and we want the browser to understand: that MacBook card became this MacBook heading.
Watch the difference. Same click, same DOM change — the only variable is whether the thumbnail and the hero share a name:
Without a shared name, two unrelated boxes cross-fade. With one, a single object moves and grows. This is what view-transition-name is designed for.
We can give an element a name:
.product-image {
view-transition-name: product-image;
}Now the browser can treat that element separately during a transition: it is lifted out of the page snapshot into its own group, and if an element with the same name exists on the other side of the update, the browser animates the visual representation between those two states. MDN describes view-transition-name as the mechanism that allows selected elements to participate separately in a view transition.[5]
In the demo above, that's all the "morph" toggle does:
// the shared-element bit: one name, present before and after
<button
className="prod-card"
onClick={(e) => {
// name the thumbnail we're about to open
e.currentTarget.querySelector(".thumb").style.viewTransitionName =
"vtx-hero";
run(() => setSelected(product));
}}
>
<div className="thumb" />
<span className="pname">{product.name}</span>
</button>
// …and in the details view, the same name on the big hero
<div className="hero" style={{ viewTransitionName: "vtx-hero" }} />React's <ViewTransition> can automatically establish transition names for the relevant React boundary. For example:
<ViewTransition>
<img src={product.image} />
</ViewTransition>React handles the necessary integration at the DOM level when the boundary participates in a transition. This means we can reason at the React level — component, boundary, browser transition, CSS animation — rather than manually coordinating DOM snapshots. It's the same chain from section 05, read from the other end:
::view-transition pseudo-element tree, and starts animating between the two. React's job here is to apply view-transition-name to the right DOM node so the browser knows which boxes correspond.The browser provides default transition animations, but we can customize them using CSS. For example, a plain cross-fade:
::view-transition-old(root) {
animation: fade-out 200ms ease;
}
::view-transition-new(root) {
animation: fade-in 200ms ease;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}Rather than describe what that feels like, here's a playground. These controls are wired to the real stylesheet — pick a preset, drag the duration, change the easing, and every demo on this page (including the product browser above) starts using it:
:root, which the ::view-transition-* rules read. Scroll back up to the product browser — it uses these too.The important thing is that these pseudo-elements represent the transition's visual layers rather than ordinary DOM elements. You are not animating your card. You are animating a picture of your card, on an overlay, for a few hundred milliseconds.
For navigation-like transitions, we could do:
::view-transition-old(root) {
animation: slide-out 300ms ease;
}
::view-transition-new(root) {
animation: slide-in 300ms ease;
}
@keyframes slide-out {
to {
transform: translateX(-20%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(20%);
opacity: 0;
}
}Now the UI feels more like a native application: the old page leaves to the left while the new page arrives from the right. That's the slide preset in the playground above — and it's the one this page ships with by default, because it's the one that reads as navigation rather than decoration.
slide. Old page leaves to the left, new page arrives from the right — and on the way back, the other way round, because direction is a data-vtx-dir attribute the CSS reads.This is where React's architecture matters. You might initially think this is enough:
setSelectedProduct(product);But when working with React View Transitions, you generally want the UI update that should participate in the transition to happen inside a React Transition:
startTransition(() => {
setSelectedProduct(product);
});Think of startTransition as telling React: this update represents a transition between UI states. Then this happens — tap through the chain:
This is one of the key differences between React's ViewTransition abstraction and simply calling document.startViewTransition() yourself: React already knows which parts of the tree changed, so it can decide what participates.
Consider a tab interface: Overview | Analytics | Settings. A traditional implementation might be:
{tab === "overview" && <Overview />}
{tab === "analytics" && <Analytics />}
{tab === "settings" && <Settings />}Switching tabs means React may completely unmount the previous content. Imagine the user sets a date range and a filter in Analytics, goes to Settings, then comes back. With conditional rendering, you may lose the local UI state. Activity changes that model:
<Activity mode={tab === "overview" ? "visible" : "hidden"}>
<Overview />
</Activity>
<Activity mode={tab === "analytics" ? "visible" : "hidden"}>
<Analytics />
</Activity>
<Activity mode={tab === "settings" ? "visible" : "hidden"}>
<Settings />
</Activity>Try it for real. Set a date range in Analytics, wander off to Settings, come back — then flip the renderer to conditional and do exactly the same thing:
Set these, switch to Settings, and come back. This is the state people actually get annoyed about losing.
React describes this as a way to maintain and restore UI state while ensuring hidden content doesn't keep unwanted active Effects.[6]
Now combine the two:
<Activity mode={tab === "analytics" ? "visible" : "hidden"}>
<ViewTransition>
<Analytics />
</ViewTransition>
</Activity>When the user switches tabs inside a startTransition, React can preserve the Activity, hide the previous one, reveal the next one, activate the ViewTransition, capture the old and new visual states, and let CSS animate between them. Six steps, none of which are your code:
That's a much richer model than remove DOM + insert DOM — which is all the browser was ever told before.
Here's the mental model I recommend remembering. React forks into two questions, and they meet again in the browser:
Activity manages presence. ViewTransition manages motion.
That's the key idea.
Let's say we execute startTransition(() => setPage("details")). React sees the current state page = "list" and the new state page = "details", renders the new tree, and the ViewTransition boundary participates. Step through what the browser is doing around it:
React is holding page = "list". Wrapped in startTransition, you ask for page = "details". Nothing visual has happened yet — this is still just a scheduled update.
startTransition(() => {
setPage("details");
});This is why the API feels fundamentally different from ordinary CSS transitions. A CSS transition says animate this property when this element changes. A View Transition says animate the relationship between two UI states. That's a much more powerful abstraction.
These are not competitors — they operate at different levels. Tap each card to flip it.
button:hover { transform: scale(1.05); }
A CSS transition animates a property on an element that stays put. It is perfect for hover, focus, button states, dropdowns — anything where the same element simply looks slightly different.
document.startViewTransition(() => setPage('details'))
A View Transition animates the relationship between two UI states. The old element may not even exist afterwards. That is a different question, not a fancier answer to the same one.
el.animate([{opacity: 0}, {opacity: 1}], 300)
The old model: you find the nodes, measure them, keep the outgoing one alive, animate both, and clean up. Every new UI state is new orchestration code.
::view-transition-new(card) { animation: fade-in 300ms; }
The new model: you describe the before and the after, and the browser figures out the in-between. The animation code stops growing with your UI.
button {
transform: scale(1);
transition: transform 200ms;
}
button:hover {
transform: scale(1.05);
}Libraries such as Framer Motion are still extremely useful. They give developers fine-grained control over physics, gestures, springs, drag interactions, sequencing, and component-level animation.
View Transitions solve a somewhat different problem: React renders state A, React renders state B, and the browser understands that this is a view transition. The browser owns much more of the transition machinery. For simple UI state-to-state transitions, this can dramatically reduce the amount of animation orchestration code.
For a modern React application, I'd think about animation at three levels. Tap one:
Use a View Transition when the identity or structure of the UI changes. And use Activity when the UI should remain a reusable, state-preserving activity — visible or hidden, but always itself.
You don't want to put <ViewTransition> everywhere. For example, this:
<ViewTransition>
<div>
<button>...</button>
<span>...</span>
<input>...</input>
<div>...</div>
</div>
</ViewTransition>might cause a much larger portion of the UI to participate than you actually intended. Instead, think about visual identity and ask: what object is actually moving or changing?
<ViewTransition>
<ProductImage />
</ViewTransition>The difference is easier to feel than to describe. Flip the scope and run the change — the violet outline shows what's participating:
Good View Transition design is therefore less about where can I put a boundary? and more about which UI elements have a meaningful visual identity across states?
<ViewTransition> around a whole page section pulls that entire subtree into the transition — buttons, inputs, spans and all. You get one enormous cross-fade where you wanted one object to move, and it usually reads as a flicker rather than as motion.view-transition-name has to be unique among rendered elements when the snapshot is taken. Two cards claiming product-image at once is not a shared-element transition, it is a skipped transition — the browser gives up and your UI just jumps.product-3), or apply the name only to the element that's actually moving.React's <ViewTransition> API is currently a Canary/Experimental API rather than part of the stable React 19.2 API — React's current documentation explicitly labels it as Canary/Experimental.[7] That distinction matters.
So if you're building production software today, you should evaluate the React ViewTransition API carefully rather than blindly moving your entire animation system onto it. The underlying browser View Transition API, however, is now much more mature: MDN currently marks the ViewTransition interface as Baseline 2025, while noting that older devices and browsers may not support it.[1]
And since we're talking about what's actually available — here is what your browser and this page's React build report right now:
19.1.0-canary-029e8bd6-20250306, which predates it — so the Activity demos use the state-preserving CSS fallback instead.Never make your application depend entirely on the animation. Your application should work perfectly without it: if View Transitions are supported you get animated navigation, and if they aren't you get normal navigation. Same app, same states, same clicks.
Prove it to yourself. This switch turns off every view transition on this page — every demo above and below keeps working, it just stops being pretty:
if (!document.startViewTransition) return update(). Everything else in the app is unchanged.The animation should improve the experience. It should not define it. This is especially important because View Transition support varies across browsers and older devices — and because a meaningful number of your users have prefers-reduced-motion switched on, which this page also respects.
The most exciting part of the View Transition API isn't actually the animation. It's the abstraction. For decades, frontend developers have thought about UI as DOM → CSS → animation. View Transitions encourage a different mental model: UI state A → UI state B → the transition between them.
That's much closer to how users actually experience applications. Users don't think the transform property changed from translateX(0) to translateX(-100%). They think:
View Transitions let us encode that higher-level idea.
If you remember only three things, remember these:
Put them together:
startTransition(() => {
setPage("details");
});<Activity mode={page === "details" ? "visible" : "hidden"}>
<ViewTransition>
<Details />
</ViewTransition>
</Activity>…and you get a very interesting architecture. One user action, travelling all the way down to the pixels — tap through it one last time:
The web has traditionally been very good at rendering pages. Modern applications need to render relationships between states. That's what makes View Transitions so interesting. The API isn't simply here's another way to animate a div. It's closer to: the UI changed — here are the old and new views, let the browser help us make that change understandable.
That separation is what makes this technology particularly exciting for complex React applications. Instead of building animation around the DOM…
…we can start building animation around the user's mental model of the application.
And that is a much more interesting direction for frontend architecture.
Filter by keyword or by layer — browser JS, React, or CSS.
The View Transition API is a browser API for animating between two visual states of a page. You call document.startViewTransition(callback), update the DOM inside the callback, and the browser snapshots the old view, renders the new one, and animates between them. MDN describes it as a mechanism for animated transitions between different website and element views.
You pass it a callback that updates the DOM. The browser captures a snapshot of the current page, runs your callback, captures the new state, then animates the old snapshot into the new one using generated pseudo-elements. You write no animation code for the default cross-fade — the browser owns the whole transition lifecycle.
view-transition-name gives an element its own identity in a view transition, so the browser lifts it out of the page snapshot and animates it as its own group. When an element with the same name exists before and after the DOM update, the browser morphs one into the other — the basis of shared-element transitions.
Target the generated pseudo-elements with CSS. ::view-transition-old(name) is the static snapshot of the old view and ::view-transition-new(name) represents the new view; assign your own animation or keyframes to each. Setting animation-duration and animation-timing-function on ::view-transition-group(name) controls the shared position and size animation.
No. React's documentation labels <ViewTransition> as a Canary/Experimental API, so it is not part of the stable React 19.2 release. <Activity> is stable in React 19.2. The underlying browser View Transition API is far more mature — MDN currently marks the ViewTransition interface as Baseline 2025.
<Activity> controls whether part of your UI is visible while keeping it alive. With mode="hidden", React preserves the subtree's state and hides its UI while cleaning up its Effects; switching back to mode="visible" restores the state and recreates the Effects. It manages presence and lifecycle, not animation.
Activity manages presence: whether a piece of UI exists, is visible, and keeps its state. ViewTransition manages motion: which part of the tree participates in a browser view transition when it changes. One answers "what exists?", the other answers "what animates?" — and they compose, with ViewTransition nested inside Activity.
React activates ViewTransition boundaries around React Transitions. Wrapping the state update in startTransition tells React the update represents a transition between UI states, which is what lets the boundary participate and trigger enter or exit animations. A plain setState outside a Transition will not drive the view transition.
Use a CSS transition for property changes on a single element — hover, focus, dropdowns, button states. Use a View Transition when the structure or identity of the UI changes: list to detail, page to page, gallery to image, tab to tab. One animates a property; the other animates the relationship between two UI states.
MDN marks the ViewTransition interface as Baseline 2025, meaning it works across current major browsers, while noting that older devices and browsers may not support it. Feature-detect with if (document.startViewTransition) and fall back to an instant DOM update, so the animation improves the experience without ever defining it.