Field notes · Frontend architecture

View Transitions: the browser finally learned how to animate between UI states.

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.

01 The problem

The problem we're actually trying to solve.

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.

// none selected — lucky you

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:

LiveA product dashboard — no view transition
Products3 items
This is a plain 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.

What the View Transition API changes

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.


02 The API

What is the View Transition API?

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:

Livedocument.startViewTransition(() => toggle a class)
dark · body.classList.toggle("dark")
Good morning.
The whole card is one snapshot. Old fades out, new fades in, and nobody wrote an animation.
The DOM update is one line: a class flips. The cross-fade is the browser's default — ::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:

LiveDrag to play the transition frame by frame
::view-transition-old(root)
Products
A static snapshot of the UI before the update. Not interactive.
::view-transition-new(root)
MacBook Pro
The new view, rendering live underneath the overlay.
capture old0%reveal new
Both layers exist at the same time, on an overlay above the page. That's the whole trick — ::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.


03 The overlay

What does the browser actually animate?

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:

LiveThe ::view-transition pseudo-element tree
The static snapshot of the old view. It is a picture, not live DOM — no events, no scrolling, no re-layout. This is the thing you animate out.
None of these are DOM nodes. They exist only while the transition runs, and they are the only surface your CSS can target.

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.


04 Hello world

