24 CSS Animated Cards04 / 24

Light JSMIT licensed

View Transitions Card-to-Detail Morph

Tap a card and it becomes the detail view — the thumbnail grows into the hero, the title slides into the headline, the whole thing morphs instead of cutting. This is the shared-element transition that native apps have had for a decade, now three lines of web platform: give the paired elements the same view-transition-name and wrap the DOM change in document.startViewTransition().

Published

Live Demo
Try it

The code

<section class="ac-04" aria-label="View transitions card to detail morph demo">
  <div class="ac-04__stage">
    <header class="ac-04__head">
      <p class="ac-04__eyebrow">Shared element morph</p>
      <h2 class="ac-04__title">Tap a card — it becomes the page</h2>
    </header>
    <ul class="ac-04__grid" role="list" data-grid>
      <li class="ac-04__card">
        <img class="ac-04__thumb" src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=700&auto=format&fit=crop" alt="Matte black over-ear headphones" loading="lazy" decoding="async" width="700" height="700">
        <div class="ac-04__meta"><h3 class="ac-04__name">Studio Over-Ear</h3><p class="ac-04__price">$249</p></div>
        <button class="ac-04__open" type="button" data-open="0" aria-haspopup="dialog">Open<span class="ac-04__sr"> Studio Over-Ear details</span></button>
      </li>
      <li class="ac-04__card">
        <img class="ac-04__thumb" src="https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=700&auto=format&fit=crop" alt="Minimal wristwatch on a light surface" loading="lazy" decoding="async" width="700" height="700">
        <div class="ac-04__meta"><h3 class="ac-04__name">Field Watch 38</h3><p class="ac-04__price">$390</p></div>
        <button class="ac-04__open" type="button" data-open="1" aria-haspopup="dialog">Open<span class="ac-04__sr"> Field Watch 38 details</span></button>
      </li>
      <li class="ac-04__card">
        <img class="ac-04__thumb" src="https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?q=80&w=700&auto=format&fit=crop" alt="Compact mirrorless camera body" loading="lazy" decoding="async" width="700" height="700">
        <div class="ac-04__meta"><h3 class="ac-04__name">Rangefinder M</h3><p class="ac-04__price">$1,180</p></div>
        <button class="ac-04__open" type="button" data-open="2" aria-haspopup="dialog">Open<span class="ac-04__sr"> Rangefinder M details</span></button>
      </li>
    </ul>
    <div class="ac-04__detail" data-detail hidden role="dialog" aria-modal="true" aria-labelledby="ac-04-dt">
      <button class="ac-04__back" type="button" data-close>
        <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
        Back to grid
      </button>
      <img class="ac-04__hero" data-hero alt="" width="900" height="600">
      <div class="ac-04__pane">
        <p class="ac-04__tag" data-tag></p>
        <h3 class="ac-04__dtitle" id="ac-04-dt" data-dtitle></h3>
        <p class="ac-04__copy" data-copy></p>
        <p class="ac-04__dprice" data-dprice></p>
      </div>
    </div>
    <p class="ac-04__chip" aria-hidden="true">document.startViewTransition() · view-transition-name pairs · ::view-transition-group timing · real dialog semantics</p>
  </div>
</section>
.ac-04,
.ac-04 *,
.ac-04 *::before,
.ac-04 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ac-04 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --ac-04-bg: oklch(0.97 0.006 95);
  --ac-04-card: oklch(1 0 0);
  --ac-04-ink: oklch(0.22 0.015 60);
  --ac-04-mut: oklch(0.55 0.02 60);
  --ac-04-acc: oklch(0.55 0.15 28);
  --ac-04-line: oklch(0.22 0.015 60/.12);
  background: var(--ac-04-bg);
  color: var(--ac-04-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  -webkit-font-smoothing: antialiased;
}

.ac-04__stage {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: clamp(16px,2.6vw,28px);
  padding: clamp(20px,5vw,58px);
}

.ac-04__head {
  display: grid;
  justify-items: center;
  gap: 9px;
  text-align: center;
}

