DEV Community

Cover image for React 19.3 ViewTransition: Animate State Without Losing It
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

React 19.3 ViewTransition: Animate State Without Losing It

You wire up a photo grid. Click a thumbnail, it should morph smoothly into a full detail view — the kind of transition Google Photos and Instagram have trained everyone to expect. You already know the browser can do this for free: the native View Transitions API wraps a DOM update in document.startViewTransition(), tags the old and new elements with a shared view-transition-name, and the browser tweens between them. You wire it up. It works beautifully — right up until the thumbnail lives inside a filtered, paginated React list, and the click also has to update selectedId state, close a search box, and possibly wait on data that hasn't arrived yet. Now the animation glitches, fires on the wrong element, or doesn't fire at all, and nothing in the DevTools explains why.

This is episode five of React Deep Dive, on what React itself decides rather than JavaScript with a React import. This one is about a decision the reconciler now makes explicitly: which DOM changes are worth animating, and which four shapes every one of those changes takes.

This article is written against React 19.3 (verified against React's own CHANGELOG.md on GitHub, version 19.3.0, published September 9, 2026), where the <ViewTransition> component and addTransitionType shipped as stable, not experimental. Everything here assumes React 19-era function components and hooks.

What you'll learn

By the end of this article you'll be able to:

  • Explain why a plain setState update never triggers a ViewTransition animation, and what has to wrap it instead
  • Use the four activation props — enter, exit, update, and share — and know which DOM change fires which one
  • Build a shared-element transition (thumbnail → detail) that survives a real re-render, not just a full-page navigation
  • Tag a transition with addTransitionType so the same component animates differently depending on why it changed
  • Recognize the cases <ViewTransition> can't help with — no Transition, no browser support, or content that's still loading

Who this is for

You've written function components with hooks and used useTransition or startTransition for pending UI at least once. No prior knowledge of the native View Transitions API is assumed, though if you've read the earlier piece on it, the mental model here builds directly on top of view-transition-name and the ::view-transition-old/::view-transition-new pseudo-elements.

Table of contents

The problem: an animation that only survives a full page load

The browser's native View Transitions API is beautifully simple for exactly one shape of change: a full document swap, or a DOM mutation you make yourself, synchronously, inside a callback:

// The "wrong way first" — wiring the native API up by hand inside a React app
function selectPhoto(id) {
  if (!document.startViewTransition) {
    setSelectedId(id); // no support: just update, no animation
    return;
  }
  document.startViewTransition(() => {
    setSelectedId(id); // ⚠️ setState is async — the DOM hasn't changed yet
  });
}
Enter fullscreen mode Exit fullscreen mode

startViewTransition() expects its callback to make the DOM change and finish before it takes its "after" snapshot. But setSelectedId doesn't touch the DOM — it schedules a re-render. By the time React actually commits the new list, the browser has already taken its snapshot and started animating between two identical frames. You can make this work by wrapping the call in flushSync, but now every click forces a synchronous, blocking render — the exact cost React's concurrent rendering exists to avoid. And even then, you've only solved this click. Add a search filter that also changes which thumbnails exist, and the browser has no idea which old element corresponds to which new one; it just cross-fades the whole container.

The actual problem: the native API assumes the code calling it fully controls when the DOM change happens. React doesn't — a setState call describes an intent to update, and the actual commit can be deferred, batched, or (with Suspense) held until data arrives. The stable <ViewTransition> component exists to close that gap: it hooks into React's own commit and Suspense machinery instead of assuming a single synchronous mutation.

The mental model: a Transition decides whether anything animates at all

The mental model: <ViewTransition> doesn't animate every DOM change inside it — it only animates changes that happen as part of a Transition, the same concept useTransition and startTransition already use to mark an update as non-urgent. Wrap the element you want to animate, then make the state change that affects it through startTransition (directly, through a Transition-aware router, or through useDeferredValue). A ViewTransition sitting around an element that changes via a plain, untransitioned setState renders instantly, with no animation, exactly as if the component weren't there.

import { ViewTransition, startTransition, useState } from "react";

function Gallery() {
  const [selectedId, setSelectedId] = useState(null);

  return (
    <button
      onClick={() => {
        // The state change is wrapped in a Transition — this is what
        // lets any <ViewTransition> below react to it at all.
        startTransition(() => setSelectedId(42));
      }}
    >
      Open photo
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key concept: "Transition" here is not a metaphor for "animation" — it's the specific React primitive that marks an update as interruptible and lower-priority. <ViewTransition> reuses that exact signal to decide when to even ask the browser for a snapshot. No Transition, no snapshot, no animation — by design, not by accident.

Once an update is a Transition, <ViewTransition> classifies what happened to each wrapped element into exactly one of four shapes, and animates accordingly:

Prop Fires when…
enter This ViewTransition is the first thing inserted, anywhere in the tree, during this Transition
exit This ViewTransition is the first thing removed during this Transition
update The element stays, but something inside it changed — content, size, or position, often because a sibling resized
share A named ViewTransition in a removed subtree shares its name with one in an inserted subtree — React treats them as the same visual element and animates between them

Stage 1: enter and exit — the smallest activation

Start with the shape that needs the least setup: something appearing or disappearing.

import { ViewTransition, startTransition, useState } from "react";

function Notice({ message }) {
  return (
    <ViewTransition enter="slide-in" exit="fade-out">
      <div className="notice">{message}</div>
    </ViewTransition>
  );
}

function App() {
  const [notice, setNotice] = useState(null);
  return (
    <>
      <button
        onClick={() => startTransition(() => setNotice("Saved!"))}
      >
        Save
      </button>
      {notice && <Notice message={notice} />}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

enter/exit accept "auto" (the browser's default cross-fade), "none", or a class name whose CSS you write yourself, styling the browser's own ::view-transition-new(*)/::view-transition-old(*) pseudo-elements — the same pseudo-elements the native API exposes, just targeted through a name React assigns for you instead of one you manage by hand.

::view-transition-new(.slide-in) {
  animation: slide-in 200ms ease-out;
}
@keyframes slide-in {
  from { transform: translateY(-12px); opacity: 0; }
}
Enter fullscreen mode Exit fullscreen mode

Key concept: you are not writing document.startViewTransition anywhere. React decides when to call the browser API, based on the Transition boundary; you only decide how the animation looks once it does.

Stage 2: update — animating a resize in place

An element that stays mounted but changes shape needs update, not enter/exit — this is also what fires on a sibling whose own resize pushes this element to a new position, even if this element's own content didn't change:

function ExpandableCard({ expanded, onToggle, summary, detail }) {
  return (
    <ViewTransition update="auto">
      <div className="card" onClick={onToggle}>
        <p>{summary}</p>
        {expanded && <p className="detail">{detail}</p>}
      </div>
    </ViewTransition>
  );
}
Enter fullscreen mode Exit fullscreen mode

Clicked through a Transition, the card's height change animates smoothly instead of snapping — and any sibling card pushed down the page animates its own repositioning too, because a position change from a neighbor's resize is exactly what update is defined to cover.

Stage 3: share — a shared-element transition across a re-render

This is the shape the opening problem needed, and it's the one the raw browser API structurally can't do inside a dynamic list: the same visual element exists in two different subtrees — a thumbnail in a grid, a hero image in a detail view — and you want React to treat them as one continuous element across the transition, even though, as far as the reconciler is concerned, one unmounted and a different one mounted.

function Thumbnail({ photo, onSelect }) {
  return (
    <ViewTransition name={`photo-${photo.id}`}>
      <img
        src={photo.thumbUrl}
        onClick={() => startTransition(() => onSelect(photo.id))}
      />
    </ViewTransition>
  );
}

function DetailView({ photo }) {
  return (
    <ViewTransition name={`photo-${photo.id}`}>
      <img src={photo.fullUrl} className="detail-image" />
    </ViewTransition>
  );
}
Enter fullscreen mode Exit fullscreen mode

Give the outgoing and incoming elements the same name, and if one is removed while the other is inserted in the same Transition, React pairs them as a shared-element transition — the browser morphs position and size from the small thumbnail to the full image, the way you'd hand-wire view-transition-name on a static page, except this now survives whatever filtering or sorting also happened in that same click. This is precisely the case from the opening bug: the naive flushSync version couldn't tell the browser "this thumbnail is that hero image" once a filter changed which items existed; naming both sides is what makes the identity explicit instead of positional.

This is also why share has to be a React-level concept, not a CSS one: whether the outgoing thumbnail and the incoming hero image are "the same element" is exactly the identity question React Re-render vs Remount: What Actually Triggers Each covers for ordinary reconciliation — share just answers it explicitly with a name, for the one case (two different subtrees) where React's own fiber identity can't do it for you.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Stage 4: naming the transition with addTransitionType

Every one of the animations above can also be told why it's happening, so the same ViewTransition can look different for "the user navigated forward" versus "the user navigated back" — without threading a prop down to describe it:

import { addTransitionType, startTransition } from "react";

function goToNext() {
  startTransition(() => {
    addTransitionType("nav-forward");
    setPage((p) => p + 1);
  });
}

function goToPrevious() {
  startTransition(() => {
    addTransitionType("nav-back");
    setPage((p) => p - 1);
  });
}
Enter fullscreen mode Exit fullscreen mode

React forwards whatever string you pass as a browser view-transition type, so your CSS can target it directly — but :active-view-transition-type() only ever matches the document root, so it has to wrap the pseudo-element rule rather than chain after it:

:root:active-view-transition-type(nav-back) {
  &::view-transition-old(.card) {
    animation: slide-out-right 200ms;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key concept: addTransitionType doesn't change whether something animates (that's still enter/exit/update/share, decided per-element) — it only labels the transition so your CSS can branch on cause. One ViewTransition handles both directions; the label is what tells them apart.

Edge cases and gotchas

  • A ViewTransition around an element that changes outside any Transition is inert. If you find an animation just isn't firing, check the state update first — a plain onClick={() => setState(x)} is the single most common reason, not a missing prop.
  • share needs the name to be unique in each subtree at the moment of the swap, exactly like the native API's view-transition-name. Two simultaneously-mounted elements with the same name isn't a silent quirk — React detects it and errors, logging both offending elements in development — so a dynamic name derived from a stable ID (photo-${id}), not the array index, is what makes this safe in a re-orderable list.
  • Suspense-blocked content usually delays the "after" snapshot, not the animation itself — React waits for newly-suspended content to be ready before asking the browser to animate, so a slow detail view doesn't produce a transition into a loading spinner; the whole Transition just starts later. The one case that's stronger than a delay: if a Suspense fallback actually appears between a share pair's removal and its matching insertion, the shared-element transition doesn't happen at all for that pair — worth knowing before you assume every Suspense-gated update animates, just late.
  • Multiple Transitions in flight no longer block each other. As of the same 19.3 release, transitions render independently rather than being entangled into a single render, so triggering a second, unrelated Transition while a slow one is still resolving doesn't stall it.
  • Browser support isn't universal. The View Transitions API this all sits on top of is a Chromium-and-Safari-shipped feature with gaps elsewhere; without support, <ViewTransition> should degrade to an instant, unanimated update rather than an error — verify that in your own target browsers rather than assuming it, and never let a missing API break the update itself.
  • This is not a general-purpose animation library. There's no spring physics, no stagger helper, no timeline — you're styling two browser pseudo-elements with CSS. For anything beyond enter/exit/resize/shared-element, a dedicated animation library is still the right tool.

Best practices: when to reach for ViewTransition

  • Reach for it when an interaction already goes through a Transition (a route change, a filter, a tab switch, an optimistic update) and the result deserves to feel continuous — a list reordering, a card expanding, a thumbnail opening into detail.
  • Name only what needs to persist visually. Give name to the handful of elements that are the actual subject of a shared-element transition; wrapping everything in a named ViewTransition just adds bookkeeping for animations nobody will notice.
  • Don't force an update into a Transition just to get an animation. If the update isn't naturally low-priority or interruptible, wrapping it in startTransition to unlock <ViewTransition> is solving the wrong problem — a plain CSS transition on the element may be simpler and more honest about what's actually happening.
  • Avoid it when the change already crosses a full page navigation between separately loaded documents — that's still the native @view-transition { navigation: auto; } CSS rule's job, not this component's.

FAQ

Why doesn't wrapping an element in ViewTransition make it animate?

Because activation depends on the state change happening inside a Transition (startTransition, useTransition, or a Transition-aware router navigation), not on the presence of the <ViewTransition> wrapper alone. A plain setState renders instantly and skips the browser's view-transition machinery entirely, by design.

Do I need the native View Transitions API to use this component?

You don't call document.startViewTransition yourself — React does that internally when it detects the underlying browser supports it. You do still rely on the same browser feature under the hood, so support follows the native API's own availability.

What's the difference between update and share?

update is for an element that stays the same element across the Transition but changes in place (content, size, position from a sibling's resize). share is for two different elements — one removed, one inserted — that you're telling React to treat as a single continuous element by giving them matching names.

Can I use ViewTransition with Suspense-loaded data?

Yes — that's a core reason it needed to be a React component rather than a manual API call. It coordinates with Suspense boundaries inside the same Transition, waiting for suspended content to resolve before the animation's "after" state is captured, instead of animating into a loading fallback.

Does the React Compiler affect any of this?

No. The Compiler (stable at 1.0) automates memoization of values computed during render; it has no relationship to Transitions, Suspense, or the browser's view-transition lifecycle. Nothing here needs the Compiler, and nothing about the Compiler changes how <ViewTransition> behaves.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Cheat sheet

import {
  ViewTransition,
  startTransition,
  addTransitionType,
} from "react";

// 1. The state change MUST go through a Transition, or nothing animates.
startTransition(() => setSelectedId(id));

// 2. Pick the shape:
<ViewTransition enter="auto" exit="auto">      {/* mounts / unmounts */}
<ViewTransition update="auto">                 {/* stays, resizes/moves */}
<ViewTransition name={`item-${id}`}>           {/* shared element pair  */}

// 3. Style the browser pseudo-elements React targets for you:
// ::view-transition-old(<class-or-name>)
// ::view-transition-new(<class-or-name>)

// 4. Label WHY, to branch the CSS on cause:
startTransition(() => {
  addTransitionType("nav-back");
  setPage(p => p - 1);
});
// :root:active-view-transition-type(nav-back) { &::view-transition-old(.card) { … } }
Enter fullscreen mode Exit fullscreen mode
Prop Use for Fires on
enter New content appearing First mount inside this Transition
exit Content disappearing First unmount inside this Transition
update Same element, different size/content/position In-place change or a sibling's resize
share (via matching name) Thumbnail → detail, list item → expanded card Matched removal + insertion, same Transition

Key takeaways

  • <ViewTransition> only reacts to changes made inside a React Transition — a plain setState is invisible to it, on purpose.
  • Every activation is one of four shapes: enter, exit, update, or share — matching names across a removal and an insertion.
  • Suspense-blocked content delays when the animation's "after" state is captured; it doesn't corrupt the animation.
  • addTransitionType labels why an update happened so one component's CSS can branch on cause, separate from whether it animates.
  • It replaces the manual document.startViewTransition + flushSync workaround, not the browser API itself — you're still styling ::view-transition-old/::view-transition-new.

Back to that photo grid

The bug in the opening scene wasn't the animation idea — it was asking a synchronous browser API to describe an asynchronous React update. startTransition is what tells <ViewTransition> a change is happening at all, and name is what tells it which old element the new one is actually replacing, even after a filter reshuffled the whole list around it. Once both are in place, the thumbnail-to-detail morph survives exactly the kind of re-render that broke the hand-wired version — no flushSync, no manual snapshot timing.

If you've been holding off on this kind of animation because the native API only seemed to work on static pages, that's the gap this component closes. What's the first shared-element transition you're going to try it on?

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)