React View Transitions: A Hands-On Look

A practical walkthrough of the View Transitions API in React — what works, what does not work, and whether it is ready for real projects.

#React#JavaScript#CSS

For years, smooth page transitions on the web have been a pain. You either reached for heavyweight animation libraries, hacked together CSS transitions with state juggling, or just accepted the hard cut between views. Native mobile apps had us beat — swipe between screens and everything morphs naturally.

The View Transitions API changes that. And with React starting to integrate support for it, there's finally a path to fluid transitions without pulling in half of npm. I've been experimenting with it, and here's what I've found.

What the View Transitions API Actually Does

At its core, the API captures a "before" snapshot of the DOM, lets you make changes, then animates between the old and new states. The browser handles the heavy lifting — it literally takes screenshots of elements, cross-fades them, and interpolates positions.

The simplest vanilla usage:

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

The browser snapshots the old state, runs your callback, snapshots the new state, then animates between them. No keyframes to write, no FLIP calculations, no layout thrashing. It just works.

Bringing It Into React

React 19 introduced the <ViewTransition> component and integrated startViewTransition into its rendering pipeline. This is where things get interesting — React can now coordinate its state updates with the browser's transition mechanism.

Here's a basic example using React Router and view transitions:

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 key — when React sees matching ViewTransition names across navigations, it morphs one into the other. The thumbnail on the list page smoothly expands into the hero image on the detail page. No animation code needed.

Controlling the Animation with CSS

The default cross-fade is fine, but you'll probably want more control. The API exposes pseudo-elements you can style:

/* 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; }
}

You can also use view-transition-class to group elements and animate them together with shared CSS rules, rather than writing per-element styles.

Using startTransition Under the Hood

React's integration ties view transitions to its concurrent rendering model. When you trigger a state update inside a transition, React batches the DOM changes and wraps them in startViewTransition for you. This means you can use useTransition alongside view transitions:

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

The content swap gets animated automatically. React's concurrent rendering ensures the new content is ready before the transition starts, so you don't get a flash of incomplete UI.

What Works Well

Zero-dependency animations. No Framer Motion, no GSAP, no React Spring. The browser handles it natively, and the performance is excellent because it's composited at the GPU level.

Shared element transitions are genuinely impressive. The list-to-detail pattern — where a card morphs into a full page — looks slick with very little code. This used to require significant effort.

Progressive enhancement is built in. If the browser doesn't support the API, nothing breaks. The transition just doesn't happen, and users see the standard instant swap. You can detect support easily:

const supportsViewTransitions = 'startViewTransition' in document;
What Needs Work

Browser support is the elephant in the room. Cross-document view transitions (MPA) landed in Chrome and Edge but Firefox and Safari are still catching up. Same-document transitions have broader support, but you need to check your audience.

Complex transitions get finicky fast. Once you go beyond simple fades and morphs, you're back to writing detailed CSS animations. The API gives you the hooks, but it doesn't magically handle every case. Animations where elements change size, position, and content simultaneously can produce visual artifacts.

Debugging is rough. When a transition doesn't look right, figuring out why is harder than it should be. The pseudo-element approach means you can't easily inspect the transition state in DevTools the way you would a normal element.

React's integration is still evolving. The <ViewTransition> component works, but the ergonomics around things like conditional transitions or transition groups could be smoother. The API surface might change.

A Practical Pattern: Route Transitions

Here's a more complete pattern 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>
  );
}

Add some CSS and every route change gets a smooth 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);
  }
}
Should You Use It Today?

If you're building a project where Chrome/Edge dominance is fine (internal tools, Electron apps, PWAs for a known audience), absolutely. The API is stable enough for same-document transitions, and the results are noticeably better than no transitions at all.

For public-facing sites where you need broad browser support, treat it as progressive enhancement. Add view transitions knowing they'll delight Chrome users and gracefully degrade elsewhere.

For production apps with complex animation requirements, keep your animation library for now. The View Transitions API handles the common cases well but isn't a replacement for something like Framer Motion when you need fine-grained control over gesture-driven animations or spring physics.

The direction is right though. Browser-native transitions with framework integration is exactly where this should be heading. The less JavaScript we ship for visual polish, the better.