.ac-04__eyebrow {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ac-04-acc);
}

.ac-04__title {
  font-size: clamp(23px,3.4vw,38px);
  font-weight: 650;
  line-height: 1.12;
  letter-spacing: -.03em;
  text-wrap: balance;
}

.ac-04__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(210px,1fr));
  gap: clamp(12px,1.8vw,18px);
  width: min(100%,880px);
  list-style: none;
}

.ac-04__card {
  position: relative;
  display: grid;
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--ac-04-line);
  border-radius: 20px;
  background: var(--ac-04-card);
  box-shadow: 0 1px 2px oklch(0.22 0.015 60/.05);
  transition: transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s;
}

.ac-04__card:hover,
.ac-04__card:focus-within {
  transform: translateY(-4px);
  box-shadow: 0 20px 40px -22px oklch(0.22 0.015 60/.55);
}

.ac-04__thumb {
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 13px;
  display: block;
  background: linear-gradient(135deg,oklch(0.88 0.03 60),oklch(0.8 0.04 30));
}

.ac-04__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 0 4px 4px;
}

.ac-04__name {
  font-size: 15px;
  font-weight: 640;
  letter-spacing: -.02em;
}

.ac-04__price {
  font-size: 14px;
  font-weight: 640;
  color: var(--ac-04-acc);
  font-variant-numeric: tabular-nums;
}

.ac-04__open {
  position: absolute;
  inset: 0;
  width: 100%;
  border: 0;
  border-radius: 20px;
  background: transparent;
  color: transparent;
  font-size: 0;
  cursor: pointer;
}

.ac-04__open:focus-visible {
  outline: 2px solid var(--ac-04-acc);
  outline-offset: 3px;
}

.ac-04__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.ac-04__detail {
  position: absolute;
  inset: clamp(14px,4vw,44px);
  display: grid;
  grid-template-columns: 1.05fr 1fr;
  align-items: stretch;
  gap: 0;
  border: 1px solid var(--ac-04-line);
  border-radius: 24px;
  overflow: hidden;
  background: var(--ac-04-card);
  box-shadow: 0 50px 90px -50px oklch(0.22 0.015 60/.7);
  view-transition-name: ac-04-panel;
}

.ac-04__detail[hidden] {
  display: none;
}

.ac-04__hero {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: linear-gradient(135deg,oklch(0.88 0.03 60),oklch(0.8 0.04 30));
}

.ac-04__pane {
  display: grid;
  align-content: center;
  gap: 12px;
  padding: clamp(22px,4vw,48px);
}

.ac-04__tag {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ac-04-acc);
}

.ac-04__dtitle {
  font-size: clamp(24px,3.4vw,38px);
  font-weight: 660;
  line-height: 1.08;
  letter-spacing: -.035em;
  text-wrap: balance;
}

.ac-04__copy {
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--ac-04-mut);
  text-wrap: pretty;
  max-width: 44ch;
}

.ac-04__dprice {
  font-size: 22px;
  font-weight: 680;
  font-variant-numeric: tabular-nums;
  letter-spacing: -.02em;
}

.ac-04__back {
  position: absolute;
  top: 14px;
  left: 14px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid var(--ac-04-line);
  border-radius: 99px;
  background: oklch(1 0 0/.86);
  backdrop-filter: blur(10px);
  color: var(--ac-04-ink);
  font-size: 13.5px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  transition: background .2s;
}

.ac-04__back:hover {
  background: oklch(1 0 0);
}

.ac-04__back:focus-visible {
  outline: 2px solid var(--ac-04-acc);
  outline-offset: 3px;
}

.ac-04__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--ac-04-mut);
  padding: 8px 15px;
  border: 1px dashed var(--ac-04-line);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,680px);
}

.ac-04__card[data-active] .ac-04__thumb {
  view-transition-name: ac-04-hero;
}

.ac-04__card[data-active] .ac-04__name {
  view-transition-name: ac-04-name;
}

.ac-04__detail:not([hidden]) .ac-04__hero {
  view-transition-name: ac-04-hero;
}

