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

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.

View transition pseudo-element tree for shared-hero The browser generates ::view-transition at the root, then ::view-transition-group(shared-hero) which contains ::view-transition-image-pair(shared-hero), which in turn contains ::view-transition-old(shared-hero) on the left and ::view-transition-new(shared-hero) on the right. ::view-transition ::view-transition-group(shared-hero) ::view-transition-image-pair(shared-hero) ::view-transition-old(shared-hero) ::view-transition-new(shared-hero) snapshot of route A element snapshot of route B element geometry interpolated by browser

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

  1. Duplicate names in the old or new snapshot. If two elements share a view-transition-name in 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.

  2. contain: strict on ::view-transition-group. Setting contain: strict or contain: layout size on the group pseudo-element prevents the browser from performing its automatic bounding-box interpolation. Use contain: paint if paint isolation is needed, and do not set size or layout containment on the group.

  3. Async callback not awaited. If the startViewTransition callback is async and 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, nextTick flushes) before resolving the callback promise.

  4. will-change persisting after the transition. Leaving will-change: transform on a large element after transition.finished keeps it on its own compositor layer indefinitely, consuming GPU memory. Remove it in the .finished handler.

  5. overflow: hidden on an ancestor. A view-transition-name element that is clipped by an overflow: hidden ancestor 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.

  6. 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-new pseudo-element with an empty bitmap β€” a β€œfade to nothing” artifact. Check for the element’s presence before assigning the name, or guard with view-transition-name: none as the fallback.

Performance checklist

  • Animate only transform and opacity inside ::view-transition-old/new. Do not animate width, height, top, or left.
  • Set will-change: transform, opacity on ::view-transition-group during the transition; remove it in transition.finished.
  • Read element geometry (getBoundingClientRect()) before calling startViewTransition, not inside the callback, to avoid forced synchronous layout.
  • Remove view-transition-name from elements that are not participating in the current morph. Extra named elements increase bitmap capture cost.
  • Prefer object-fit: cover on the ::view-transition-old/new pseudo-elements when the aspect ratio changes between routes.
  • Open DevTools Performance panel before the morph, record the navigation, and check that no Layout or Style Recalc spikes 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-transition group frame-by-frame to verify that the bounding-box geometry interpolates smoothly.
  • Check the Memory tab after transition.finished for 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;