Sep 20, 2026 First publish on Mar 17, 2023

Using View Transition API in React App

The View Transition API is a new feature on web that simplifies the process of creating animated transitions for shared element. Previously, achieving smooth transitions for shared element on the web was a complex task. However, with the introduction of this API, we can now easily animate shared element, allows us to create engaging and fluid navigational experiences, similar to those found in mobile applications.

In this article, I will provide a brief overview of how to start using View Transition API in your React applications. For a more comprehensive guide, please refer to this article authored by Jake Archibald, who championed this feature.

A simple example

In the example below, when the Move button is clicked, the cat image will switch from top right to bottom left (or vice versa) abruptly, as they are technically two separate elements.

Use the play button on the right to run the example.

document.getElementById('moveBtn').addEventListener('click', () => {
  document
    .querySelectorAll('.thumbnail, .cat-details')
    .forEach((target) => target.classList.toggle('hidden'));
});
<div class="top-bar">
  <div class="top-bar-content">
    <h1>Move Cat</h1>
    <button id="moveBtn">Move</button>
  </div>
  <img
    src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
    alt="cat"
    width="300"
    height="300"
    class="cat-img thumbnail"
  />
</div>
<div class="cat-details hidden">
  <img
    src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
    alt="cat"
    width="500"
    height="500"
    class="cat-img detailed-img"
  />
  <div class="cat-desc">
    <h2>Cat Details</h2>
  </div>
</div>
<style>
  .cat-details {
    display: flex;
  }

  .hidden {
    display: none;
  }

  .cat-desc {
    flex: 1;
  }

  .top-bar {
    display: flex;
    justify-content: space-between;
    align-items: start;
  }

  .top-bar-content {
    display: flex;
    align-items: center;
    gap: 2rem;
  }

  .thumbnail {
    width: 100px;
    height: 100px;
  }

  .detailed-img {
    width: 50vw;
    height: auto;
  }
</style>

Using View Transition API

Using View Transition API, we can animate that movement with the following changes:

  1. wrap the code that update the DOM element with document.startViewTransition.
  2. add view-transition-name CSS property with the same value to the two cat images that we want to animate.

If you’re using latest version of Chrome, you’ll see the following changes when the Move button is clicked:

  1. The cat image will transition between two positions with smooth animation.
  2. There is a subtle fade animation for the “Cat Details” text.
document.getElementById('moveBtn').addEventListener('click', () => {
  document.startViewTransition(() => {
    document
      .querySelectorAll('.thumbnail, .cat-details')
      .forEach((target) => target.classList.toggle('hidden'));
  });
});
<div class="top-bar">
  <div class="top-bar-content">
    <h1>Move Cat</h1>
    <button id="moveBtn">Move</button>
  </div>
  <img
    src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
    alt="cat"
    width="300"
    height="300"
    class="cat-img thumbnail"
  />
</div>
<div class="cat-details hidden">
  <img
    src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
    alt="cat"
    width="500"
    height="500"
    class="cat-img detailed-img"
  />
  <div class="cat-desc">
    <h2>Cat Details</h2>
  </div>
</div>
<style>
  .cat-img {
    view-transition-name: meow-image;
  }

  .cat-details {
    display: flex;
  }

  .hidden {
    display: none;
  }

  .cat-desc {
    flex: 1;
  }

  .top-bar {
    display: flex;
    justify-content: space-between;
    align-items: start;
  }

  .top-bar-content {
    display: flex;
    align-items: center;
    gap: 2rem;
  }

  .thumbnail {
    width: 100px;
    height: 100px;
  }

  .detailed-img {
    width: 50vw;
    height: auto;
  }
</style>

How it works

You can understand how the API works in this way:

  1. When document.startViewTransition is called, browser

    • takes a screenshot of the entire page
    • finds all the elements with view-transition-name CSS property declared, and takes a screenshot of them

    In the example above, it takes a screenshot of the entire page, and then a screenshot of the cat image.

  2. Once the screenshots are done, it will invoke the callback that you passed to it. In the example above, the callback will hide the cat image on top right and display the cat image on bottom left.

  3. Once the callback is done, it then take another round of screenshots like step 1, then figures out the difference and perform the animation accordingly.

    • by default, the animation is fading effect, which is why the “Cat Details” has the fade animation. (To be more precise, entire page has the fade animation, but for parts that did not change, we can’t observe the fade animation)
    • for elements with view-transition-name property defined, it will figure out the difference of the position, and animate accordingly.

If you want to understand it in a more accurate and detailed way, refer to this section of Jake Archibald’s article.

Usage View Transition API with React

Because React renders state changes asynchronously, we need to wrap the state-setter function with flushSync to force the state changes to be applied synchronously.

import * as React from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';