.ac-04__detail:not([hidden]) .ac-04__dtitle {
  view-transition-name: ac-04-name;
}

::view-transition-group(ac-04-hero),
::view-transition-group(ac-04-name),
::view-transition-group(ac-04-panel) {
  animation-duration: .46s;
  animation-timing-function: cubic-bezier(.16,1,.3,1);
}

::view-transition-old(ac-04-hero),
::view-transition-new(ac-04-hero) {
  height: 100%;
  object-fit: cover;
  overflow: clip;
}

::view-transition-old(ac-04-panel) {
  animation: ac-04-out .3s cubic-bezier(.5,0,.9,.4) both;
}

::view-transition-new(ac-04-panel) {
  animation: ac-04-pop .46s cubic-bezier(.16,1,.3,1) both;
}

@keyframes ac-04-pop {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes ac-04-out {
  to {
    opacity: 0;
  }
}

@media (max-width: 720px) {
  .ac-04__detail {
    grid-template-columns: 1fr;
    grid-template-rows: minmax(0,1fr) auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ac-04__card {
    transition: none;
  }

  ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
    animation-duration: .01ms !important;
  }
}
(() => {
  const DATA = [
    ['Studio Over-Ear', '$249', 'Acoustics', 'Forty-hour battery, adaptive ANC tuned in a real room rather than an anechoic chamber, and a headband that survives being thrown in a bag every day for three years.'],
    ['Field Watch 38', '$390', 'Horology', 'A 38 mm case that disappears under a cuff, sapphire crystal, and a movement rated to 42 hours — designed to be read at a glance in bad light.'],
    ['Rangefinder M', '$1,180', 'Optics', 'Full-frame sensor in a body the size of a paperback. Mechanical dials for every setting that matters, so you never open a menu mid-shot.'],
  ];
  document.querySelectorAll('.ac-04').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const grid = root.querySelector('[data-grid]');
    const detail = root.querySelector('[data-detail]');
    const el = {
      hero: detail.querySelector('[data-hero]'), tag: detail.querySelector('[data-tag]'),
      title: detail.querySelector('[data-dtitle]'), copy: detail.querySelector('[data-copy]'),
      price: detail.querySelector('[data-dprice]'), back: detail.querySelector('[data-close]'),
    };
    let opener = null;

    // A view-transition-name must be unique per frame — tag only the pair that is morphing.
    // Returns the transition (or undefined) so callers can sequence focus after it settles.
    const run = (fn) => {
      if (!document.startViewTransition) { fn(); return undefined; }
      const t = document.startViewTransition(fn);
      t.ready.catch(() => {});                       // aborts reject; never leave it dangling
      return t;
    };
    // Focus must land AFTER the snapshot phase — a bare rAF during a view transition is dropped.
    const focusAfter = (t, el) => {
      if (!el) return;
      Promise.resolve(t && t.finished).catch(() => {}).finally(() => el.focus({ preventScroll: true }));
    };

    const open = (btn) => {
      const i = +btn.dataset.open, card = btn.closest('.ac-04__card');
      const [name, price, tag, copy] = DATA[i];
      opener = btn;
      card.dataset.active = '1';          // OLD snapshot: the name lives on the thumbnail
      const t = run(() => {
        delete card.dataset.active;       // NEW snapshot: it lives on the detail hero only
        el.hero.src = card.querySelector('.ac-04__thumb').src.replace('w=700', 'w=1200');
        el.hero.alt = card.querySelector('.ac-04__thumb').alt;
        el.tag.textContent = tag; el.title.textContent = name;
        el.copy.textContent = copy; el.price.textContent = price;
        grid.setAttribute('inert', ''); detail.hidden = false;
      });
      focusAfter(t, el.back);            // never leave focus on the now-inert opener
    };

    const close = () => {
      const card = opener && opener.closest('.ac-04__card');
      const t = run(() => {               // reversed: hero → thumbnail, still one name per state
        detail.hidden = true;
        grid.removeAttribute('inert');
        if (card) card.dataset.active = '1';
      });
      focusAfter(t, opener);              // return focus only once the grid is interactive again
      if (card) Promise.resolve(t && t.finished).catch(() => {}).finally(() => delete card.dataset.active);
    };

    grid.addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) open(b); });
    el.back.addEventListener('click', close);
    root.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !detail.hidden) { e.preventDefault(); close(); } });
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Animated Card from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: View Transitions Card-to-Detail Morph
Source: https://codefronts.com/components/css-animated-cards/view-transitions-card-to-detail-morph/

