Cross-Route Element Morphing
Cross-route element morphing preserves user spatial context by animating a shared element β a card image, product thumbnail, or article hero β from its old position and size to its new position and size as a route changes. Rather than a full-page crossfade, the browser appears to physically carry the element across the screen. This is the flagship feature of SPA page-swap animations and is solved by pairing view-transition-name values on the source and destination elements inside document.startViewTransition(). For the complete API context, see Scroll-Driven & View Transition Implementation Patterns.
Pages in this section
- Implementing View Transitions for React Router β wrapping
router.navigate()instartViewTransitionwith view-transition-name scoping per list item
How the browser pairs shared elements
When document.startViewTransition() fires, the browser snapshots the old DOM, runs your update callback, snapshots the new DOM, then matches every view-transition-name that appears in both snapshots. Each matched pair gets a ::view-transition-group(<name>) that interpolates the bounding-box geometry and a ::view-transition-image-pair(<name>) containing the captured bitmaps.
The ::view-transition-group translates and scales between the two bounding boxes automatically. You only need CSS to style the bitmaps inside ::view-transition-old and ::view-transition-new.
Syntax reference
| Property / pseudo | Role | Default |
|---|---|---|
view-transition-name: <custom-ident> |
Names this element as a morph participant | none |
::view-transition-group(<name>) |
Wrapper that interpolates position and size | browser-managed |
::view-transition-image-pair(<name>) |
Contains the two captured bitmaps | isolation: isolate |
::view-transition-old(<name>) |
Bitmap of the element on the outgoing route | animation: fade-out 0.25s ease |
::view-transition-new(<name>) |
Bitmap of the element on the incoming route | animation: fade-in 0.25s ease |
view-transition-class (Chrome 125+) |
Apply one rule to multiple named elements | none |
A view-transition-name must be unique in the DOM at the moment the transition fires. Duplicate names within the same snapshot cause the browser to silently fall back to a root crossfade for those elements.
Minimal working example
<!-- Route A: list view -->
<article class="card" id="card-42">
<img src="/img/42.jpg" alt="Product 42" class="card__thumb">
<h2>Product 42</h2>
</article>
/* Route A */
#card-42 .card__thumb {
view-transition-name: product-hero-42;
contain: layout; /* required alongside view-transition-name */
}
/* Route B */
.detail-hero[data-id="42"] {
view-transition-name: product-hero-42;
contain: layout;
}
/* Override default crossfade β morph the new element in from scale 0.9 */
@keyframes hero-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes hero-out {
from { opacity: 1; }
to { opacity: 0; }
}
::view-transition-new(product-hero-42) {
animation: hero-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
::view-transition-old(product-hero-42) {
animation: hero-out 0.35s ease-in forwards;
}
async function openDetail(id) {
if (!document.startViewTransition) {
// Instant swap β no morph
renderDetailRoute(id);
return;
}
// Read geometry before the transition to avoid forced reflow inside the callback
const card = document.getElementById(`card-${id}`);
card.getBoundingClientRect(); // warm the layout cache
const transition = document.startViewTransition(() => {
renderDetailRoute(id); // synchronous DOM update
});
await transition.finished;
}
The callback passed to startViewTransition must be synchronous or return a promise that resolves only after the DOM reflects the new route. Async rendering (React batching, Vue nextTick) must be flushed before the promise resolves.
Timeline scoping: pairing morphs with scroll position
Morphs can be gated on scroll progress β useful when a card enters the viewport before the user has scrolled to it and you want the transition to feel anchored to that scroll event. Bind the pseudo-element to a scroll-driven animation-range using a named scroll-timeline:
/* Morph the new element as it enters the viewport */
::view-transition-new(product-hero-42) {
animation: hero-in linear;
animation-timeline: scroll(root);
animation-range: entry 0% cover 40%;
}
The scroll container must have a defined height and overflow: auto or overflow: scroll. If the container has overflow: hidden or no scrollable content, the scroll() timeline resolves to zero length and the animation freezes at its from keyframe.
For named scroll timelines scoped to a specific container:
.card-list {
scroll-timeline: --card-list block;
overflow-y: scroll;
}
::view-transition-new(product-hero-42) {
animation: hero-in linear;
animation-timeline: --card-list;
animation-range: cover 20% cover 60%;
}
Compositor-safe properties
Keeping the morph on the compositor thread β away from the rendering pipelineβs style and layout phases β is what gives view transitions their performance edge. The table below shows which properties are safe and which cause forced recalculation:
| Property | Runs on compositor | Notes |
|---|---|---|
transform (translate, scale, rotate) |
Yes | Preferred for all position/size morphs |
opacity |
Yes | Safe for fade-in/out of old/new bitmaps |
filter (blur, brightness) |
Yes (most cases) | Check DevTools Layers panel |
clip-path (simple shapes) |
Partial | Complex paths may fall to main thread |
width, height |
No | Forces layout recalc every frame |
top, left, margin |
No | Forces layout recalc every frame |
border-radius on ::view-transition-group |
No | Triggers paint; use it sparingly |
background-color |
No | Forces paint; use a wrapper element instead |
::view-transition-group(product-hero-42) {
/* Promote group to its own compositor layer */
will-change: transform, opacity;
backface-visibility: hidden;
/* Do NOT use contain: strict here β it breaks the automatic
bounding-box interpolation the browser performs on the group */
}
Common implementation patterns
Pattern 1 β List-to-detail card morph
The most common pattern: assign a name derived from a record ID so each card gets a unique name without conflicts.
function bindCardNames(cards) {
cards.forEach(card => {
// Assign at render time β remove after the transition to avoid
// duplicate names if multiple cards are on screen simultaneously
card.style.viewTransitionName = `card-${card.dataset.id}`;
});
}
async function openCard(id) {
// Remove names from all other cards before the transition fires
document.querySelectorAll('.card').forEach(el => {
el.style.viewTransitionName = 'none';
});
// Re-assign only the clicked card
document.querySelector(`#card-${id}`).style.viewTransitionName = `card-${id}`;
await document.startViewTransition(() => renderDetail(id)).finished;
// Clean up to avoid name collisions on the next navigation
document.querySelector('.detail-hero').style.viewTransitionName = 'none';
}
Pattern 2 β Image expansion (thumbnail to full-bleed)
When the element changes aspect ratio between routes, ::view-transition-group interpolates the bounding box, but the captured bitmap stretches inside it. Override object-fit to control how the bitmap fills the interpolated box:
::view-transition-old(product-hero-42),
::view-transition-new(product-hero-42) {
object-fit: cover;
object-position: center top;
}
Pattern 3 β Multi-element morph
Morph several elements simultaneously by assigning distinct names to each. Keep names isolated to the transition by setting them in JavaScript immediately before startViewTransition and removing them in .finished:
async function navigateToProfile(userId) {
const avatar = document.querySelector(`[data-user="${userId}"] .avatar`);
const name = document.querySelector(`[data-user="${userId}"] .username`);
avatar.style.viewTransitionName = 'profile-avatar';
name.style.viewTransitionName = 'profile-name';
await document.startViewTransition(() => renderProfile(userId)).finished;
// Names persist on the new route's elements β clear them
document.querySelector('.profile-avatar').style.viewTransitionName = 'none';
document.querySelector('.profile-name').style.viewTransitionName = 'none';
}
Pattern 4 β Class-based batching (Chrome 125+)
view-transition-class lets a single ::view-transition-group() rule target many elements without listing each name:
/* Assign the class in CSS */
.card__thumb {
view-transition-class: card-thumb;
view-transition-name: var(--card-vt-name); /* still must be unique per element */
}
/* Target all card-thumb groups with one rule */
::view-transition-group(.card-thumb) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
Browser support and @supports guard
| Feature | Chrome | Edge | Safari | Firefox |
|---|---|---|---|---|
document.startViewTransition |
111 (Mar 2023) | 111 | 18.0 (Sep 2024) | No (flag in 130) |
view-transition-name CSS |
111 | 111 | 18.0 | No |
Cross-document (@view-transition) |
126 | 126 | 18.2 | No |
view-transition-class |
125 | 125 | No | No |
| Scroll-timeline on pseudo-elements | 115 | 115 | 17.4 | No |
Guard morphing CSS with both an API check (in JavaScript) and a CSS feature query:
/* Opt-in morphing only when the API is available */
@supports (view-transition-name: none) {
#card-42 .card__thumb {
view-transition-name: product-hero-42;
contain: layout;
}
}
async function openDetail(id) {
if (!document.startViewTransition) {
renderDetailRoute(id); // instant, no morph
return;
}
const detailHero = document.querySelector(`.detail-hero[data-id="${id}"]`);
if (detailHero) {
detailHero.style.viewTransitionName = `product-hero-${id}`;
}
await document.startViewTransition(() => renderDetailRoute(id)).finished;
}
For browser-support progressive enhancement across older Safari and all Firefox, render the route change immediately when document.startViewTransition is undefined β the user gets a functional navigation with no animation.
Gotchas and failure modes
-
Duplicate names in the old or new snapshot. If two elements share a
view-transition-namein the same DOM snapshot, the browser falls back to a root crossfade for both β no morph, no console error. Always clear names before the transition fires if multiple instances of the same component exist on the page. -
contain: stricton::view-transition-group. Settingcontain: strictorcontain: layout sizeon the group pseudo-element prevents the browser from performing its automatic bounding-box interpolation. Usecontain: paintif paint isolation is needed, and do not set size or layout containment on the group. -
Async callback not awaited. If the
startViewTransitioncallback isasyncand returns before the new route is fully painted, the browser captures the new snapshot mid-render. The target element may be absent, resulting in a fade with no morph. Always await all async operations (data fetches,nextTickflushes) before resolving the callback promise. -
will-changepersisting after the transition. Leavingwill-change: transformon a large element aftertransition.finishedkeeps it on its own compositor layer indefinitely, consuming GPU memory. Remove it in the.finishedhandler. -
overflow: hiddenon an ancestor. Aview-transition-nameelement that is clipped by anoverflow: hiddenancestor is captured with the clip applied. During the morph, the bounding box is interpolated correctly but the bitmap shows the clipped state, producing a visible jump when the clip boundary changes. Temporarily remove the clip during the transition or restructure the layout. -
Missing morph target on the destination route. If the destination does not render the target element (conditional rendering, a route that lacks a matching detail component), the browser produces a
::view-transition-newpseudo-element with an empty bitmap β a βfade to nothingβ artifact. Check for the elementβs presence before assigning the name, or guard withview-transition-name: noneas the fallback.
Performance checklist
- Animate only
transformandopacityinside::view-transition-old/new. Do not animatewidth,height,top, orleft. - Set
will-change: transform, opacityon::view-transition-groupduring the transition; remove it intransition.finished. - Read element geometry (
getBoundingClientRect()) before callingstartViewTransition, not inside the callback, to avoid forced synchronous layout. - Remove
view-transition-namefrom elements that are not participating in the current morph. Extra named elements increase bitmap capture cost. - Prefer
object-fit: coveron the::view-transition-old/newpseudo-elements when the aspect ratio changes between routes. - Open DevTools Performance panel before the morph, record the navigation, and check that no
LayoutorStyle Recalcspikes appear during the morph window. - In the Rendering pane, enable Layer borders and Paint flashing β the morphing element should not flash and should remain on its own layer.
- In the Animations panel, step through the
view-transitiongroup frame-by-frame to verify that the bounding-box geometry interpolates smoothly. - Check the Memory tab after
transition.finishedfor retained GPU bitmaps; each unreleased bitmap is a leak.
Post-transition accessibility
After morphing completes, restore keyboard focus to the primary content of the incoming route. Without this, focus stays on the element that triggered the navigation β typically a card in the list β which is no longer in view:
const transition = document.startViewTransition(() => renderDetailRoute(id));
await transition.finished;
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
heading.removeAttribute('tabindex');
}
Respect prefers-reduced-motion by skipping the morph rather than just shortening it. Vestibular users can be harmed by large-scale motion even at reduced duration. See implementing prefers-reduced-motion for the full pattern:
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const t = document.startViewTransition(() => renderDetailRoute(id));
if (prefersReduced) t.skipTransition();
await t.finished;
Related
- SPA Page-Swap Animations β the full
startViewTransitionsetup and route-change integration - Implementing View Transitions for React Router β React-specific wiring
- How View Transition Works Under the Hood β pseudo-element tree, snapshot timing, and compositor capture
- Implementing prefers-reduced-motion β safe patterns for vestibular and motion-sensitive users
- Scroll-Driven & View Transition Implementation Patterns β parent section