Same-document transitions wrap state changes like filter chips and tab switches in a single startViewTransition call.
Shared-element morphs use view-transition-name to animate a product card into a detail page on Shopify themes.
Cross-document transitions on MPAs ship with the @view-transition rule, supported in Chrome 126+, Safari 17.4, and Firefox 131.
Theme-toggle flash gets replaced by a clip-path circle wipe expanding from the click coordinates of the toggle button.
Some interactions should skip view transitions entirely, and view-transition-class is the per-element scoping primitive that helps.
Last week I was watching a filter chip on the RAXXO shop snap from "All" to "Tools" and the entire product grid blinked. Same DOM, same data, same scroll position. It just looked cheap. Ten minutes later I wrapped the state update in document.startViewTransition and the grid cross-faded in 200ms. Nothing else changed. The shop felt twice as expensive.
That is the whole pitch for the View Transitions API. You write the state change you were already going to write, and the browser handles the in-between frames. As of May 2026, caniuse reports around 92% global support. Chrome, Edge, and Safari 17.4 all ship same-document transitions. Firefox 131 added them. Cross-document transitions need Chrome 126 or newer, which is fine because they degrade silently on older browsers.
Five patterns earned a spot on every RAXXO site this year. Here is how I use them, with the code I actually ship.
Pattern 1: Same-document transitions for state changes
This is the gateway drug. Any time you swap visible DOM in response to a click (filter chips, tab switches, sort dropdowns, "show more" toggles), you wrap the mutation in startViewTransition. The browser captures a snapshot before and after, then cross-fades between them.
The catch most people miss: the callback runs synchronously inside the transition. If you await a network request inside, the snapshot freezes the loading state. Fetch first, then start the transition with the data already in hand.
async function setFilter(tag) {
const products = await fetchProducts(tag);
if (!document.startViewTransition) {
renderGrid(products);
return;
}
document.startViewTransition(() => renderGrid(products));
}
That is 8 lines. The progressive enhancement check on line 3 is non-optional because the API is still missing on roughly 8% of sessions. Per-transition cost on a mid-range Android device sits under 16ms in my profiling, which is one frame at 60Hz. No JS animation library can match that because the compositor is doing the work directly.
A small note on accessibility. The browser honors prefers-reduced-motion automatically for the default cross-fade, but if you write custom ::view-transition-* animations you need to gate them yourself with a media query. I forgot this on the first build and a user flagged the wipe on Pattern 4 as nauseating. Three lines of CSS fixed it.
Pattern 2: Shared-element morphs between product cards and detail pages
This is where you earn the premium feel. When a customer clicks a product card, the card image stretches into the hero image of the product page. No fade, no cut. The same pixels appear to fly across the viewport.
You tag the source element and the destination element with the same view-transition-name. Each name must be unique per page (two elements with the same name in the same snapshot will throw). I generate the name from the product ID so the source card and the destination hero stay paired.
.product-card[data-id="abc123"] .card-image {
view-transition-name: product-abc123;
}
.product-detail .hero-image {
view-transition-name: product-abc123;
}
On Next.js this pairs with router.push wrapped in a transition. On Shopify themes (I run the RAXXO shop on Shopify, signed up via shopify.pxf.io/5k5rj9), you use the cross-document version covered in Pattern 3. Either way, the morph runs around 250ms by default, which is the sweet spot. Faster feels glitchy, slower feels sluggish.
One real gotcha. If your card image is object-fit: cover and the hero is object-fit: contain, the browser interpolates the box shape, not the crop. You get a brief letterbox flash. Match the object-fit on both ends or accept the artifact.
The other thing worth saying out loud: a morph like this costs nothing extra to ship. The image is already in cache from the card. The destination DOM was going to render anyway. You are paying for one snapshot and one compositor animation. Compare that to a JS-based FLIP animation (First Last Invert Play) which has to read layout, write transforms, and clean up listeners. I removed about 40 lines of FLIP code per page when I moved to view transitions.
Pattern 3: Cross-document transitions on MPAs and Shopify themes
Same-document transitions assume you control the DOM swap. On a classic multi-page site, the browser unloads the old document and parses a new one. Until 2024, that meant no view transitions across navigations. Then Chrome 126 shipped the @view-transition rule, and Safari 17.4 followed. Firefox 131 caught up in late 2025.
The setup is two CSS lines on both the source page and the destination page. No JavaScript at all.
@view-transition {
navigation: auto;
}
That is it. The browser opts the navigation into a transition. Add view-transition-name on the elements you want to morph and you have the same Pattern 2 effect, except it now works across full page loads on a Shopify theme, an Astro static site, or a Rails MPA. The two pages must be same-origin and both opted in.
I ship this on every RAXXO Shopify theme now. Collection page to product page, blog index to blog post, cart drawer to checkout. The transitions degrade to instant navigation on older browsers, so there is no risk to the 8% who do not have support. If you already moved your tokens to a clean system (I wrote about that in tailwind-v4-tokens-that-actually-scale), the transitions inherit your design language for free.
One sharp edge. The browser only runs the transition if the user navigates via a real link click or history.pushState. A meta refresh or a window.location.replace skips the API entirely. If your theme uses any of those for redirects, you will not see the morph. Swap to a real anchor or accept the cut.
Pattern 4: Theme-toggle flash elimination
Dark mode toggles have a tell. The whole screen flashes. Even with a prefers-color-scheme listener and a color-scheme meta tag, the moment of repaint catches the eye. View transitions plus a clip-path wipe fix this in a way that feels almost theatrical.
The trick: when the user clicks the toggle, you grab the click coordinates. Then you animate a circular clip-path from radius 0 at those coordinates outward to the diagonal of the viewport. The new theme appears to ripple out from the button itself.
toggle.addEventListener("click", async (e) => {
const x = e.clientX, y = e.clientY;
const r = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
document.documentElement.style.setProperty("--cx", x + "px");
document.documentElement.style.setProperty("--cy", y + "px");
document.documentElement.style.setProperty("--cr", r + "px");
await document.startViewTransition(() => toggleTheme()).ready;
});
::view-transition-new(root) {
clip-path: circle(var(--cr) at var(--cx) var(--cy));
animation: rx-wipe 420ms ease-out forwards;
}
@keyframes rx-wipe {
from { clip-path: circle(0 at var(--cx) var(--cy)); }
}
12 lines of CSS and 9 lines of JS. The animation runs on the compositor thread. I have measured it on a 2019 MacBook Air and it holds 60fps. If you want the technique without view transitions you would need a full-screen overlay element and a lot of coordination. The reason this matters is the same reason I obsess over the small things in dark UIs (8-css-properties-that-make-dark-uis-feel-premium). The toggle is the most-clicked control on a portfolio. Make it feel like the rest of the design.
Pattern 5: Knowing when NOT to use view transitions
Three places I do not reach for the API.
Continuous streams. A live log, a chat message list, a stock ticker. View transitions add a snapshot cost per change. At 10 updates per second the snapshot work piles up and the main thread chokes. Use plain CSS transitions on the new items instead.
Virtualized lists. If you render only the visible rows and recycle DOM nodes on scroll, view transitions will treat node recycling as element removal plus insertion. Items appear to teleport. Tag virtualized rows with view-transition-name: none to opt out.
Drag interactions. The user is already controlling the position. A second animation layer fights them.
For everything else, view-transition-class gives you per-element scoping without naming every node uniquely. It shipped alongside the cross-document syntax and now has 92% support per caniuse.
.product-card { view-transition-class: card; }
::view-transition-group(.card) {
animation-duration: 180ms;
}
That tunes every card transition to 180ms without forcing me to write a view-transition-name per product. The naming requirement (one name per snapshot) is what makes lists painful. Classes solve that.
Bottom Line
View Transitions are the rare browser feature that pays back the time you spend learning them within a single afternoon. Same-document transitions for state changes are a one-line win on every interactive surface. Cross-document transitions are the cheapest way to make a multi-page site feel like a single-page app. The theme-toggle wipe is a 21-line flourish that no client has failed to notice.
The traps are real but few. Network calls belong outside the transition callback. Unique names per snapshot, or use classes. Some interactions (streams, virtualized lists, drag) should opt out via view-transition-name: none. Older browsers fall back to instant change with no broken layouts, so the feature detection in Pattern 1 is the only defensive code you actually need.
I started 2026 with view transitions on three sites. They are now on every shop, every landing page, and every theme I ship. The grid no longer blinks when I switch filters. That alone was worth the afternoon.
Top comments (0)