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:

Naming the element the reader is actually looking at A grid of cards is scrolled so three are on screen. Assigning the shared name to a fixed card morphs from whatever is at that index. Assigning it at click time to the activated card morphs from what the reader touched, which is the only version that reads as continuous. six cards, three on screen, one clicked card 1 — off screen card 2 — off screen card 3 card 4 — clicked name: hero card 5 card 6 Name assigned in the stylesheet always card 1 — the morph starts from an element the reader never looked at Name assigned on activation set on the clicked card, cleared afterwards the morph starts where the reader's eye is Clear the name once the transition finishes, or the next navigation finds two elements claiming it.
/* 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.
Aspect-ratio mismatch in a morph A square thumbnail morphing into a wide banner. Because the group interpolates width and height independently while the image inside keeps its own ratio, the midpoint frame shows a stretched image unless object-fit cover and a matched ratio are set on both sides. 1:1 thumb midpoint — stretched the image is squashed here 16:5 banner correct again at the end only the endpoints look right — the frames between are where the bug lives object-fit: cover on the image, plus a group animation that scales rather than stretches Give both sides the same aspect-ratio where the design allows it; where it does not, animate a wrapper and let the image inside stay a fixed ratio.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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;

Frequently Asked Questions

Why did the morph turn into a plain crossfade?

Four conditions have to hold and the first one that fails produces exactly this symptom. Both the outgoing and incoming element must carry the same view-transition-name; no two rendered elements may share that name at capture time; the element must actually be rendered, not display: none or inside a hidden ancestor; and it must not be fragmented across columns or pages.

Work down that list in order. In practice the second condition — a name applied by a class that matches more than one element — accounts for most cases, and it is the one with no console output at all.

Should names be assigned in CSS or at interaction time?

At interaction time, for anything that repeats. A name written into a stylesheet for .card applies to every card, which both violates uniqueness and means the morph starts from whichever card the browser happened to pick rather than the one the reader clicked. Assigning the name to the activated element on click, and clearing it once the transition settles, gives a morph that begins where the reader’s attention already is.

For genuinely singular elements — a page header, a hero image that appears once — a static assignment is fine and simpler.

How do I stop the image squashing halfway through?

The group interpolates width and height independently, so an element changing aspect ratio passes through intermediate shapes that neither endpoint has. If the content inside is an image, object-fit: cover keeps it filling the box without distorting. If it is a composed layout, animating a wrapper and keeping the inner content at a fixed ratio avoids the intermediate states entirely.

Testing this requires slowing the transition down — at normal speed the distorted frames pass too quickly to diagnose but not quickly enough to go unnoticed.

What happens to focus and screen-reader announcements?

Nothing, unless you handle it. The transition is a visual effect layered over a DOM change; it does not move focus, does not announce anything, and does not delay assistive technology. After the DOM update, focus should move to a sensible landing point — usually the destination’s heading — and the change should be announced if the surrounding context does not already make it obvious.

Doing this after the transition completes rather than before avoids announcing a state the user cannot yet see.

Does the morph work when the two routes are separate documents?

Only where cross-document transitions are supported, and the mechanics differ. Same-document morphs capture both states inside one document; cross-document morphs capture in the outgoing document, discard it, and complete once the incoming document reaches first render. The shared name has to exist in both documents, which means it must be present in the server-rendered markup rather than applied by script after hydration.

The practical consequence is that the transition’s perceived speed becomes a server and network question rather than a CSS one. A morph that looks instant in a client-side router can look sluggish across documents purely because the second page takes longer to render its first frame.

How do I test a morph that only misbehaves for a few frames?

Slow it down. Setting the transition’s playback rate low in the Animations drawer, or temporarily giving the pseudo-element animations a multi-second duration, turns a defect that flickers past into one you can look at. Aspect-ratio squash, a mismatched corner radius, and text resampling are all invisible at 300 milliseconds and obvious at three seconds.

Capture the slowed version as a recording rather than describing it — a morph defect is much easier to agree on when the whole team is watching the same frame.