中文
Shopify knowledge · Guide

Horizon's Cross-Document View Transitions: Render Blocking, Bail-Out Conditions and a WebView Blocklist

A Horizon 4.2.0 source walkthrough of cross-document View Transitions in a multi-page theme: a settings-driven opt-in, render blocking until main content with a 1.8 second cap, releasing it when no transition can happen, skipping on user input, switching animations by type and passing the type across pages, plus the in-app WebView blocklist and in-page transitions.
Historical material
Read it together with the applicable versions and sources stated in the article.

This article is based on Horizon 4.2.0, with source pinned to 5acd1b6 (verified 2026-10-07). Conclusions are limited to that commit; this is a static source analysis and nothing was tested in a store.

A Shopify theme is a classic multi-page application (MPA): every navigation is a full document load. Horizon uses cross-document View Transitions for two effects: a transition on ordinary page navigations, and a "fly-in" of the main image from a product card to the product page.

One line, @view-transition { navigation: auto }, turns it on. The hard part is when not to turn it on, and how to keep it from slowing the page down once it is on. Involved:

1. The switch lives in Liquid, not in a CSS file

{% if settings.transition_to_main_product or settings.page_transition_enabled %}
  {% style %}
    @media (prefers-reduced-motion: no-preference) {
      @view-transition { navigation: auto; }
    }
  {% endstyle %}
  <link rel="expect" href="#MainContent" blocking="render" id="view-transition-render-blocker">
{% endif %}

base.css is a static file shared by the theme.liquid and password.liquid layouts and can't read theme settings, so each layout renders this snippet for the opt-in. The transition rules in base.css only take effect once the opt-in is active; without it they're inert.

The control script view-transitions.js sits behind the same settings condition and is inlined into <head> with inline_asset_content. The comment says this avoids a network round trip for a render-blocking asset.

2. blocking="render": wait for main content, with a cap

A cross-document transition needs the new page to be "ready" before it can be snapshotted. If the browser paints the first frame before <main> has been parsed, the transition shows a half-built page.

<link rel="expect" href="#MainContent" blocking="render"> tells the browser to hold the first paint until #MainContent has been parsed. The cost is a slower FCP, so Horizon caps the blocking:

const RENDER_BLOCKER_TIMEOUT_MS = Math.max(0, 1800 - performance.now());
setTimeout(() => viewTransitionRenderBlocker?.remove(), RENDER_BLOCKER_TIMEOUT_MS);

The source comment says the aim is an FCP under 1.8 seconds (also the line web.dev draws for a "good" FCP), measured from navigation start (performance.now()) rather than from when the script runs. A slow-parsing page is held at most until 1.8 seconds after navigation start; after that the user sees content regardless.

3. Let go immediately when no transition is possible

In many cases no transition can happen at all, and blocking is pure waste. Horizon removes the blocker immediately in these cases, without setting the timer:

const activation = window.navigation?.activation;
const noTransitionPossible =
  !!activation && (activation.from === null || activation.navigationType === 'reload');

if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || isLowPowerDevice() || noTransitionPossible) {
  viewTransitionRenderBlocker?.remove();
}
  • activation.from === null: the first page of the visit, with no old document to snapshot;
  • navigationType === 'reload': the comment notes that the browser excludes reloads from navigation: auto anyway;
  • The user prefers reduced motion;
  • A low-power device: hardwareConcurrency <= 2 or deviceMemory <= 2.

The first two rely on the Navigation API's navigation.activation. In browsers without it, activation is undefined, those two checks don't apply, and the 1.8 second fallback takes over. The first visit is often the load performance metrics care about most, and it's exactly the one least likely to have a transition, so this check has the largest effect.

4. Give up the transition as soon as the user interacts

window.addEventListener('pageswap', (event) => {
  const { viewTransition } = event;
  // shouldSkipViewTransition first rules out low-power devices, reduced motion and the blocklist
  ['pointerdown', 'keydown'].forEach((name) => {
    document.addEventListener(name, () => viewTransition.skipTransition(), { once: true });
  });
});

If the user clicks or presses a key while a transition is running, it's skipped immediately. The comment states the goal is better INP: interactions shouldn't wait for an animation to finish.

The CSS cooperates: :root defaults to view-transition-name: none and the root is only named root-custom under the page-navigation or product-image-transition types; ::view-transition gets pointer-events: none. The comment reads "Keep page interactive while view transitions are running."

5. Switch animations by type, and carry the type across pages

View Transition types let one set of CSS support several transitions:

html:active-view-transition-type(page-navigation) main[data-page-transition-enabled='true'] {
  view-transition-name: main-content;
}
html:active-view-transition-type(product-image-transition) {
  [data-view-transition-type='product-image-transition'] { view-transition-name: product-image-transition; }
  [data-view-transition-type='product-details']          { view-transition-name: product-details; }
}

