view-transition-name and Shared Elements: Morphing a Thumbnail into a Hero

The interaction is familiar from native apps: tap a small square in a list and it expands into the full-width image at the top of a detail view, carrying the eye with it. Done with JavaScript this needs measurement of both rectangles, a cloned node positioned over the page, a FLIP calculation, and cleanup. Done with view-transition-name it is one declaration on each of the two elements. This page, part of View Transitions for CSS Developers, covers how that matching works and the one constraint that shapes every real implementation of it.

The scenario is narrow and specific: a grid of thumbnails, one of which becomes the hero of the view that replaces the grid. Everything below follows from that.


Why a name rather than a selector

The property does not describe an animation. It assigns an identity. When the browser captures the before state it records "there was a box called hero-7 at these coordinates with this content"; when it captures the after state it records the same for whatever box carries that name then. Matching is by string, and by string alone.

That has an immediate and slightly surprising consequence: the two elements need have nothing in common. A <img> inside an <li> can be matched with a <figure> inside a <header>. Different tags, different ancestors, different stylesheets. The browser is not tracking a node across the change — the old node may well have been destroyed. It is tracking a label.

This is why the approach beats a scripted equivalent. A FLIP animation has to hold a reference to a real element, which forces the old and new views to coexist in the DOM for at least a frame, which in turn forces you to manage two competing layouts. Naming sidesteps all of it: the old element can be gone entirely by the time the new one is measured.

The tradeoff is that identity is now your responsibility. There is no :has()-style inference, no automatic pairing by position. If you get a name wrong, the elements do not morph — they cross-fade like everything else, silently.


The uniqueness constraint

A view-transition-name must be unique among all elements captured in a single snapshot. Not unique per page, not unique per component — unique per capture. If two rendered elements carry the same name at the moment the browser captures, the transition is skipped entirely. It does not fall back to a cross-fade, and it does not choose one arbitrarily; the whole operation aborts and the DOM change happens with no animation.

This is easy to violate without noticing, because the natural first instinct is to write a component stylesheet:

/* Wrong: every card in the grid now claims the same identity. */
.card__image {
  view-transition-name: hero;
}

With one card on screen this works beautifully. With twelve it silently does nothing, which makes it a nasty bug to catch in review — the code looks correct and the failure is an absence.

Matching one name across two captures The before capture holds three thumbnails of which one carries a unique name; the after capture holds a single hero box with the same name, and the two are matched. One unique name, matched across two captures before capture after capture thumb (no name) view-transition-name: hero-7 thumb (no name) hero image hero-7 match A duplicate name in either capture aborts the whole transition. Unnamed thumbnails travel inside the root snapshot instead.

The demo runs the group animation this pairing produces — a box interpolating position and size between the thumbnail rectangle and the hero rectangle.

Live demoThe morph a matched view-transition-name produces
What the group animation looks like when a thumbnail and a hero share one view-transition-name: the box travels and resizes between the two measured rectangles. Reproduced with keyframes — no transition is actually running.

Complete working implementation

The pattern that survives contact with real data: names are derived from record identifiers, applied only to the item being acted on, and cleared afterwards so the next interaction starts from a clean slate.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Thumbnail to hero morph</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 2rem; }

  .grid {
    list-style: none; margin: 0; padding: 0;
    display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;
  }
  .thumb {
    width: 100%; aspect-ratio: 1; object-fit: cover;
    border-radius: 10px; display: block; cursor: pointer;
  }

  .detail { display: none; }
  .detail[data-open] { display: block; }
  .grid[data-hidden] { display: none; }

  .hero {
    width: 100%; aspect-ratio: 16 / 9; object-fit: cover;
    border-radius: 14px; display: block;
  }

  /* The name is set from script on exactly one element at a time, so no
     stylesheet rule assigns it. See the toggle() function below. */

  /* Both captures are replaced elements. Without cover, the square
     thumbnail capture is stretched into the 16:9 hero box and the
     content visibly distorts mid-morph. */
  ::view-transition-old(hero-shot),
  ::view-transition-new(hero-shot) {
    object-fit: cover;
    /* Hold the incoming image fully opaque so the morph reads as one
       object changing shape rather than two images cross-fading. */
    mix-blend-mode: normal;
  }
  ::view-transition-old(hero-shot) { animation: none; opacity: 0; }
  ::view-transition-new(hero-shot) { animation: none; opacity: 1; }

  /* The group is what travels and resizes. Give it a longer, softer
     curve than the default so the distance is legible. */
  ::view-transition-group(hero-shot) {
    animation-duration: 420ms;
    animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
  }
</style>
</head>
<body>

<ul class="grid" id="grid">
  <li><img class="thumb" data-id="7"  src="/img/7.jpg"  alt="Coastal path"></li>
  <li><img class="thumb" data-id="12" src="/img/12.jpg" alt="Harbour at dusk"></li>
  <li><img class="thumb" data-id="19" src="/img/19.jpg" alt="Pine ridge"></li>
</ul>