const App = () => {
  const [isThumbnail, setIsThumbnail] = React.useState(true);

  const handleMove = () => {
    document.startViewTransition(() => {
      flushSync(() => {
        setIsThumbnail((prev) => !prev);
      });
    });
  };

  return (
    <div>
      <div className="top-bar">
        <div className="top-bar-content">
          <h1>Move Cat</h1>
          <button onClick={handleMove}>Move</button>
        </div>
        {isThumbnail && (
          <img
            src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
            alt="cat"
            className="cat-img thumbnail"
          />
        )}
      </div>
      {!isThumbnail && (
        <div className="cat-details">
          <img
            src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
            alt="cat"
            className="cat-img detailed-img"
          />
          <div className="cat-desc">
            <h2>Cat Details</h2>
          </div>
        </div>
      )}
    </div>
  );
};

createRoot(document.getElementById('root')).render(<App />);

React first-class support: ViewTransition component

As part of React 19.3, ViewTransition component is available from React directly.

import * as React from 'react';
import { createRoot } from 'react-dom/client';

const App = () => {
  const [isThumbnail, setIsThumbnail] = React.useState(true);

  const handleMove = () => {
    React.startTransition(() => {
      setIsThumbnail((prev) => !prev);
    });
  };

  return (
    <div>
      <div className="top-bar">
        <div className="top-bar-content">
          <h1>Move Cat</h1>
          <button onClick={handleMove}>Move</button>
        </div>
        {isThumbnail && (
          <React.ViewTransition name="meow-image">
            <img
              src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
              alt="cat"
              className="thumbnail"
            />
          </React.ViewTransition>
        )}
      </div>
      {!isThumbnail && (
        <div className="cat-details">
          <React.ViewTransition name="meow-image">
            <img
              src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
              alt="cat"
              className="detailed-img"
            />
          </React.ViewTransition>
          <div className="cat-desc">
            <h2>Cat Details</h2>
          </div>
        </div>
      )}
    </div>
  );
};

createRoot(document.getElementById('root')).render(<App />);

With the ViewTransition component:

  • we no longer need to write custom CSS to define the view-transition-name property. Instead, we can use the name prop as a replacement.
  • we replace flushSync and document.startViewTransition with React.startTransition. Not only it’s more concise, by allowing React to handle the view transition, the behavior integrates well with React’s Suspense. For instance, we can use it with useDeferredValue to create animated list.
import * as React from 'react';
import { createRoot } from 'react-dom/client';

const App = () => {
  const [search, setSearch] = React.useState('');

  const deferredSearch = React.useDeferredValue(search);

  const displayedCats = deferredSearch
    ? catData.filter((cat) => cat.name.toUpperCase().includes(deferredSearch.toUpperCase()))
    : catData;

  return (
    <div>
      <div className="search-container">
        <input
          type="text"
          value={search}
          onChange={(ev) => setSearch(ev.target.value)}
          placeholder="Type to search"
          className="search-input"
        />
      </div>
      {displayedCats.length === 0 && (
        <React.ViewTransition name="error-message">
          <div className="error-message">
            <p>No cats found</p>
          </div>
        </React.ViewTransition>
      )}
      <ul className="cat-list">
        {displayedCats.map((cat) => (
          <li key={cat.id}>
            <React.ViewTransition name={`cat-${cat.id}`}>
              <div className="cat-item">
                <img src={cat.image} alt={cat.name} className="cat-thumbnail" />
                <div className="cat-item-content">
                  <p className="cat-name">{cat.name}</p>
                </div>
              </div>
            </React.ViewTransition>
          </li>
        ))}
      </ul>
    </div>
  );
};

const catData = [
  {
    id: 'meow',
    name: 'Meow',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png',
  },
  {
    id: 'angie',
    name: 'Angie',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/angry_cat.png',
  },
  {
    id: 'milky',
    name: 'Milky',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/milky.png',
  },
  {
    id: 'spinner',
    name: 'Spinner',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/spinning_cat.png',
  },
];

createRoot(document.getElementById('root')).render(<App />);

Shared elements must be in the viewport

React only forms a shared-element pair when both the element being removed and the element being inserted are inside the viewport. If either side is outside the viewport, React treats each side as a regular enter or exit instead. This prevents an element from unexpectedly flying into or out of the screen when the page is scrolled.

This often shows up in a listing-to-detail transition. Try opening the first cat without scrolling: the thumbnail and detail hero are both visible, so React forms a shared-element transition between them. Then go back, scroll to the last cat, and open it. The detail page keeps the scroll position, leaving its hero image above the viewport, so React does not pair the two images.

import * as React from 'react';
import { createRoot } from 'react-dom/client';

