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.
Step-by-step resolution
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.
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.
Related
- View Transition Types & Nested Groups — the parent guide on nesting, classes and types
- View Transitions API Implementation — the lifecycle and pseudo-element tree this builds on
- Choosing View Transition Types for Route Direction — supplying the direction alongside the morph type