What the View Transition API Changes
Page transitions in Shopify themes have historically followed one of two paths: hard page loads with no animation at all, or heavyweight JavaScript libraries like Barba.js and Swup that intercept navigation, fetch pages via AJAX, and manually swap DOM content. The JavaScript approach can look impressive, but it's fragile in the Shopify ecosystem. Third-party app scripts expect traditional page loads. Shopify's own analytics tracking breaks when navigation is intercepted. App blocks that inject content via script tags stop working when the DOM is swapped without a real page load.
The Cross-Document View Transition API takes a fundamentally different approach. It operates at the browser level, below the application layer, below your theme code, below any JavaScript framework. When a user clicks a link, the browser captures a visual snapshot of the outgoing page, performs a normal full-page navigation to load the new page, and then animates between the two snapshots using CSS. No JavaScript router. No client-side rendering. No DOM manipulation. The new page loads exactly as it would without transitions, scripts execute, analytics fire, app blocks render, and the browser handles the visual continuity.
Same-Document vs Cross-Document
The View Transition API has two distinct modes, and understanding the difference is critical for Shopify themes. Same-document transitions, triggered via document.startViewTransition(), are designed for single-page applications. They animate between state changes within one page, toggling a sidebar, switching tabs, updating a list. The JavaScript controls when and what transitions occur.
Cross-document transitions work across full page navigations, exactly how Shopify themes function. Every link click, every collection-to-product navigation, every page load is a separate HTML document. Cross-document transitions require a CSS @view-transition rule on both the source and destination pages. Since Shopify themes share a single layout file (theme.liquid), adding the rule once covers every page in the store.
Enabling Transitions in Your Theme
Enabling cross-document view transitions requires exactly one CSS rule. Add this to your theme's base stylesheet, the file that loads on every page:
@view-transition {
navigation: auto;
}
That single at-rule enables a default crossfade on all same-origin navigations. Every link click between pages in your store now fades smoothly instead of flashing white during the page load. No configuration, no JavaScript, no build step.
In a Shopify theme, you can add this to your main CSS file (typically base.css or theme.css) or inline it directly in theme.liquid:
<style>
@view-transition {
navigation: auto;
}
</style>
Case Study: Collection-to-Product Image Morph
Here's a concrete business requirement: when a customer clicks a product card on a collection page, the product image should appear to expand from its grid position into the hero image on the product page. Instead of an abrupt white flash between pages, the image animates smoothly, creating the kind of spatial continuity you'd expect from a native mobile app.
This effect requires assigning the same view-transition-name to the product image on both pages, the collection card thumbnail and the product page hero. The browser detects that both pages contain an element with the same transition name and automatically generates a morph animation between them. The image's position, size, and aspect ratio interpolate smoothly from source to destination.
Assigning Transition Names in Liquid
On the collection page, each product card's image gets a unique view-transition-name derived from the product ID. In your collection template (or the product card snippet your theme uses):
{%- for product in collection.products -%}
<div class="product-card">
<a href="{{ product.url }}">
<img
src="{{ product.featured_image | image_url: width: 600 }}"
alt="{{ product.featured_image.alt | escape }}"
style="view-transition-name: product-{{ product.id }};"
loading="lazy"
/>
</a>
<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>
</div>
{%- endfor -%}
On the product page, the hero image receives the matching transition name:
<div class="product-hero">
<img
src="{{ product.featured_image | image_url: width: 1200 }}"
alt="{{ product.featured_image.alt | escape }}"
style="view-transition-name: product-{{ product.id }};"
/>
</div>
The view-transition-name must be unique on each page and must match between pages for the morph to work. Using product-{{ product.id }} ensures uniqueness, each product gets its own transition channel, so the browser knows exactly which source element maps to which destination element.
Uniqueness matters. view-transition-name values must be unique within a single page. If two elements share the same name, both elements are excluded from the transition entirely. On collection pages with many products, each image gets a unique name via the product ID, this is why we use product-{{ product.id }} rather than a generic name like product-image.
Customizing Transition Animations
By default, matching elements morph automatically, the browser interpolates position, size, and visual state between the old and new snapshots. For the rest of the page (everything not assigned a specific view-transition-name), the browser applies a crossfade on the root pseudo-element. You can customize both behaviors using the ::view-transition-old() and ::view-transition-new() pseudo-elements:
::view-transition-old(root) {
animation: fade-out 250ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 250ms ease-in;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
The root pseudo-element represents everything not captured by a named transition. For elements with matching view-transition-name values (like our product images), the morphing animation is automatic, the browser handles the position and size interpolation. You don't need to write custom keyframes for the morph itself. The pseudo-elements for named transitions (e.g., ::view-transition-old(product-123)) let you override the default behavior, but for most image morphs the built-in interpolation looks better than anything you'd write manually.
Keeping the Header Persistent
By default, the entire page participates in the crossfade, including the header and navigation. This creates a jarring flicker where the header appears to dissolve and reappear. To keep the header visually stable during transitions, assign it a separate view-transition-name:
.header-main {
view-transition-name: site-header;
}
Then suppress the animation for the header so it stays in place:
::view-transition-old(site-header),
::view-transition-new(site-header) {
animation: none;
mix-blend-mode: normal;
}
In Liquid, this means adding the header-main class (or an inline style="view-transition-name: site-header;") to the header element in theme.liquid. The header appears to stay perfectly in place while the content area transitions beneath it, exactly the behavior users expect from app-like navigation.
Page-Specific Transition Choreography
Liquid's template conditionals let you vary transition names per page type. In your theme.liquid head, you can assign transition names only where they're relevant:
<style>
@view-transition {
navigation: auto;
}
{%- if template.name == 'product' -%}
.product-hero-image {
view-transition-name: product-{{ product.id }};
}
{%- endif -%}
{%- if template.name == 'collection' -%}
/* Names assigned inline on each product card */
{%- endif -%}
</style>
This approach keeps transition names scoped to the pages where they're meaningful. The product page assigns the hero image's transition name via CSS (matched to the inline name on the collection card). The collection page handles names inline because each card needs a unique name based on the product ID. Other pages, cart, blog, about, get the default crossfade with no extra configuration.
Accessibility: Respecting Motion Preferences
Some users configure their OS to reduce motion, and your transitions should respect that preference. The approach isn't to disable transitions entirely, instead, make them instant. This preserves the state change (no flash of white between pages) while removing the motion:
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms !important;
}
}
Setting the duration to near-zero rather than disabling animations entirely preserves the snapshot mechanism. The page still transitions without the white flash, it just happens instantly instead of over 250ms.
Why not disable transitions entirely? You can't conditionally disable @view-transition based on prefers-reduced-motion because the at-rule doesn't support media queries. Instead, keep transitions enabled but set animation durations to near-zero for users who prefer reduced motion. The result is a clean instant cut between pages, no animation, but also no white flash.
Performance Considerations
View transitions should be fast, 200 to 300ms at most. The animation masks a page load, not a loading spinner. The browser doesn't wait for the animation to complete before the new page becomes interactive; the transition is purely cosmetic. But complex transitions with many named elements increase the snapshot capture cost. Each named element requires the browser to isolate it into a separate compositing layer, capture its visual state, and animate it independently.
On low-end devices, keep the number of named elements small, two to four per page is a practical ceiling. A product image morph plus a persistent header is enough to create a polished feel without taxing the compositor. Avoid assigning view-transition-name to every element in a product grid; the snapshot overhead for 20+ named elements will negate the perceived performance benefit.
Keep transitions under 300ms. The animation masks a page load, not a loading spinner. If the new page takes 500ms to load and your transition is 300ms, the user sees 200ms of animation continuation while the page is already rendering. If your transition is 1s, the user waits an unnecessary 500ms watching an animation after the page is ready. Short transitions feel snappy; long transitions feel sluggish.
Browser Support in 2026
As of early 2026, cross-document view transition support covers the majority of Shopify store traffic:
- Chrome 126+, full cross-document support
- Edge 126+, same Chromium engine, identical support
- Safari 18.2+, cross-document support added in late 2025
- Firefox, behind a flag (
dom.viewTransitions.enabled), not yet enabled by default
This puts the feature at roughly 85%+ of typical Shopify store traffic. The progressive enhancement story is the strongest argument for adoption: unsupported browsers perform standard navigation with no transition animation. No broken links, no missing content, no JavaScript errors. The CSS rules are silently ignored. You can ship transitions to supported browsers today without a single line of feature-detection code.
Edge Cases in Production
AJAX page transition libraries: If your theme uses a JavaScript-based transition library like Barba.js or Swup, it intercepts navigation and prevents cross-document transitions from firing. These libraries operate in the same space, you need to choose one approach, not layer both.
Shopify preview mode: The theme editor wraps the storefront in an iframe and injects its own scripts. View transitions still work in the preview, but may be less smooth due to the editor overhead. Test transitions on the live storefront, not just the editor.
Third-party app scripts: Apps that inject content via script tags reload on each navigation. View transitions don't interfere with this, the scripts execute normally after the transition completes. The new page's DOM is fully constructed before the animation begins.
Back/forward navigation: Cross-document transitions work on both forward navigation and browser back/forward. The browser applies the transition in reverse for back navigation, creating a natural spatial relationship between pages.
Cross-origin links: View transitions only apply to same-origin navigations. External links load normally. Notably, Shopify checkout is on a different origin (checkout.shopify.com), so the transition won't apply to the checkout redirect, customers will see a standard page load when moving to checkout.
Large images with different aspect ratios: If the source and destination images have very different aspect ratios, the morph animation can look awkward, the image stretches or squeezes during interpolation. Use object-fit: cover and consistent aspect ratios across collection cards and product hero images for the best visual result.
Key Takeaways
- One CSS rule enables page transitions across your entire Shopify theme,
@view-transition { navigation: auto; } - Matching
view-transition-namevalues between pages create automatic morph animations, product images that expand from collection grid to product hero - Keep the header stable by assigning it a dedicated
view-transition-nameand suppressing its animation - Browser support covers ~85% of typical Shopify traffic. Unsupported browsers get standard navigation, zero degradation
- Keep animations under 300ms. View transitions mask page loads, not loading spinners
- Respect
prefers-reduced-motionwith near-zero animation durations, not by disabling transitions entirely
If your Shopify theme needs app-like page transitions without the maintenance burden of JavaScript transition libraries, I can help you implement the View Transition API with the right choreography for your storefront. Let's talk about making your navigation feel seamless.