Tap a card and it becomes the detail view — the thumbnail grows into the hero, the title slides into the headline, the whole thing morphs instead of cutting. This is the shared-element transition that native apps have had for a decade, now three lines of web platform: give the paired elements the same view-transition-name and wrap the DOM change in document.startViewTransition().
## HTML
```html
<section class="ac-04" aria-label="View transitions card to detail morph demo">
  <div class="ac-04__stage">
    <header class="ac-04__head">
      <p class="ac-04__eyebrow">Shared element morph</p>
      <h2 class="ac-04__title">Tap a card — it becomes the page</h2>
    </header>
    <ul class="ac-04__grid" role="list" data-grid>
      <li class="ac-04__card">
        <img class="ac-04__thumb" src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=700&auto=format&fit=crop" alt="Matte black over-ear headphones" loading="lazy" decoding="async" width="700" height="700">
        <div class="ac-04__meta"><h3 class="ac-04__name">Studio Over-Ear</h3><p class="ac-04__price">$249</p></div>
        <button class="ac-04__open" type="button" data-open="0" aria-haspopup="dialog">Open<span class="ac-04__sr"> Studio Over-Ear details</span></button>
      </li>
      <li class="ac-04__card">
        <img class="ac-04__thumb" src="https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=700&auto=format&fit=crop" alt="Minimal wristwatch on a light surface" loading="lazy" decoding="async" width="700" height="700">
        <div class="ac-04__meta"><h3 class="ac-04__name">Field Watch 38</h3><p class="ac-04__price">$390</p></div>
        <button class="ac-04__open" type="button" data-open="1" aria-haspopup="dialog">Open<span class="ac-04__sr"> Field Watch 38 details</span></button>
      </li>
      <li class="ac-04__card">
        <img class="ac-04__thumb" src="https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?q=80&w=700&auto=format&fit=crop" alt="Compact mirrorless camera body" loading="lazy" decoding="async" width="700" height="700">
        <div class="ac-04__meta"><h3 class="ac-04__name">Rangefinder M</h3><p class="ac-04__price">$1,180</p></div>
        <button class="ac-04__open" type="button" data-open="2" aria-haspopup="dialog">Open<span class="ac-04__sr"> Rangefinder M details</span></button>
      </li>
    </ul>
    <div class="ac-04__detail" data-detail hidden role="dialog" aria-modal="true" aria-labelledby="ac-04-dt">
      <button class="ac-04__back" type="button" data-close>
        <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
        Back to grid
      </button>
      <img class="ac-04__hero" data-hero alt="" width="900" height="600">
      <div class="ac-04__pane">
        <p class="ac-04__tag" data-tag></p>
        <h3 class="ac-04__dtitle" id="ac-04-dt" data-dtitle></h3>
        <p class="ac-04__copy" data-copy></p>
        <p class="ac-04__dprice" data-dprice></p>
      </div>
    </div>
    <p class="ac-04__chip" aria-hidden="true">document.startViewTransition() · view-transition-name pairs · ::view-transition-group timing · real dialog semantics</p>
  </div>
</section>
```
## CSS
```css
.ac-04,
.ac-04 *,
.ac-04 *::before,
.ac-04 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ac-04 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --ac-04-bg: oklch(0.97 0.006 95);
  --ac-04-card: oklch(1 0 0);
  --ac-04-ink: oklch(0.22 0.015 60);
  --ac-04-mut: oklch(0.55 0.02 60);
  --ac-04-acc: oklch(0.55 0.15 28);
  --ac-04-line: oklch(0.22 0.015 60/.12);
  background: var(--ac-04-bg);
  color: var(--ac-04-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  -webkit-font-smoothing: antialiased;
}

.ac-04__stage {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: clamp(16px,2.6vw,28px);
  padding: clamp(20px,5vw,58px);
}

.ac-04__head {
  display: grid;
  justify-items: center;
  gap: 9px;
  text-align: center;
}

.ac-04__eyebrow {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ac-04-acc);
}

.ac-04__title {
  font-size: clamp(23px,3.4vw,38px);
  font-weight: 650;
  line-height: 1.12;
  letter-spacing: -.03em;
  text-wrap: balance;
}

.ac-04__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(210px,1fr));
  gap: clamp(12px,1.8vw,18px);
  width: min(100%,880px);
  list-style: none;
}

.ac-04__card {
  position: relative;
  display: grid;
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--ac-04-line);
  border-radius: 20px;
  background: var(--ac-04-card);
  box-shadow: 0 1px 2px oklch(0.22 0.015 60/.05);
  transition: transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s;
}

.ac-04__card:hover,
.ac-04__card:focus-within {
  transform: translateY(-4px);
  box-shadow: 0 20px 40px -22px oklch(0.22 0.015 60/.55);
}

.ac-04__thumb {
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 13px;
  display: block;
  background: linear-gradient(135deg,oklch(0.88 0.03 60),oklch(0.8 0.04 30));
}

.ac-04__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 0 4px 4px;
}

.ac-04__name {
  font-size: 15px;
  font-weight: 640;
  letter-spacing: -.02em;
}

.ac-04__price {
  font-size: 14px;
  font-weight: 640;
  color: var(--ac-04-acc);
  font-variant-numeric: tabular-nums;
}

.ac-04__open {
  position: absolute;
  inset: 0;
  width: 100%;
  border: 0;
  border-radius: 20px;
  background: transparent;
  color: transparent;
  font-size: 0;
  cursor: pointer;
}

.ac-04__open:focus-visible {
  outline: 2px solid var(--ac-04-acc);
  outline-offset: 3px;
}

.ac-04__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.ac-04__detail {
  position: absolute;
  inset: clamp(14px,4vw,44px);
  display: grid;
  grid-template-columns: 1.05fr 1fr;
  align-items: stretch;
  gap: 0;
  border: 1px solid var(--ac-04-line);
  border-radius: 24px;
  overflow: hidden;
  background: var(--ac-04-card);
  box-shadow: 0 50px 90px -50px oklch(0.22 0.015 60/.7);
  view-transition-name: ac-04-panel;
}

.ac-04__detail[hidden] {
  display: none;
}

.ac-04__hero {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: linear-gradient(135deg,oklch(0.88 0.03 60),oklch(0.8 0.04 30));
}

.ac-04__pane {
  display: grid;
  align-content: center;
  gap: 12px;
  padding: clamp(22px,4vw,48px);
}

.ac-04__tag {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ac-04-acc);
}

.ac-04__dtitle {
  font-size: clamp(24px,3.4vw,38px);
  font-weight: 660;
  line-height: 1.08;
  letter-spacing: -.035em;
  text-wrap: balance;
}

.ac-04__copy {
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--ac-04-mut);
  text-wrap: pretty;
  max-width: 44ch;
}

.ac-04__dprice {
  font-size: 22px;
  font-weight: 680;
  font-variant-numeric: tabular-nums;
  letter-spacing: -.02em;
}

.ac-04__back {
  position: absolute;
  top: 14px;
  left: 14px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid var(--ac-04-line);
  border-radius: 99px;
  background: oklch(1 0 0/.86);
  backdrop-filter: blur(10px);
  color: var(--ac-04-ink);
  font-size: 13.5px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  transition: background .2s;
}

.ac-04__back:hover {
  background: oklch(1 0 0);
}

.ac-04__back:focus-visible {
  outline: 2px solid var(--ac-04-acc);
  outline-offset: 3px;
}

.ac-04__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--ac-04-mut);
  padding: 8px 15px;
  border: 1px dashed var(--ac-04-line);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,680px);
}

.ac-04__card[data-active] .ac-04__thumb {
  view-transition-name: ac-04-hero;
}

.ac-04__card[data-active] .ac-04__name {
  view-transition-name: ac-04-name;
}

.ac-04__detail:not([hidden]) .ac-04__hero {
  view-transition-name: ac-04-hero;
}

.ac-04__detail:not([hidden]) .ac-04__dtitle {
  view-transition-name: ac-04-name;
}

::view-transition-group(ac-04-hero),
::view-transition-group(ac-04-name),
::view-transition-group(ac-04-panel) {
  animation-duration: .46s;
  animation-timing-function: cubic-bezier(.16,1,.3,1);
}

::view-transition-old(ac-04-hero),
::view-transition-new(ac-04-hero) {
  height: 100%;
  object-fit: cover;
  overflow: clip;
}

::view-transition-old(ac-04-panel) {
  animation: ac-04-out .3s cubic-bezier(.5,0,.9,.4) both;
}

::view-transition-new(ac-04-panel) {
  animation: ac-04-pop .46s cubic-bezier(.16,1,.3,1) both;
}

@keyframes ac-04-pop {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes ac-04-out {
  to {
    opacity: 0;
  }
}

@media (max-width: 720px) {
  .ac-04__detail {
    grid-template-columns: 1fr;
    grid-template-rows: minmax(0,1fr) auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ac-04__card {
    transition: none;
  }

  ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
    animation-duration: .01ms !important;
  }
}
```

