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.
Step-by-step resolution
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.
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.
Related
- View Transitions API Implementation — the parent topic
- Choosing Types for Route Direction — directional types in CSS
- View Transitions vs JavaScript Page Transition Libraries — when a library is still useful