Detecting View Transition Support with @supports and JavaScript
Feature detection for the View Transitions API happens on two independent surfaces: CSS declarations (view-transition-name, the ::view-transition-* pseudo-elements) and a JavaScript method (document.startViewTransition). A page can get one surface right and still break on the other — guarded CSS with an unguarded startViewTransition() call throws a TypeError in Firefox stable and Safari 17, and because the DOM update lives inside the callback, the navigation itself never happens. This page builds the complete detection stack step by step, within the Scroll-Driven & View Transition Implementation Patterns section; for the per-engine version numbers each check maps onto, see the parent View Transition browser support matrix.
When to use this approach
- You call
document.startViewTransition()anywhere. The JS guard (Steps 2–3) is mandatory. Firefox stable has no implementation, Safari before 18 has none, and an unguarded call fails the DOM update itself, not just the animation. - You assign
view-transition-nameor style::view-transition-*pseudo-elements. The CSS guard (Step 1) keeps that styling out of engines that would ignore or misparse it, and keeps your stylesheet honest about which tier gets what. - You use cross-document transitions. The
@view-transitionat-rule self-gates (Step 4), but yourpageswap/pagerevealcustomization code does not — it needs its own event detection. - You are tempted to UA-sniff instead. Don’t. Safari shipped same-document support in 18.0 and cross-document in 18.2, and Firefox’s implementation is in active development behind a pref — any UA table is stale within months, while feature detection tracks reality automatically. This is the same discipline as the site-wide browser support and progressive enhancement approach.
Skip this approach only when transitions are hand-rolled with the FLIP technique instead of the native API — the View Transition vs FLIP comparison covers when that trade is worth making.
Implementation
Step 1: Gate all transition CSS with @supports
Every view-transition-name assignment and every ::view-transition-* rule belongs inside a feature query. The canonical test is the property’s initial value, which parses in every supporting engine and has no side effects:
/* Baseline outside the guard: nothing transition-related at all */
@supports (view-transition-name: none) {
.page-header {
view-transition-name: page-header;
}
.hero__figure {
view-transition-name: hero-figure;
}
/* Customize the generated pseudo-element tree */
::view-transition-old(hero-figure),
::view-transition-new(hero-figure) {
animation-duration: 260ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Reduced motion nests INSIDE the guard — it only needs
to exist where the transition styling exists */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.01ms !important;
}
}
}
Two details matter here. First, use none, not a real identifier — @supports (view-transition-name: header) also works, but testing the initial value signals intent and avoids any chance of a typo becoming a live declaration if the block is later refactored. Second, detect newer sub-features separately: @supports (view-transition-class: none) is false in Chrome 111–124 even though the query above is true there. One guard per feature family.
The alternative form @supports selector(::view-transition) tests the pseudo-element tree directly. In practice the two queries agree in every shipping engine; prefer the property form for consistency with the rest of your feature queries.
Step 2: The JavaScript existence check
The JS surface is a single method on Document.prototype, so detection is one expression:
const supportsViewTransitions = 'startViewTransition' in document;
Use the in operator against document, not a truthiness check like if (document.startViewTransition). Both work today, but in reads as an existence test, survives the method being wrapped or bound by other code, and matches how you will detect the cross-document events later ('onpagereveal' in window).
One thing this check cannot tell you: which transition features exist. A true result in Safari 18.0 still means no cross-document support and (depending on the exact 18.x version) possibly no view-transition-class. The check gates the method call — the feature-by-feature matrix gates your expectations.
Step 3: The wrapper with graceful fallback
Wrap the check once and route every DOM swap through it. The non-negotiable design rule: the update callback runs on every path. Supported or not, reduced motion or not, the DOM must end in the same state:
/**
* Runs `update` inside a View Transition when the browser supports it
* and the user has not requested reduced motion; otherwise runs it
* directly as an instant swap.
*
* Always returns a promise that resolves when the DOM update is done.
*/
function transitionTo(update) {
const reduceMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!('startViewTransition' in document) || reduceMotion) {
// Graceful fallback: same mutation, no snapshots, no animation.
// Promise.resolve() normalizes sync and async update callbacks.
return Promise.resolve(update());
}
const transition = document.startViewTransition(update);
// updateCallbackDone resolves when the DOM mutation completes,
// even if the animation itself is skipped or fails — the closest
// analogue to what the fallback path returns.
return transition.updateCallbackDone;
}
Usage stays identical at every call site, which is the entire point:
link.addEventListener('click', async (event) => {
event.preventDefault();
await transitionTo(() => renderRoute(link.href));
document.querySelector('h1')?.focus({ preventScroll: true });
});
Return updateCallbackDone rather than finished when callers only need to know the DOM is ready — finished waits for the animation, so awaiting it in the fallback-vs-supported case would give the two paths different timing characteristics. If a caller genuinely needs animation completion (scroll restoration, cleanup of temporary view-transition-name values), expose the transition object from the wrapper as an optional second capability rather than changing the promise contract. Router-level integrations of this wrapper are covered in the SPA page-swap animation patterns guide.
The reduced-motion branch deliberately takes the fallback path instead of relying on the CSS duration override from Step 1: skipping startViewTransition() avoids allocating GPU snapshot textures for a transition nobody will see.
Step 4: The cross-document opt-in guard
Cross-document transitions between same-origin pages need no JavaScript detection at all, because the at-rule is ignored by engines that do not implement it:
/* In the CSS of BOTH the outgoing and incoming page */
@view-transition {
navigation: auto;
}
That is the whole guard: Chrome 126+ and Safari 18.2+ animate the navigation, everything else navigates instantly. Note that @supports cannot detect this — feature queries test declarations and selectors, not at-rules — so there is nothing to wrap the at-rule in, and nothing you need to.
JavaScript customization of cross-document transitions is a different story. The pageswap and pagereveal events do need detection, and event.viewTransition needs a null check because the events also fire for navigations where no transition runs:
if ('onpagereveal' in window) {
window.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) return; // navigation without a transition
// e.g. retarget names based on where the user came from
document.documentElement.dataset.transitioning = 'true';
event.viewTransition.finished.then(() => {
delete document.documentElement.dataset.transitioning;
});
});
}
The behavioral split between this path and the Step 3 wrapper — what snapshots when, which document runs which half — is mapped out in same-document vs cross-document View Transitions.
Verification
Verify the detection stack once per engine tier, not once total. The four tiers behave differently by design:
- Chrome or Edge (current stable) — full support. DevTools console:
'startViewTransition' in document→true. Trigger a swap and confirm the Animations drawer shows::view-transition-*activity. Then emulate reduced motion (Rendering tab →prefers-reduced-motion: reduce), trigger again, and confirm the swap is instant and the DOM still updated. - Safari 18.0–18.1 — same-document only. The wrapper takes the animated path; cross-document navigations are instant despite the at-rule being present on both pages. Confirm no console errors on multi-page navigation — silence plus instant navigation is the correct result.
- Firefox stable — no support.
'startViewTransition' in document→false. Every swap must complete instantly with the final DOM state identical to Chrome’s. This is the tier that catches update-callback bugs: if content goes missing here, your mutation logic was leaning on the transition. - Firefox Nightly with
dom.viewTransitions.enabled— in development. Flip the pref inabout:configand re-run tier 1’s checks as an early-warning pass. Expect partial behavior and do not gate anything on it.
Automated coverage: assert both branches of the wrapper by deleting the method in one test —
// Playwright — force the fallback branch in a full-support browser
await page.addInitScript(() => {
delete Document.prototype.startViewTransition;
});
await page.goto('/');
await page.click('a[href="/pricing/"]');
await expect(page.locator('h1')).toHaveText('Pricing'); // DOM still swapped
The same assertion runs without the init script to cover the animated branch, and with page.emulateMedia({ reducedMotion: 'reduce' }) to cover the motion-preference branch.
Edge cases and gotchas
The update callback must never be conditional on support.
The single worst failure mode is writing if (supported) { document.startViewTransition(() => render(url)); } with no else. On Firefox stable that code renders nothing. Every branch calls render(url); only the wrapping differs.
startViewTransition rejects — it does not throw synchronously — for most runtime failures.
Duplicate view-transition-name values or a rejected update callback surface on transition.ready / transition.finished as rejected promises. If you await transition.finished without a .catch, an aborted animation becomes an unhandled rejection even though the DOM updated fine. Catch and ignore animation-phase rejections; only updateCallbackDone rejections are real errors.
Feature queries answer per feature, not per API.
@supports (view-transition-name: none) passing does not imply view-transition-class (Chrome 125+, Safari 18.x line) or nested group support (newer Chromium only). Write one guard per feature you consume.
matchMedia results go stale in long-lived SPAs.
The wrapper re-queries prefers-reduced-motion on every call, which is the cheap and correct pattern. Caching matches in a module-level constant means a user who toggles the OS setting mid-session keeps getting the wrong branch.
Rapid successive calls skip earlier transitions.
A second startViewTransition() while one is running skips the first (its finished promise rejects with an AbortError-flavored skip). The fallback path has no such queueing, so double-clicks behave differently across tiers unless your router debounces navigation itself.
Server-side rendering.
'startViewTransition' in document touches document, which does not exist during SSR. Keep the wrapper inside event handlers or effects, never at module top level in code that also runs on the server.
Browser-specific notes
Chrome / Edge (Chromium)
Same-document support since 111, cross-document since 126, view-transition-class since 125, nested groups only in newer releases. Detection results are stable and DevTools has the best inspection surface: the Animations drawer captures the pseudo-element animations, and the Elements panel shows the ::view-transition tree on <html> during a transition.
Safari (WebKit)
Same-document from Safari 18, cross-document and view-transition-class from the later 18.x releases (18.2 for cross-document). The JS check returns true from 18.0, so remember it certifies only the method — verify sub-features against your minimum supported Safari version. Web Inspector’s animation tooling around the pseudo-tree is thinner than Chromium’s; the Playwright deletion trick above is often faster than manual inspection.
Firefox (Gecko)
No production support; the implementation is in active development behind dom.viewTransitions.enabled in Nightly (targeted at the 144+ line). Both detection checks correctly return false/fail in stable, so a properly wrapped codebase needs no Firefox-specific code at all — which is exactly the property you are verifying in tier 3 above.
All engines
The detection primitives themselves — @supports, the in operator, matchMedia — are universally supported far below any browser floor this site considers, so the guards can never themselves become a compatibility problem.
Related
- Implementing View Transitions for React Router — where the wrapper function lives in a real router integration
- How the View Transition API Works Under the Hood — the snapshot lifecycle behind the promises the wrapper returns
- Cross-Route Element Morphing — shared-element patterns that depend on correctly guarded
view-transition-nameCSS - Fallback Strategies for Legacy Browsers — designing what the instant-swap tier experiences
- How to Respect prefers-reduced-motion in CSS Animations — the motion-preference branch of the wrapper in full detail