React Router and View Transitions

  • react
  • react-router
  • view transitions

Container transform

Recently I wanted to implement something like what Material UI calls the container transform pattern. From the Material 3 docs:

This pattern is used to seamlessly transform an element to show more detail, like a card expanding into a details page. […] Persistent elements are used to seamlessly connect the start and end state of the transition.

Here’s a simple example in a GIF

An example from the Material UI documentation

The pattern isn’t unique to Material or Google, it’s used for example in iOS when opening an app from the home screen. I can’t speak on how this is implemented on mobile, but on the web the implementation has historically been complicated by the variety of layout rules that need to be taken into account (Cassie Evans has an excellent talk about the difficulties involved here).

FLIP

The solution that folks seem to have landed on for this is the FLIP technique. It’s a bit of a magic trick that involves moving the element to its final state and then applying a transform so that we can efficiently animate from the initial state.

… [I]nstead of animating “straight ahead” and potentially doing expensive calculations on every single frame we precalculate the animation dynamically and let it play out cheaply.

There are some libraries for implementing this, like GSAP or Flipping.js. In React, framer/motion provides a high-level API for using this technique to achieve “shared layout animations” with its LayoutGroup component.

View Transitions API

But there’s also a new high-level API coming to the browser, in the View Transitions API. This is still a draft specification at time of writing, but it’s landed in Chrome. There’s a nice write-up by the Chrome team that serves as a tutorial for using in a framework-less SPA. Getting this to work in a React app where we don’t normally manage DOM updates (especially for route transitions) is a bit trickier.

A simple demo using React Router

After a bit of trial-and-error I got a demo working using animated transitions between routes with React Router. Here’s the repo: https://github.com/ptrfrncsmrph/react-view-transitions-api.

Some rough edges

startViewTransition takes a callback that synchronously updates the DOM. The only way I could figure to do so was using React Router’s useNavigate and a button with a click handler instead of Link 😔. We then need to wrap the call to navigate in flushSync to force the synchronous update.

document.startViewTransition(() => {
  ReactDOM.flushSync(() => {
    navigate(nextRoute);
  });
});

The React docs warn that flushSync should be used as a “last resort”, and this API does seem to be at odds with the React mental model that doesn’t normally care about when DOM updates happen.

Another awkward bit is the need to toggle the viewTransitionNames for transitioning elements.

if (ref.current) {
  ref.current.style.viewTransitionName = "movie-image";
}

There needs to be exactly one element with the "movie-image" transition name at any given time, so the recommendation seems to be to assign the tag name in the event handler.

A nicer alternative might be to give unique names to each element, like movie-image-${movie.id} and then select pairs with ::view-transition-group(movie-image-*) but that syntax doesn’t exist and as far as I can tell the only way of achieving this currently would require creating just as many rules in the style sheet as there are pairs of elements you’d want to target. I started going down that road but couldn’t get it to work (the transitions did apply but looked janky for reasons I couldn’t understand). (I think this is also the approach one of the authors of the spec tried in this repo but seems like it had complications: https://github.com/jakearchibald/wordle-analyzer/pull/19)

This unique name constraint makes “back navigation” transitions (from detail to list view) messy. The event handler doesn’t have direct access to what we want to target as the view-transition-new element, so we need to find it in the DOM, assign the transition name, wait for the transition to complete, and finally remove the name (so it can be reassigned to the next item that gets clicked).