React View Transitions: A Hands-On Look

A look at the View Transitions API in React: how it works, where it has problems, and whether it is ready for real projects.

#React#JavaScript#CSS

The View Transitions API gives page transitions to the browser, and React 19 has started to wire it into its own renderer. In this post I want to look at how it works and where it still has problems. Under the hood the API is simple enough: it makes a screenshot of the page, then it lets you change the DOM however you want, and after this it makes a second screenshot and animates between the two images. The important detail here is that you don't animate the real elements but only the pictures of them, and if you keep this in mind it explains most of the strange things which happen later.

Here is the plain version, without any framework:

document.startViewTransition(() => {
  // Update the DOM however you want
  document.querySelector('.content').innerHTML = newContent;
});

The browser takes a snapshot of the old state, then it runs your callback and takes a snapshot of the new state, and after that it animates the difference between them.

What React 19 adds

React 19 adds a <ViewTransition> component. It also moves startViewTransition into its render pipeline. So a React state update and the browser transition stay in sync.

Here is a small example with React Router:

import { ViewTransition } from 'react';
import { useNavigate } from 'react-router-dom';

function PostCard({ post }) {
  const navigate = useNavigate();

  return (
    <ViewTransition name={`post-${post.id}`}>
      <article
        onClick={() => navigate(`/post/${post.id}`)}
        style={{ cursor: 'pointer' }}
      >
        <img src={post.thumbnail} alt={post.title} />
        <h3>{post.title}</h3>
      </article>
    </ViewTransition>
  );
}

function PostDetail({ post }) {
  return (
    <ViewTransition name={`post-${post.id}`}>
      <article>
        <img src={post.image} alt={post.title} />
        <h1>{post.title}</h1>
        <p>{post.body}</p>
      </article>
    </ViewTransition>
  );
}

The name prop is the part which does the real work. Two ViewTransition elements get the same name across a navigation. Then the browser animates one into the other. As a result the thumbnail on the list page grows into the main image on the detail page. And you didn't write any animation code for this.

Customizing pseudo-elements

The default cross-fade is good enough in many cases. But sometimes you need something different. For this the API gives you a set of pseudo-elements:

/* Customize the transition duration */
::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: 300ms;
}

/* Make a specific element slide instead of fade */
::view-transition-old(post-hero) {
  animation: slide-out 250ms ease-in;
}

::view-transition-new(post-hero) {
  animation: slide-in 250ms ease-out;
}

@keyframes slide-out {
  to { transform: translateX(-100%); opacity: 0; }
}

@keyframes slide-in {
  from { transform: translateX(100%); opacity: 0; }
}
Using it with concurrent rendering

Transitions are also connected to React's concurrent rendering. Updates inside a transition are batched and wrapped in startViewTransition automatically:

import { useTransition } from 'react';

function Tabs({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <nav>
        {tabs.map((tab, i) => (
          <button
            key={i}
            onClick={() => {
              startTransition(() => setActiveTab(i));
            }}
          >
            {tab.label}
          </button>
        ))}
      </nav>
      <ViewTransition>
        <div className={isPending ? 'loading' : ''}>
          {tabs[activeTab].content}
        </div>
      </ViewTransition>
    </div>
  );
}

React prepares the new content before it starts the transition. So you avoid the flash of half-rendered UI. Normally you get this flash when you do it manually.

What I liked

The main advantage is that it is native. The compositing runs on the GPU, not on the main thread. So the animation stays smooth, even when React is busy with a heavy render. The most interesting case is the shared element. A thumbnail in a list grows into the full image on the detail page. And the only thing you write is the same name on both sides. You also don't need to write a fallback. In a browser without the API the transition simply does nothing. The content is just swapped instantly. One line is enough to detect this:

const supportsViewTransitions = 'startViewTransition' in document;
Where it still has rough edges

The first thing to know is that you return to hand-written CSS very quick. As soon as you go past a simple fade, you write keyframes by hand again.

A more subtle problem appears when an element changes size and aspect ratio at the same time. Imagine a square thumbnail and a wider detail image with the same view-transition-name. The browser interpolates width and height separately. So for a short moment the image visibly stretches, before it reaches the correct shape. The usual solution is to stop the browser from interpolating the box. You let object-fit do the crop instead:

::view-transition-old(photo),
::view-transition-new(photo) {
  height: 100%;
  width: 100%;
  object-fit: cover;
}

In general, you animate screenshots. So the worst problems appear exactly where size, position and content change all at the same time.

Debugging is also not comfortable. The pseudo-elements are not normal DOM nodes. So DevTools can't show you the transition state during the animation. In the end you set animation-duration: 5s only in order to see what actually happens. Also React's own API is still changing. <ViewTransition> works today. But I wouldn't write a tutorial about conditional transitions or transition groups yet. I expect that this API will change again, before it becomes stable.

A few words about browser support. Same-document transitions are available in Chrome already for a while, so you can rely on them. Cross-document transitions (the MPA case) came later, only in recent Chrome and Edge. Firefox and Safari are still behind. So if you start today, start with same-document.

Animating whole routes

Here is a more complete example for animating between routes:

import { ViewTransition } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';

function App() {
  const location = useLocation();

  return (
    <ViewTransition>
      <Routes location={location}>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/post/:id" element={<Post />} />
      </Routes>
    </ViewTransition>
  );
}

With a little CSS every route change also receives a transition:

::view-transition-old(root) {
  animation: fade-and-scale-out 200ms ease-in forwards;
}

::view-transition-new(root) {
  animation: fade-and-scale-in 200ms ease-out forwards;
}

@keyframes fade-and-scale-out {
  to {
    opacity: 0;
    transform: scale(0.95);
  }
}

@keyframes fade-and-scale-in {
  from {
    opacity: 0;
    transform: scale(1.05);
  }
}
My conclusion

Same-document transitions are the part which I would use right now without much hesitation. They're stable enough. And in the worst case, if something is wrong, the page simply swaps the content instead of animating it. So nobody sees a broken screen. Cross-document transitions I prefer to avoid until Safari ships them. The whole point of this API is that it is almost effortless. And to build a second transition path, only for the browsers which don't support it yet, removes this advantage.

So as a conclusion, my answer is simple: yes for any place where I control the browser, and not yet for the open web. Safari needs one or two more releases.