<section class="detail" id="detail" tabindex="-1">
  <img class="hero" id="hero" src="" alt="">
  <h1 id="heroTitle"></h1>
  <button type="button" id="back">Back to grid</button>
</section>

<script>
  const grid = document.getElementById('grid');
  const detail = document.getElementById('detail');
  const hero = document.getElementById('hero');
  const title = document.getElementById('heroTitle');
  let active = null;

  function open(img) {
    active = img;
    // Assign the shared identity to exactly two elements, one per capture.
    img.style.viewTransitionName = 'hero-shot';
    hero.style.viewTransitionName = 'hero-shot';
    hero.src = img.src;
    hero.alt = img.alt;
    title.textContent = img.alt;
    grid.setAttribute('data-hidden', '');
    detail.setAttribute('data-open', '');
  }

  function close() {
    grid.removeAttribute('data-hidden');
    detail.removeAttribute('data-open');
  }

  function run(fn) {
    if (!document.startViewTransition) { fn(); return; }
    // finished resolves once the overlay is torn down; clear names then so
    // the next open() never finds two elements holding hero-shot.
    document.startViewTransition(fn).finished.then(() => {
      if (active) active.style.viewTransitionName = '';
      hero.style.viewTransitionName = '';
    });
  }

  grid.addEventListener('click', e => {
    const img = e.target.closest('.thumb');
    if (img) run(() => { open(img); detail.focus(); });
  });

  document.getElementById('back').addEventListener('click', () => {
    if (active) active.style.viewTransitionName = 'hero-shot';
    hero.style.viewTransitionName = 'hero-shot';
    run(close);
  });
</script>

</body>
</html>

Note detail.focus() inside the callback. The overlay is inert, so without an explicit focus move the keyboard user is left on a button that no longer exists.


The key technique: the group does the moving

It is tempting to write @keyframes that translate and scale the snapshots. Do not. ::view-transition-group() already carries an animation the browser generated from the two measured rectangles, interpolating width, height, and transform from the thumbnail's box to the hero's box. It knows the exact numbers; you do not, and cannot without measuring.

Your job is only to stop the snapshots inside that group from fighting it. That is what the two animation: none declarations in the implementation do: they cancel the default cross-fade so the new image is simply opaque for the whole morph, while the group carries it across the screen. The result reads as one object changing shape instead of two pictures dissolving into each other during a move — which is the difference between a native-feeling morph and a muddy blur.

The complementary detail is object-fit. Because the two captures have different aspect ratios (a square thumbnail, a 16:9 hero) the default fill stretches the square capture into the wide box. cover keeps the framing honest throughout. If your source images are already the right shape, the same discipline is worth applying at the layout level with aspect-ratio for responsive media.


Variation: naming from data with generated idents

Assigning names from script is explicit and easy to reason about, but for a static list rendered on the server you can bake unique idents in and skip the assignment step entirely:

/* Server-rendered: each card carries --vt-id: 7, 12, 19… as an inline style. */
.card {
  view-transition-name: var(--vt-name, none);
}

The template writes style="--vt-name: card-7" on each element. Uniqueness is now guaranteed by the data rather than by careful scripting, and the CSS stays declarative. The catch is that every visible card is then named, which means one snapshot texture per card — acceptable for a dozen, wasteful for a hundred. When the list is long, prefer the assign-on-demand approach and let the rest of the grid travel inside the root snapshot.

A newer alternative is view-transition-name: match-element, which asks the engine to derive a stable per-element identity so you never invent idents at all. It solves this problem cleanly where it exists, but its availability is much narrower than the rest of the API in mid-2026, so treat it as an enhancement over one of the two patterns above rather than a replacement for them. This is the same kind of value-level indirection used for motion tokens in fluid spacing tokens driving transition durations.


Browser support note

view-transition-name ships with same-document view transitions: Chrome and Edge 111+, Safari 18+, and Firefox 144+ — every current engine. In any older browser the property is unknown and dropped at parse time with no effect on surrounding rules, and the click handler's feature test means the detail view still opens, just instantly. view-transition-class, the convenience for styling many names with one selector, is narrower and available in Chromium at the time of writing, so write the per-name selectors when broad coverage matters.


FAQ

Why does my transition do nothing when several cards are visible? Two elements almost certainly share one view-transition-name in the same capture. That is invalid, and rather than picking a winner the browser skips the entire transition, so nothing animates at all.

Do the old and new elements have to be the same kind of element? No. The browser matches purely on the name string, not on tag, class, or position in the tree. An img can morph into a div because only the two captured rectangles and their images are compared.

Why does my image squash while it grows? Snapshots are replaced elements and default to object-fit: fill, so a capture with one aspect ratio is stretched into a box with another. Set object-fit: cover on both the old and new pseudo-elements.

Should every card in a list have a view-transition-name? Only if every card needs to animate independently. Each name costs an extra snapshot texture, so name the element the user is acting on and let everything else travel with the root snapshot.


Related articles

More pages in the same section.