The simplest possible example.

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>
  `;
});
LiveReplace the contents of one element

Hello

No animation code. The <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.


05 Enter the framework

But what about React?

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:

03 · Commit
DOM mutation
React mutates the DOM. This is the moment that used to be a jump — one tree destroyed, another created, no relationship between them. It is also exactly the moment the browser needs to be told about, and the reason the update has to happen inside the transition's callback.

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]


06 Scope of the question

ViewTransition is about WHAT should animate.

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.


07 The other half

Enter Activity.

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.

Live{isOpen && <Panel />} vs <Activity mode={…}>
conditional rendering
0
<Activity>
0
Left — React destroyed the subtree. Next time it mounts, it's a brand-new component with brand-new state. Everything the user typed is gone.
Right — the subtree stayed alive with its UI hidden. Bring it back and the note and counter are exactly where they were.
Left: unmounted, so the state is gone. Right: hidden but alive, so it isn't. This build's React doesn't export <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.


08 Composition

The really interesting combination.

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.

Activity · presence
visible ⇄ hidden
Keeps the subtree alive. State survives. Effects are cleaned up while hidden and recreated on the way back. Nothing here is about pixels moving.
ViewTransition · motion
old visual state → new visual state
Marks the subtree as visually meaningful, so the browser can pair its old and new boxes and animate between them. Nothing here is about lifecycle.

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.


09 The example

Let's build something real.

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.

LiveList → detail, with a shared element
Products3 items
The stage carries 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.


10 The card

Our React component.

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]

What the live demo does instead

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);
}

11 The detail view

The details page.

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:

ProductAppOwns selectedProduct. The only piece of state in the whole feature.
Activity · listVisible when nothing is selected. Hidden — but alive — when a product is open, so scroll position and filters survive.
Activity · detailsThe mirror image. Hidden until a product is selected, then visible with its state intact.
ViewTransition ×2One inside each Activity. They don't decide what exists — only what the change looks like.

12 Identity

But we can make it much better.

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:

LiveOne card growing into a hero
MacBook Pro$1,999
With a shared name the browser knows these two boxes are the same object and animates position and size. Without one, it has two unrelated boxes and can only cross-fade them.

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.


13 The CSS property

view-transition-name.

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" }} />

14 Who writes the name

React makes this particularly interesting.

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:

04 · Browser
View Transition
The browser had already snapshotted the old view before the mutation. Now it captures the new one, builds the ::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.

15 The choreography

Customizing the animation.

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:

LiveAnimation playground — these settings drive every demo on this page
Presetslide
Duration340ms
Easingease-out-ish
UI state A
Products
Press run. Everything you see is one snapshot animating into another.
/* what your controls just wrote */ ::view-transition-old(page) { animation: slide-out 340ms cubic-bezier(0.4, 0, 0.2, 1); } ::view-transition-new(page) { animation: slide-in 340ms cubic-bezier(0.4, 0, 0.2, 1); }
Changing anything here rewrites the real custom properties on :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.


16 Navigation feel

Slide the new page instead.

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.

LiveSet the whole page to a navigation-style slide
OLD PAGE
──────▶
NEW PAGE
Currently running: 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.

17 The keystone

The most important React concept: startTransition.

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:

01 · Call
startTransition()
You mark the update as a Transition. Semantically you are saying: this is not an urgent typing-into-an-input update, this is a move from one UI state to another.

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.


18 State that survives

Why Activity makes this even more interesting.

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:

LiveTabs — state preservation and motion, independently
Overview

Nothing exciting here — but the counter is local state, so it's a perfectly good canary.

0
Analytics

Set these, switch to Settings, and come back. This is the state people actually get annoyed about losing.

Revenue · Jan 1 — Dec 31
Settings

A place to go so you can come back and find out what survived.

Two switches, two concerns. The left one decides whether your Analytics filters survive a trip to Settings; the right one decides whether the switch is animated. Neither one affects the other — which is exactly the point.

React describes this as a way to maintain and restore UI state while ensuring hidden content doesn't keep unwanted active Effects.[6]


19 Both at once

Activity + ViewTransition = native-app-like navigation.

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:

  1. 01Preserve the Activity.
  2. 02Hide the previous Activity.
  3. 03Reveal the next Activity.
  4. 04Activate the ViewTransition.
  5. 05Capture the old and new visual states.
  6. 06Let CSS animate between them.

That's a much richer model than remove DOM + insert DOM — which is all the browser was ever told before.


20 The model

A useful mental model.

Here's the mental model I recommend remembering. React forks into two questions, and they meet again in the browser:

Activity
"WHAT EXISTS?"
UI lifecycle. Mounted or not, visible or not, state kept or thrown away, Effects running or cleaned up.
ViewTransition
"WHAT ANIMATES?"
Visual lifecycle. Which boxes the browser should treat as the same object across two states.
↓ both feedthe browser, which runs the View Transition API…
↓ which hands off toCSS, which decides what the movement actually looks like.

Activity manages presence. ViewTransition manages motion.

That's the key idea.


21 The mechanics

What happens under the hood?

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.


22 Not competitors

View Transition vs CSS transition.

These are not competitors — they operate at different levels. Tap each card to flip it.

CSS transition

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.

tap — see the other level →
View Transition

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.

← back to the property level
✕ Animating the DOM

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.

tap — the mental shift →
✓ Animating the change

::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.

← the browser owns the machinery
CSS transition · best for
  • hover
  • focus
  • button states
  • dropdowns
  • small property changes
button {
  transform: scale(1);
  transition: transform 200ms;
}

button:hover {
  transform: scale(1.05);
}
View Transition · best for
  • page navigation
  • route changes
  • master/detail transitions
  • large UI changes
  • list → detail
  • gallery → image
  • dashboard → panel
The distinction, in one line: a CSS transition follows a property change; a View Transition follows a UI state change.

23 Libraries

View Transition vs Framer Motion.

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.

An animation library owns
  • physics
  • gestures
  • springs
  • drag interactions
  • sequencing
  • component-level animation
The browser owns
  • snapshots
  • pairing old ⇄ new
  • the overlay tree
  • position + size morph
  • cleanup

24 In practice

A practical architecture.

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.


25 Sharp edges

One subtle but important detail.

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:

LiveWrapping the whole panel vs wrapping the image
participating
thumbnail
MacBook Pro
buttoninputspan
A short summary line that changes when the view changes.
Same DOM change either way. Wrapping the panel cross-fades buttons, inputs and text that nobody asked to animate; naming just the image lets one object move while everything else simply updates.

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?

01Wrapping everything is worse than wrapping nothing
A <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.
Rule → wrap the thing with a visual identity, not the container it happens to live in.
02Ask what object is actually moving
Good View Transition design is less about where you can put a boundary and more about which elements a user would say are the same thing in both states. The product image is the same object. The wrapper div around it never was.
Rule → name the object the user recognises, and let everything else cross-fade.
03Two elements can't share a name at the same time
A 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.
Rule → give names per identity (product-3), or apply the name only to the element that's actually moving.
04The transition freezes the page while it runs
During a transition the page is painted from snapshots, so anything on screen is a picture for those few hundred milliseconds. That is an argument for short durations — and a reason not to put a view transition on something the user does dozens of times a minute.
Rule → keep it under ~400ms, and save it for changes that are actually a change of view.

26 Reality check

Another subtle React detail.

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.

<Activity>Stable · React 19.2
Introduced as a stable React feature in 19.2. Preserves state while hidden, cleans up Effects, restores both when visible again.
<ViewTransition>Still evolving · React Canary / Experimental
React's current documentation explicitly labels it Canary/Experimental. Evaluate carefully before moving a production animation system onto it.
View Transition APIBaseline 2025 · The browser
MDN currently marks the ViewTransition interface as Baseline 2025, while noting that older devices and browsers may not support it.

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:

LiveFeature detection, run in your browser just now
document.startViewTransition
not available
Your browser doesn't expose it, so every demo above updated instantly instead. Same states, no motion.
React Activity
not exported
Stable in React 19.2, but this app renders on Next's vendored 19.1.0-canary-029e8bd6-20250306, which predates it — so the Activity demos use the state-preserving CSS fallback instead.
React ViewTransition
checking…
Not on the stable build this site ships, which is why the demos call the browser API directly.
Nothing on this page breaks if any of these say no — that's section 27's whole argument, and it is checked, not claimed.

27 The safety net

Progressive enhancement is important.

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:

LiveTurn the whole feature off and see what's left
this browser has no support — already falling back
State A
Either way you end up in the same state, with the same DOM, and the same thing on screen. One route is just nicer to watch.
The guard is one line: 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.


28 The shift

The bigger idea.

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:

I opened the product.I went from the list to the details.The image expanded.I switched to Analytics.

View Transitions let us encode that higher-level idea.


29 Three things

The React mental model to take away.

If you remember only three things, remember these:

<Activity mode="visible">
Keep this UI as an activity and control whether it is visible.
It can preserve the UI's state while hidden — and clean up its Effects while it's there.
<ViewTransition>
This part of my UI should participate in a visual transition.
Nothing about existence, visibility or state. Only about what the browser should treat as one moving object.
startTransition(() => …)
This state update represents a React transition.
The signal the boundaries key off. Without it, the update is just an update.

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:

01 · Intent
User action
Someone opens a product. In their head this is one event, not a DOM mutation followed by an animation.

Final thought

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.

Activitylifecycle & state preservation
React Transitionthe state update is a transition
ViewTransitionvisual identity & animation
CSSactual visual choreography
Browsersnapshots & transition mechanics

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.


31 Reference

Everything this article touches, in one place.

Filter by keyword or by layer — browser JS, React, or CSS.

19 / 19
AllJSReactCSS
document.startViewTransition(cb)Start a same-document view transition; the DOM update goes inside the callback.JS
transition.readyPromise that resolves once the pseudo-element tree is built and the animations are about to run.JS
transition.finishedPromise that resolves when the transition's animations have finished and the overlay is gone.JS
transition.updateCallbackDonePromise that resolves when your DOM-update callback has completed.JS
transition.skipTransition()Skip straight to the end state — the DOM update still happens, the animation doesn't.JS
flushSync(update)Force React to commit the update synchronously inside the transition callback.React
startTransition(update)Mark a state update as a React Transition — what ViewTransition boundaries key off.React
<Activity mode="visible">Render the subtree normally: UI shown, state kept, Effects mounted.React
<Activity mode="hidden">Hide the UI but preserve the subtree's state, cleaning up its Effects.React
<ViewTransition>Opt a subtree into the browser view transition. Canary/Experimental in React today.React
view-transition-nameGive an element its own identity so it animates as its own group.CSS
view-transition-name: noneOpt an element back out of participating separately.CSS
::view-transitionRoot of the overlay pseudo-element tree, painted above the page.CSS
::view-transition-group(name)Animates the position and size between the old and new box.CSS
::view-transition-image-pair(name)Container that stacks the old and new representations.CSS
::view-transition-old(name)Static snapshot of the old view — the thing you animate out.CSS
::view-transition-new(name)Representation of the new view — the thing you animate in.CSS
@view-transition { navigation: auto }Opt in to cross-document view transitions between same-origin pages.CSS
@media (prefers-reduced-motion)Where you turn all of this off for people who asked you to.CSS

FAQ Quick answers

View Transitions — common questions.

What is the View Transition API?

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.

How does document.startViewTransition work?

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.

What does view-transition-name do?

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.

How do you customize a view transition animation?

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.

Is React's <ViewTransition> component stable?

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.

What is React's <Activity> component for?

<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.

What is the difference between <Activity> and <ViewTransition>?

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.

Why do you need startTransition for React view transitions?

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.

When should you use a View Transition instead of a CSS 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.

Which browsers support the View Transition API?

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.


Copied to clipboard