Integrating View Transitions with Client-Side Routers

Part of View Transitions API Implementation in Modern View Transitions & Scroll APIs.

The problem

A single-page application adds document.startViewTransition(() => router.push(url)) to its link handler. The transition runs, but the “new” snapshot shows the old page, then the real new page pops in after the animation. On another route the page freezes for a second before animating, because the callback waits for an API request. Back-button navigation animates as if moving forward. And sometimes a transition is skipped with an error because a second click started a new one.

The API is simple; the integration point is where it goes wrong.

Root cause analysis: the callback must cover the DOM change, exactly

document.startViewTransition(callback) does three things in order. It captures the current page as the old state. It calls the callback, pausing rendering while the returned promise is pending. When the promise resolves, it captures the page again as the new state and animates between the two.

If the callback returns before the DOM changes, the new capture happens too early. router.push() in most frameworks schedules a render rather than performing it synchronously, so the callback returns immediately, the “new” snapshot is still the old page, and the real update lands after the animation. The callback must return a promise that resolves after the new route has committed to the DOM.

If the callback waits for data, rendering is paused for the whole wait. Users see a frozen page, and browsers abort the transition if the callback takes too long. Load data before starting the transition, or render a lightweight new state — a skeleton — inside the callback and let data arrive afterwards.

History navigation has direction. Clicking a link and pressing Back produce the same startViewTransition call unless you tell the transition which way it is going. Types passed at start let CSS choose forward or backward animations.

Overlapping transitions are skipped. Starting a new transition while one is running skips the running one. That is correct behaviour for rapid clicks, but code awaiting the old transition’s finished must handle the skip.

What happens when the callback resolves too earlyThe new snapshot is captured before the router renders, so the animation morphs old into old.What happens when the callback resolves too earlyCapture oldoldidle400 msCallbackidle400 msCapture newwaitidle400 msRouter renderswaitnew DOM400 msoldidlereturnswaitstill oldnew DOM
The new snapshot is captured before the router renders, so the animation morphs old into old.

Step-by-step resolution

Wiring transitions into navigationCapture old, render new inside the callback, resolve after commit.Wiring transitions into navigation1Intercept navigation in the router's before-navigate hook.A place to start the transition2Preload route data before starting the transition.Callback stays short3Call startViewTransition with a callback that performs or awaits the render.Old state captured first4Resolve the callback only when the router reports the navigation committed.New snapshot is the new page5Pass a forward or back type based on the navigation direction.History animates correctly6Skip the transition when reduced motion is preferred.
Capture old, render new inside the callback, resolve after commit.

Production code pattern

// Framework-neutral: using the Navigation API to intercept same-origin navigations.
const reduce = matchMedia('(prefers-reduced-motion: reduce)');

navigation.addEventListener('navigate', (event) => {
  if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
  const url = new URL(event.destination.url);

  event.intercept({
    async handler() {
      const data = await loadRoute(url);                       // BEFORE the transition starts
      const render = () => renderRoute(url, data);             // synchronous DOM commit

      if (!document.startViewTransition || reduce.matches) return render();

      const direction = event.navigationType === 'traverse'
        && event.destination.index < navigation.currentEntry.index ? 'back' : 'forward';

      const transition = document.startViewTransition({ update: render, types: [direction] });
      await transition.finished.catch(() => {});               // skipped transitions are fine
    },
  });
});
// SvelteKit-style hook: resolve the router's promise inside the transition callback.
onNavigate((navigation) => {
  if (!document.startViewTransition || reduce.matches) return;
  return new Promise((resolve) => {
    document.startViewTransition(async () => {
      resolve();                                               // let the router proceed
      await navigation.complete;                               // resolve AFTER the new page renders
    });
  });
});
html:active-view-transition-type(forward) ::view-transition-old(root) { animation-name: slide-out-left; }
html:active-view-transition-type(forward) ::view-transition-new(root) { animation-name: slide-in-right; }
html:active-view-transition-type(back)    ::view-transition-old(root) { animation-name: slide-out-right; }
html:active-view-transition-type(back)    ::view-transition-new(root) { animation-name: slide-in-left; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}

Rendering Impact: composite for the snapshot animations. Rendering is paused while the update callback is pending, so everything slow — data fetching, heavy component work — must happen before the transition starts or it becomes visible freezing.

The SvelteKit-style pattern works with any router that exposes a “navigation complete” promise: resolving the router’s hook lets it render, and awaiting completion inside the transition callback delays the new capture until the render lands. React routers typically need the state update flushed synchronously inside the callback so the DOM is committed before it returns.

Where the data fetch happensOnly one version keeps rendering responsive during the transition.Where the data fetch happensFetch inside the callbackRendering paused during the requestPage appears frozenLong waits abort the transitionVisible freezeFetch before startingPage stays interactive while loadingCallback only commits the DOMTransition starts with data readyShort, reliable callback
Only one version keeps rendering responsive during the transition.

Scroll restoration and focus

Route changes usually reset scroll to the top, or restore a previous position on Back. Do that inside the update callback, after the new DOM is committed, so the new snapshot captures the correct scroll position — otherwise the animation shows the new page at the old scroll offset, then jumps. Similarly, move focus to the new page’s main heading after the transition’s updateCallbackDone resolves so screen reader users land in the new content, as covered in focus management during route transitions.

Verification checklist

Constraints and trade-offs

  • Frameworks with asynchronous rendering need an explicit “rendered” signal to await.
  • Pausing rendering during the callback means long callbacks look like hangs.
  • The Navigation API is not available everywhere; routers may need their own hooks.
  • Full-page root transitions snapshot the whole viewport, which costs memory on large screens.
  • Streaming or partial rendering can capture incomplete new states.

Frequently asked questions

Why does my view transition show the old page as the new state?

The update callback returned before the router rendered the new route. Return a promise that resolves after the new DOM is committed.

Can I fetch data inside the startViewTransition callback?

Avoid it. Rendering is paused while the callback runs, so the page freezes. Fetch before starting and use the callback only to commit the DOM.

How do I animate back navigation differently?

Detect the navigation direction and pass it as a view transition type, then select animations with :active-view-transition-type().

What happens if a user clicks again during a transition?

The running transition is skipped and a new one starts. Handle the skipped transition’s finished promise so it does not throw.