## JavaScript
```js
(() => {
  const DATA = [
    ['Studio Over-Ear', '$249', 'Acoustics', 'Forty-hour battery, adaptive ANC tuned in a real room rather than an anechoic chamber, and a headband that survives being thrown in a bag every day for three years.'],
    ['Field Watch 38', '$390', 'Horology', 'A 38 mm case that disappears under a cuff, sapphire crystal, and a movement rated to 42 hours — designed to be read at a glance in bad light.'],
    ['Rangefinder M', '$1,180', 'Optics', 'Full-frame sensor in a body the size of a paperback. Mechanical dials for every setting that matters, so you never open a menu mid-shot.'],
  ];
  document.querySelectorAll('.ac-04').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const grid = root.querySelector('[data-grid]');
    const detail = root.querySelector('[data-detail]');
    const el = {
      hero: detail.querySelector('[data-hero]'), tag: detail.querySelector('[data-tag]'),
      title: detail.querySelector('[data-dtitle]'), copy: detail.querySelector('[data-copy]'),
      price: detail.querySelector('[data-dprice]'), back: detail.querySelector('[data-close]'),
    };
    let opener = null;

    // A view-transition-name must be unique per frame — tag only the pair that is morphing.
    // Returns the transition (or undefined) so callers can sequence focus after it settles.
    const run = (fn) => {
      if (!document.startViewTransition) { fn(); return undefined; }
      const t = document.startViewTransition(fn);
      t.ready.catch(() => {});                       // aborts reject; never leave it dangling
      return t;
    };
    // Focus must land AFTER the snapshot phase — a bare rAF during a view transition is dropped.
    const focusAfter = (t, el) => {
      if (!el) return;
      Promise.resolve(t && t.finished).catch(() => {}).finally(() => el.focus({ preventScroll: true }));
    };

    const open = (btn) => {
      const i = +btn.dataset.open, card = btn.closest('.ac-04__card');
      const [name, price, tag, copy] = DATA[i];
      opener = btn;
      card.dataset.active = '1';          // OLD snapshot: the name lives on the thumbnail
      const t = run(() => {
        delete card.dataset.active;       // NEW snapshot: it lives on the detail hero only
        el.hero.src = card.querySelector('.ac-04__thumb').src.replace('w=700', 'w=1200');
        el.hero.alt = card.querySelector('.ac-04__thumb').alt;
        el.tag.textContent = tag; el.title.textContent = name;
        el.copy.textContent = copy; el.price.textContent = price;
        grid.setAttribute('inert', ''); detail.hidden = false;
      });
      focusAfter(t, el.back);            // never leave focus on the now-inert opener
    };

    const close = () => {
      const card = opener && opener.closest('.ac-04__card');
      const t = run(() => {               // reversed: hero → thumbnail, still one name per state
        detail.hidden = true;
        grid.removeAttribute('inert');
        if (card) card.dataset.active = '1';
      });
      focusAfter(t, opener);              // return focus only once the grid is interactive again
      if (card) Promise.resolve(t && t.finished).catch(() => {}).finally(() => delete card.dataset.active);
    };

    grid.addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) open(b); });
    el.back.addEventListener('click', close);
    root.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !detail.hidden) { e.preventDefault(); close(); } });
  });
})();
```

