Morphing a List Item into a Detail View

Part of Transition Types & Nested Groups in Modern View Transitions & Scroll APIs.

The problem

A shared-element morph is the effect that makes a view transition feel like a native application: tap a card in a grid, and the card itself grows into the detail page rather than the page crossfading to a new layout. Done well, it tells the user exactly which object they opened.

Done carelessly it produces the “shattered morph” — the card grows, but its image detaches and flies on its own diagonal, the title stretches into unreadable intermediate frames, and everything reassembles at the end. Every part of that is fixable, and the fixes are structural rather than a matter of tuning durations.

Root cause: a flat snapshot tree animates every name independently

By default each view-transition-name produces a group directly under the transition root. Groups are siblings, so each one interpolates its own position, size and transform from its old rectangle to its new one — independently, in parallel, with no relationship between them.

For a card and its image that is exactly wrong. The image is inside the card; it should be carried by the card’s transform, not compute its own path. Because the two rectangles change by different ratios, the independent paths diverge in the middle of the transition even though they agree at both ends. That divergence is the shatter.

Nesting fixes the structure. Declaring view-transition-group: contain on a named child places its group inside the parent’s group, so the child inherits the parent’s transform and clipping. The child still gets its own crossfade between old and new snapshots, but it travels with its container.

Text needs a second consideration. A group that scales carries its text at the same ratio, so a title that grows from card size to hero size is rendered at every intermediate size — briefly blurry, and at large ratios uncomfortable. Naming the text and animating only its opacity cross-fades the two copies at their natural sizes instead.

Flat groups against nested groupsThe endpoints are identical in both cases; only the path between them differs.Flat groups against nested groupsAll groups at the rootEach part computes its own pathImage and card diverge mid-transitionOverflowing content is not clippedReads as several objects arrivingThe shattered morphChildren nested in the card groupChildren inherit the containertransformParts stay in place relative to thecardParent clipping applies throughoutReads as one object growingA coherent morph
The endpoints are identical in both cases; only the path between them differs.

Step-by-step resolution

Building the morphNaming is the part to get right; the animation itself is largely automatic.Building the morph1Derive one name per item from its identifier and apply it to the card justbefore navigating.Only one element carries the name at capture2Apply the same name to the detail hero as the new DOM is built.The browser can correlate the two rectangles3Name the image and the title, and set view-transition-group: contain onboth.They travel inside the card group4Give the title group an opacity-only animation.No stretched intermediate text5Clear every name when the finished promise settles.The next transition starts from a clean tree
Naming is the part to get right; the animation itself is largely automatic.

Production code pattern

// Intent: name only the pair that morphs, and only for as long as it takes.
async function openDetail(card, id) {
  const hero = () => document.querySelector('[data-detail-hero]');

  card.style.viewTransitionName = `item-${id}`;
  card.querySelector('[data-media]').style.viewTransitionName = `media-${id}`;
  card.querySelector('[data-title]').style.viewTransitionName = `title-${id}`;

  const transition = document.startViewTransition({
    update: async () => {
      await renderDetail(id);
      // The destination elements take the same names as the source pair.
      hero().style.viewTransitionName = `item-${id}`;
      hero().querySelector('[data-media]').style.viewTransitionName = `media-${id}`;
      hero().querySelector('[data-title]').style.viewTransitionName = `title-${id}`;
    },
    types: ['forward', 'morph'],
  });

  try {
    await transition.finished;
  } finally {
    // Names must not survive into the next transition.
    document.querySelectorAll('[style*="view-transition-name"]')
      .forEach((el) => { el.style.viewTransitionName = ''; });
  }
}
/* Intent: nest the parts that belong to the card, and cross-fade the text. */
[data-media] { view-transition-group: contain; view-transition-class: morph-part; }
[data-title] { view-transition-group: contain; view-transition-class: morph-part; }

::view-transition-group(.morph-part) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

/* Text cross-fades at its natural size instead of being scaled. */
html:active-view-transition-type(morph) {
  &::view-transition-old(.morph-part),
  &::view-transition-new(.morph-part) { animation-duration: 200ms; }
}

/* Accessibility gate: no growth, no travel — just a short crossfade. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) { animation-duration: 1ms; }
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: 120ms linear both fade; }
  @keyframes fade { from { opacity: 0; } }
}

Rendering Impact: composite only. Nesting changes the shape of the snapshot tree rather than the amount of work per frame; the child inherits a transform instead of computing an independent one.

What to name, and how to treat itName the minimum: every extra name is another group to reconcile at capture time.What to name, and how to treat itName it?Nested?AnimateThe card / herocontainerYesRoot groupPosition and sizeMedia inside thecardYesContainInherited transformTitle inside thecardYesContainOpacity onlyBody copy on thedetail pageNoRoot crossfadeOther cards inthe gridNoRoot crossfadeFixed headerOptionalSiblingHold in place
Name the minimum: every extra name is another group to reconcile at capture time.

Verification checklist

Constraints and trade-offs

  • Nested children are clipped by their parent group, so anything that must escape the card during the morph has to stay a sibling.
  • Every named element is a group to reconcile; naming a whole grid instead of one card measurably slows the capture.
  • Names set as inline styles are easy to clear but fight framework reconciliation; a data attribute plus a stylesheet rule is more robust in a re-rendering view.
  • If the detail page loads asynchronously, the callback’s await extends the frozen frame — prefetch before starting the transition.
  • A morph implies the two things are the same object; using one where the destination is unrelated content is actively misleading.

Frequently asked questions

Why does my morph capture the wrong card?

Because more than one element carried the name at capture time, or the name was left attached from a previous transition. Assign names immediately before starting the transition, derive them from the item identifier, and clear them when the finished promise settles.

Should the thumbnail be a nested group or a sibling?

Nested if it stays inside the card as the card becomes the panel — that way it inherits the container’s transform and clipping. A sibling only if it travels somewhere the container is not going, such as into a fixed header.

How do I stop text stretching during the morph?

Give the text element its own name and animate only its opacity, cross-fading between the old and new copies. A group that scales carries its text with it, and scaled text is briefly unreadable.


Reversing the morph on the way back

A morph that only works in one direction is half a feature, and the return journey has one structural difference worth planning for: the destination is a list that may have been re-rendered, re-sorted or paginated since the user left it.

The naming has to survive that. Deriving the name from the item identifier rather than from its index is what makes the return work when the list has changed — item a91f is still item a91f even if it is now third rather than first. An index-derived name morphs the detail view into whatever happens to be in that position, which is worse than no morph at all because it asserts a relationship that does not exist.

The second consideration is scroll position. If the list was scrolled when the user opened the detail, the card they came from may be off-screen on the way back, and a morph into an off-screen element produces a snapshot that flies away to nowhere. Restoring scroll inside the update callback, before the after-snapshot is captured, puts the card back where it was and lets the morph land.

Finally, if the item genuinely no longer exists — deleted, filtered out, on another page — clear the name and let the transition fall back to a crossfade. A morph that has nowhere to go should degrade rather than guess, and the crossfade reads as a normal navigation instead of as a broken animation.