Shared Layout

When a tab indicator jumps from one tab to the next, your eye has to find it again. When it slides, you never lose it. A shared layout animation treats the indicator as one element that moves, even if in the DOM it is two elements that swap.

Click the tabs below. Both rows update together.

Wrong
Right

On the left, the highlight disappears from one tab and appears under another. On the right, it travels. The second version tells you two things at once: which tab is active now, and where it came from. That is the whole value of the technique. Continuity is information.

Under the hood this is FLIP. Motion measures where the element was, where it is now, and animates the difference with a transform. You give two elements the same layoutId, and when one unmounts as the other mounts, Motion plays the move.

From list to detail

The same idea carries a lot further than tabs. A thumbnail in a list can become the header of a detail view. A title in a row can become the title of a page. Tap a project below, then switch to swap mode and try again.

With a shared layout, the thumbnail and title stay put in your mind while everything around them changes. With a plain swap, the whole view is replaced and you have to re-read it to confirm you landed in the right place.

Keep the settle short. A spring with no bounce that lands in under 300ms feels responsive. Anything longer starts to look like the interface is showing off.

Where it goes wrong

Shared layout animations scale elements to move them, and scaling distorts border radius and shadows. Motion corrects borderRadius when you set it through style rather than a class. Text can also stretch briefly when a container changes shape, which is why the title above uses layout="position": it moves, but never scales.

Also be careful about what you share. Two elements with the same layoutId should be the same thing to the user. Sharing a card's thumbnail with a detail header makes sense. Sharing a button with an unrelated panel just looks like a glitch.

Usage

app.tsx
import { motion } from "motion/react";

{tabs.map((tab) => (
  <button key={tab} onClick={() => setActive(tab)}>
    {tab === active && (
      <motion.span
        className="absolute inset-0 rounded-md bg-card"
        layoutId="indicator"
        transition={{ type: "spring", duration: 0.3, bounce: 0 }}
      />
    )}
    <span className="relative">{tab}</span>
  </button>
))}
/* Give the element the same name in both states. */
.thumbnail,
.detail-header {
  view-transition-name: project-thumb;
}

::view-transition-group(project-thumb) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}

The browser has a native version of this now. Wrap a state change in document.startViewTransition() and any element with a matching view-transition-name morphs on its own, across routes too. It is worth reaching for first when you do not need Motion for anything else.

Resources

Search concepts

Search for a command to run...