How this works

The browser screenshots the old state, applies your DOM change, screenshots the new state, and cross-fades the two — except for elements you have named, which it morphs from old box to new box:

.card[data-open] .thumb { view-transition-name: ac-04-hero }
.detail .hero        { view-transition-name: ac-04-hero }

button.onclick = () => {
  if (!document.startViewTransition) return apply();   // graceful fallback
  document.startViewTransition(() => apply());        // that's the whole API
};

Two rules matter. First, a view-transition-name must be unique at any given moment — so the name is assigned only to the card being opened (via a data attribute), never to all six at once. Second, the callback must apply the DOM change synchronously; the browser is holding a frozen frame while it runs.

::view-transition-old(ac-04-hero),
::view-transition-new(ac-04-hero) { animation-duration: .42s }
::view-transition-group(ac-04-hero) { animation-timing-function: cubic-bezier(.16,1,.3,1) }

Everything else is styling: the pseudo-element tree (::view-transition-group, -image-pair, -old, -new) is real CSS you can retime, delay, or replace with custom keyframes. The detail panel here is a real modal — focus moves into it, Escape closes it, and the trigger regains focus — so the morph is decoration on top of a correct dialog, not a substitute for one.

Make it yours

  • Name more pairs: title, price, badge. Each named pair morphs independently, which is what sells the illusion — the eye tracks three things moving in concert.
  • Direction-aware exits: set a class on the root before starting the transition and target .back::view-transition-old(root) to slide the opposite way.
  • Cross-document (MPA) transitions: add @view-transition { navigation: auto } to both pages and the same morph works across a real page load, no SPA router.
  • Slow it down for debugging: ::view-transition-group(*) { animation-duration: 3s } in DevTools shows exactly what is morphing.

