You click a product card and the page cuts as if the transition was never enabled. On a WooCommerce shop page that usually means two elements ended up with the same view-transition-name, and when the browser can’t tell them apart, it drops the whole transition and navigates normally.
Every view-transition-name on a page has to be unique. That rule is trivial with one hero image and easy to break the moment a store layout repeats a product: product 42 in the shop grid prints product-42, product 42 again in a featured carousel prints product-42 from the same template code, and the morph dies on every navigation until someone notices. The scaling half of this post comes from part two of the CSS-Tricks series on cross-document view transitions, and the just-in-time pattern below is from there too.
Two ways to generate view transition names
The first option is pure PHP. Print the name in the loop template so every card carries it in the markup:
<li <?php wc_product_class( 'product', $product ); ?>
style="view-transition-name: product-<?php echo (int) $product->get_id(); ?>; view-transition-class: card">This works with no JavaScript, the markup is identical for every visitor so full-page caches don’t care, and the name is stable between the archive and the single product page, which is all the browser needs to morph one into the other. The cost is snapshotting. A named element participates in every navigation on that page, and the source describes the waste plainly: a 48-card grid means 47 extra snapshots, enough to make a mid-range phone skip the transition. The duplicate problem from the intro is baked in too, since the same partial usually renders the carousel.
The second option names nothing in advance. pageswap fires on the outgoing page right before the browser takes its snapshot, and that’s where you set the name:
window.addEventListener( 'pageswap', ( event ) => {
if ( ! event.viewTransition ) return;
const id = new URL( event.activation.entry.url ).pathname.match( /product\/(\d+)/ )?.[1];
if ( ! id ) return;
document
.querySelector( `.card[data-id="${id}"] img` )
?.style.setProperty( 'view-transition-name', `product-${id}` );
event.viewTransition.finished.finally( () =>
document.querySelectorAll( '[style*="view-transition-name"]' )
.forEach( ( el ) => el.style.removeProperty( 'view-transition-name' ) )
);
} );Only one element gets a name, so only one snapshot is taken. It also doesn’t matter whether the card was in the initial HTML or appended by infinite scroll, because you query the DOM at click time. On the outgoing page event.activation.entry.url tells you where the navigation is headed; on the incoming page a matching pagereveal handler reads window.location and names the hero image. Neither side needs sessionStorage or shared state. The tradeoff is that you own the cleanup: strip the name when viewTransition.finished resolves, or the next click into the grid morphs from the wrong card. Keep the handler dumb, too, because the source pegs the window before the snapshot at roughly 10 to 50ms.
One stylesheet for the whole catalog
Names are identity. Styling is view-transition-class, and mixing the two up is the other common mistake here. Give every card the class and the animation CSS stops caring how many products exist:
@view-transition {
navigation: auto;
}
::view-transition-group(*.card) {
animation-duration: 0.35s;
}
::view-transition-old(*.card),
::view-transition-new(*.card) {
object-fit: cover;
}The object-fit line matters because the pseudo-elements default to fill, which stretches a square thumbnail into taffy on the way to a wide hero. view-transition-class shipped in Chrome 125 and Safari 18.2+. Firefox still holds it behind a flag, and the failure is polite: unsupported browsers get the plain cross-fade and you write no fallback code. Wrap the custom timing in prefers-reduced-motion: no-preference, because a full-page morph is exactly the motion some shoppers have told their OS to suppress. It’s the same bet as doing the fly-to-cart animation with offset-path instead of a JavaScript library. Longer term, ident() paired with sibling-index() would generate names in pure CSS, but ident() has no shipping browser yet, while sibling-index() landed in Chrome 138.
Which one I’d pick
With a small catalog in a hand-built theme, where products render as cards in one place, print the names in PHP. It’s less code to maintain and no script is involved. On a busy store with layered nav, related products, upsell carousels and infinite scroll, go just-in-time, because it’s the only version that survives a page where the same product appears three times. If the client wants server-side names anyway, at least pull the name out of the related and carousel templates so the grid is the only template that assigns names.
One thing I haven’t verified is how this behaves with filter plugins that replace the grid through AJAX. The names ride along in the markup, so they should reappear with the new HTML, but I’d test that before promising it.
This is the kind of storefront front-end work I do most weeks. If you want product-to-detail morphs on your shop without an SPA rebuild, send me your archive template and I’ll take a look.
Before writing any of it, open your shop archive in Chrome and run this in the console: [...document.querySelectorAll('[style*="view-transition-name"]')].map(e => e.style.viewTransitionName). If the same value shows up twice, that’s your hard cut, and it’s the first thing to fix.