const App = () => {
  const [selectedCatId, setSelectedCatId] = React.useState(null);

  const selectedCat = cats.find((cat) => cat.id === selectedCatId);

  const openCat = (catId) => {
    React.startTransition(() => setSelectedCatId(catId));
  };

  const goBack = () => {
    React.startTransition(() => setSelectedCatId(null));
  };

  return (
    <main>
      <header className="demo-toolbar">
        {selectedCat ? <button onClick={goBack}>← Back to cats</button> : <b>Cat directory</b>}
        {selectedCat ? (
          <small>The detail hero is at the top; your scroll position was preserved</small>
        ) : (
          <small>Try the first card, then scroll down and try the last one</small>
        )}
      </header>

      {selectedCat ? (
        <article className="cat-detail">
          <div className="detail-header">
            <div>
              <p>Featured cat</p>
              <h1>{selectedCat.name}</h1>
            </div>
            <React.ViewTransition name={`cat-image-${selectedCat.id}`}>
              <img src={selectedCat.image} alt={selectedCat.name} className="detail-image" />
            </React.ViewTransition>
          </div>
          <div className="detail-copy">
            <h2>About {selectedCat.name}</h2>
            <p>
              This detail page is intentionally tall, like a real product or profile page. That lets
              it preserve the listing's scroll position during navigation.
            </p>
          </div>
        </article>
      ) : (
        <ul className="cat-list">
          {cats.map((cat) => (
            <li key={cat.id}>
              <button className="cat-card" onClick={() => openCat(cat.id)}>
                <React.ViewTransition name={`cat-image-${cat.id}`}>
                  <img src={cat.image} alt="" />
                </React.ViewTransition>
                <span>
                  <b>{cat.name}</b>
                  <small>View profile →</small>
                </span>
              </button>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
};

const cats = [
  {
    id: 'meow',
    name: 'Meow',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png',
  },
  {
    id: 'angie',
    name: 'Angie',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/angry_cat.png',
  },
  {
    id: 'milky',
    name: 'Milky',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/milky.png',
  },
  {
    id: 'spinner',
    name: 'Spinner',
    image:
      'https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/spinning_cat.png',
  },
];

createRoot(document.getElementById('root')).render(<App />);

This viewport check applies specifically to a named share transition where one component is removed and another is inserted. If the same component instance changes position, React classifies it as an update, and it can still animate even when one position is outside the viewport. See the ViewTransition documentation for the full caveat.

Using View Transition API with React Router

Since React Router version 6.27.0, using View Transition API requires only adding viewTransition prop to Link component. Note that the View Transition API support of React Router is not based on the React’s ViewTransition component, therefore you can use it today as long as your React Router version is 6.27.0 or above.

import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { useNavigate, createBrowserRouter, Link, RouterProvider } from 'react-router-dom';

const TopBar = ({ link, rightContent }) => (
  <div className="top-bar">
    <div className="top-bar-content">
      <h1>Move Cat</h1>
      {link}
    </div>
    {rightContent}
  </div>
);

const router = createBrowserRouter([
  {
    index: true,
    element: (
      <div>
        <TopBar
          link={
            <Link to="/details" viewTransition>
              Details
            </Link>
          }
          rightContent={
            <img
              src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_300/v1678947391/malcolm-kee/meow_dtsn8h.png"
              alt="cat"
              className="cat-img thumbnail"
            />
          }
        />
      </div>
    ),
  },
  {
    path: '/details',
    element: (
      <div>
        <TopBar
          link={
            <Link to="/" viewTransition>
              Home
            </Link>
          }
        />
        <div className="cat-details">
          <img
            src="https://res.cloudinary.com/djzsjzasg/image/upload/c_scale,w_500/v1678947391/malcolm-kee/meow_dtsn8h.png"
            alt="cat"
            className="cat-img detailed-img"
          />
          <div className="cat-desc">
            <h2>Cat Details</h2>
          </div>
        </div>
      </div>
    ),
  },
]);

createRoot(document.getElementById('root')).render(<RouterProvider router={router} />);

Practical Use Cases

Other than transitioning between pages, some practical use cases of the View Transition API are:

  1. Animating an element when open modal: When a user opens/closes a modal, transition the button to the entire modal to produce an expansion effect.

  2. Animating focus ring for onboarding overlay: When navigating between steps, the focus ring and the popup modal can be moved with animation.

  3. Tab active indicator animation: When switching tab, animating the active indicator used to require manual computation by considering the position of the previous active tab and the new active tab. With View Transition API we can delegates to browser to perform the computation.

    Some content here

Using Progressive Enhancement Technique with View Transition

To ensure that your application still functions properly on browsers that do not support the View Transition API, you can use the progressive enhancement technique. This involves checking whether document.startViewTransition is available before using it. If it’s not available, then no animation will be applied.

// create a wrapper to be used everywhere in your application
const transitionViewIfSupported = (updateCb) => {
  if (document.startViewTransition) {
    document.startViewTransition(updateCb);
  } else {
    updateCb();
  }
};

Conclusion

In conclusion, the View Transition API offers a straightforward way to create smooth animations for web applications. By implementing it in your React projects and using progressive enhancement, you can cover a wider range of browsers while enhancing user experience. Give it a try and see the difference it makes!

Acknowledgements

Thanks Jake Archibald for his works on designing and championing this API, and also being extremely patient for all my questions about the API.

Thanks for reading!

Love what you're reading? Sign up for my newsletter and stay up-to-date with my latest contents and projects.

    I won't send you spam or use it for other purposes.

    Unsubscribe at any time.