Gotchas — read before shipping

  • Never ship src="" on a placeholder image. An empty src resolves against the document URL, so the browser requests the page itself and logs a failed image load. Omit the attribute until you set it.
  • Duplicate view-transition-name values abort the whole transition silently. Assign names dynamically to the active pair only — the number-one cause of "it just cuts".
  • The callback runs while the page is frozen; anything async inside it must be awaited by returning a promise, and a long await means a long freeze.
  • A named element that is position: fixed or inside a transformed/filtered ancestor can morph from the wrong coordinates — the containing block matters.
  • Focus moves must be sequenced off transition.finished, not a bare requestAnimationFrame. While the browser is capturing snapshots the document is render-blocked, and a rAF-scheduled .focus() lands in a frame where it is dropped — leaving keyboard focus stranded on the opener button, which by then sits inside the inert grid.
  • Transitions are skipped entirely under prefers-reduced-motion unless you handle it; keep the fallback path (plain DOM update) always working.
  • Don't rely on the morph for state: the detail view must be a real, focusable, Escape-dismissible dialog. The animation is polish.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

Same-document View Transitions: Chrome/Edge 111+, Safari 18+, Firefox 144+. Cross-document: Chrome 126+, Safari 18.2+. Where unsupported, document.startViewTransition is undefined and the code path falls through to an instant, fully functional update.

Techniques used in this demo

Search CodeFronts

Loading…