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
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.
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(() => {
.flushSync(() => {
ReactDOMnavigate(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
viewTransitionName
s for transitioning elements.
if (ref.current) {
.current.style.viewTransitionName = "movie-image";
ref }
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).