The catch: the type is decided in the old page's pageswap, but the new page's pagereveal also needs to know it. Horizon's approach:

  1. When the user clicks a product card, handleViewTransition in product-card.js adds data-view-transition-type="product-image-transition" and data-view-transition-triggered to the card gallery element (ref="cardGallery"). Clicks on buttons, inputs and other interactive elements, or events already preventDefault-ed, are ignored;
  2. In the old page's pageswap, it finds the element carrying the triggered marker, puts its type into viewTransition.types, and also writes it to sessionStorage; with no triggered element, the type is page-navigation and the storage entry is cleared;
  3. In the new page's pagereveal, it reads the type back from sessionStorage and sets it. After the transition finishes it switches the type back to page-navigation, then clears the storage and the page's data-view-transition-type attributes in an idle callback.

One more cleanup detail: if the user lands directly on a product page, the first image of the media gallery already carries data-view-transition-type. In pageswap, Horizon first removes every such attribute without the triggered marker, so there are no duplicate view-transition-name values; duplicate names make the transition fail.

The card image and the product page image must match

Product cards can browse through several images, so when the user clicks, the card may be showing the second image while the product page's first paint shows the featured image. Transitioning directly would look like one picture "turning into" another.

On click, product-card.js finds the currently visible image and compares its currentSrc with data-featured-media-url. If they differ, it plays a 125ms opacity animation (0.8 to 1) and, when it finishes, swaps the srcset to the featured image, so both ends of the shared element show the same content.

6. A blocklist for in-app WebViews

function shouldDisableCrossDocumentViewTransitions(ua = navigator.userAgent) {
  const androidWebView = /\bAndroid\b/i.test(ua) && /;\s?wv\)/i.test(ua);
  const knownInAppBrowser = /\b(FBAN|FBAV|FB_IAB|FBIOS|Instagram|musical_ly|Bytedance|BytedanceWebview|trill|TikTok)(?:\b|_)/i.test(ua);
  return androidWebView || knownInAppBrowser;
}

The source comment says June 2026 testing found that the Chromium WebView used by in-app browsers on Android, such as Facebook, Instagram and TikTok (UA containing ; wv)), can freeze or white-screen during cross-document transitions.

On a match it does two things:

  1. Injects @view-transition { navigation: none; }. The later rule wins the cascade and overrides the earlier opt-in;
  2. Removes the render-blocking <link> outright, so first paint can't be held by a transition that's doomed to fail.

For stores with a lot of traffic from social ads, this is worth far more than the transition animation itself.

7. In-page transitions reuse the same checks

When filters change outside the dialog, assets/facets.js uses the same-document startViewTransition, wrapped in utilities.js:

  • It checks API support, low-power devices, reduced motion and the WebView blocklist the same way; if any applies, it just runs the callback;
  • Before a product-grid transition, in an idle callback, getCardsToAnimate estimates how many cards fit in the visible area (using the card size of the grid's "zoomed-out" state, which covers both densities before and after filtering). In DOM order, the first N cards get a view-transition-name and the rest get content-visibility: hidden, reducing the number of snapshots;
  • The current transition's finished promise is stored in viewTransition.current, so code such as Scheduler can wait for the transition to end before touching the DOM (see taming layout thrashing).

Things worth questioning

  • Three checks, two copies each. view-transitions.js is an inlined classic script that can't import modules, so isLowPowerDevice, prefersReducedMotion and the WebView detection each have a copy in utilities.js, kept consistent only by "keep in sync" comments. Horizon has no build step, so generating the inline script from a single source would mean introducing one first.
  • A comment has fallen behind the implementation. The comment on disableCrossDocumentViewTransitions says the injected rule "defeats base.css," but the opt-in has moved to view-transition-opt-in.liquid, as base.css's own comment says.
  • The UA blocklist needs ongoing maintenance. The comment says "Remove check if ever resolved," but there's no tracking link or version condition, so it can easily become permanent code.
  • The card estimate takes the first N cards in DOM order. N comes from the grid's visible height in the viewport, but picking which cards doesn't skip those already scrolled above the viewport. Filter after scrolling well down the grid and the cards actually visible may not be among the first N, so they won't take part in the transition.
  • The low-power check is coarse. deviceMemory is only supported in Chromium-based browsers, so on Safari it effectively relies on hardwareConcurrency alone.

Summary

Horizon's stance on cross-document transitions can be summed up as on by setting, with a fallback everywhere.

SituationHandling
Main content parses slowlyBlocked at most until 1.8 seconds after navigation start
First visit, reloadBlocking removed immediately
Reduced motion, low-power deviceNo blocking and no transition
User interactionAnimation skipped immediately
Known-bad WebViewOpt-in disabled at the CSS level

If you plan to add cross-document transitions to a Shopify theme, this table is more worth copying than the animation itself.

Horizon's LICENSE.md forbids distributing themes derived from its code; this article only analyzes the source, so implement any borrowed ideas yourself.

Source baseline