Horizon's Cross-Document View Transitions: Render Blocking, Bail-Out Conditions and a WebView Blocklist
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:
- snippets/view-transition-opt-in.liquid: the opt-in switch and render blocking
- assets/view-transitions.js: the control script that snippets/scripts.liquid inlines into
<head> - assets/base.css: transition styles
- assets/product-card.js: the card trigger
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 fromnavigation: autoanyway;- The user prefers reduced motion;
- A low-power device:
hardwareConcurrency <= 2ordeviceMemory <= 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:
- When the user clicks a product card,
handleViewTransitioninproduct-card.jsaddsdata-view-transition-type="product-image-transition"anddata-view-transition-triggeredto the card gallery element (ref="cardGallery"). Clicks on buttons, inputs and other interactive elements, or events alreadypreventDefault-ed, are ignored; - In the old page's
pageswap, it finds the element carrying the triggered marker, puts its type intoviewTransition.types, and also writes it to sessionStorage; with no triggered element, the type ispage-navigationand the storage entry is cleared; - 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 topage-navigation, then clears the storage and the page'sdata-view-transition-typeattributes 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:
- Injects
@view-transition { navigation: none; }. The later rule wins the cascade and overrides the earlier opt-in; - 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-gridtransition, in an idle callback,getCardsToAnimateestimates 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 aview-transition-nameand the rest getcontent-visibility: hidden, reducing the number of snapshots; - The current transition's
finishedpromise is stored inviewTransition.current, so code such asSchedulercan wait for the transition to end before touching the DOM (see taming layout thrashing).
Things worth questioning
- Three checks, two copies each.
view-transitions.jsis an inlined classic script that can't import modules, soisLowPowerDevice,prefersReducedMotionand the WebView detection each have a copy inutilities.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
disableCrossDocumentViewTransitionssays the injected rule "defeats base.css," but the opt-in has moved toview-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.
deviceMemoryis only supported in Chromium-based browsers, so on Safari it effectively relies onhardwareConcurrencyalone.
Summary
Horizon's stance on cross-document transitions can be summed up as on by setting, with a fallback everywhere.
| Situation | Handling |
|---|---|
| Main content parses slowly | Blocked at most until 1.8 seconds after navigation start |
| First visit, reload | Blocking removed immediately |
| Reduced motion, low-power device | No blocking and no transition |
| User interaction | Animation skipped immediately |
| Known-bad WebView | Opt-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.