Implementing View Transitions for React Router
React Router’s navigate() call and document.startViewTransition() have a timing conflict: the View Transitions API requires the DOM mutation to commit inside its callback synchronously, while React 18’s concurrent renderer batches and defers state updates across scheduler ticks. This page resolves that conflict with a working hook, explains dynamic view-transition-name assignment, and covers the scroll restoration edge cases that arise when SPA page swap animations meet React’s rendering model. It extends the patterns in the Scroll-Driven & View Transition Implementation Patterns pillar.
When to Use This Approach
Use the patterns on this page when:
- Your project uses React Router v6 (with
useNavigate) and you want animated route transitions without a separate animation library. - You are already using SPA page swap animations via
startViewTransitionand need to adapt the basic integration for React’s concurrent rendering model. - You need
view-transition-namemorphing between route-specific elements (hero images, list cards) without snapshot collisions from React’s element reuse. - You have
animation-timeline: scroll()on new-route content and are seeing scroll offset jumps or frozen progress indicators immediately after navigation.
Do not use these patterns when:
- Your app uses React Router’s
<Link>component with theunstable_viewTransitionprop (available in React Router v6.23+) — that built-in support handles thestartViewTransitioncall for you and requires no custom hook. - You are building an MPA (multi-page app) and want cross-document transitions — use
@view-transition { navigation: auto }in your CSS instead; no JavaScript is needed. - Animations are cosmetic only and reduced-motion users are your primary audience — consider skipping the transition layer entirely and relying on instant navigation.
The Core Timing Problem
The diagram below shows the lifecycle mismatch between startViewTransition and React 18’s concurrent scheduler, and how wrapping navigate() in React.startTransition bridges them.
Implementation
Step 1 — Feature-detect and build a navigation hook
The View Transitions API is not universal. Any call to document.startViewTransition must be guarded, with plain navigation as the fallback. The hook below also integrates React.startTransition to ensure the scheduler commits the route update synchronously inside the browser’s update callback window:
import { useNavigate } from 'react-router-dom';
import { startTransition, useCallback, useState } from 'react';
export function useViewTransitionNavigation() {
const navigate = useNavigate();
const [isTransitioning, setIsTransitioning] = useState(false);
const navigateWithTransition = useCallback((to: string) => {
// Graceful fallback for Firefox < 131, Safari < 18, older Chromium
if (!document.startViewTransition) {
navigate(to);
return;
}
setIsTransitioning(true);
const transition = document.startViewTransition(() => {
// startTransition yields to the browser's compositing thread;
// the scheduler flushes this update before releasing the callback
startTransition(() => {
navigate(to);
});
});
transition.finished
.catch(err => {
if (err.name !== 'AbortError') console.error('Transition failed:', err);
})
.finally(() => setIsTransitioning(false));
}, [navigate]);
return { navigateWithTransition, isTransitioning };
}
React.startTransition marks the navigate() call as a lower-priority update that must still flush within the current task. Do not call navigate() outside the startViewTransition callback — doing so commits the DOM before the browser has captured the outgoing snapshot.
Step 2 — Assign view-transition-name dynamically
Static view-transition-name values on components React reuses across routes cause snapshot collisions. The browser sees the same element in both old and new state, so there is nothing to morph. Assign names only during the active transition window using a requestAnimationFrame gate:
import { useEffect, useState } from 'react';
interface TransitionWrapperProps {
children: React.ReactNode;
routeKey: string; // derive from location.pathname
duration?: number; // ms — match your CSS transition duration
}
export const TransitionWrapper = ({
children,
routeKey,
duration = 400,
}: TransitionWrapperProps) => {
const [transitionName, setTransitionName] = useState<string | null>(null);
useEffect(() => {
// Assign on the next frame — after the old snapshot is captured
const frameId = requestAnimationFrame(() => {
setTransitionName(`route-${routeKey}`);
});
return () => cancelAnimationFrame(frameId);
}, [routeKey]);
// Clear after the transition window closes
useEffect(() => {
if (!transitionName) return;
const timerId = setTimeout(() => setTransitionName(null), duration);
return () => clearTimeout(timerId);
}, [transitionName, duration]);
return (
<div style={{ viewTransitionName: transitionName ?? undefined }}>
{children}
</div>
);
};
Derive routeKey from useLocation().pathname to guarantee a unique name per route. Using a stable static value (e.g. "main") across all routes will animate the element correctly once but will produce silent no-ops on subsequent navigations between pages that share the name.
Step 3 — Handle concurrent rendering interruptions
React’s concurrent renderer can interrupt rendering if a higher-priority update (user input, data fetch) arrives mid-transition. The browser surfaces an AbortError on transition.finished and leaves orphaned ::view-transition-old / ::view-transition-new pseudo-elements. Use a module-level transition registry that cancels the previous transition before starting a new one:
// transition-registry.ts
let activeTransition: ViewTransition | null = null;
export const safeNavigate = (
to: string,
navigate: (path: string) => void
): void => {
if (!document.startViewTransition) {
navigate(to);
return;
}
// Cancel any in-flight transition before starting a new one
if (activeTransition) {
activeTransition.skipTransition();
}
activeTransition = document.startViewTransition(() => {
startTransition(() => navigate(to));
});
activeTransition.finished
.catch(err => {
if (err.name !== 'AbortError') console.error('Transition error:', err);
})
.finally(() => { activeTransition = null; });
};
skipTransition() completes the transition instantly without animating, releasing compositor resources before the next capture.
Step 4 — Restore scroll position after transition.finished
When React Router calls window.scrollTo(0, 0) during route entry it fires before animation-timeline: scroll() engines re-initialize on the new route. Progress indicators and parallax elements jump or reset incorrectly. Disable browser scroll restoration globally and restore programmatically after the transition:
// Set once at application startup — before the router initialises
window.history.scrollRestoration = 'manual';
// In your navigation handler:
export async function navigateWithScrollSync(
to: string,
navigate: (path: string) => void
): Promise<void> {
const savedScrollY = window.scrollY;
const transition = document.startViewTransition(() => {
startTransition(() => navigate(to));
});
await transition.finished;
// Restore scroll only after the visual transition has committed.
// 'instant' avoids a smooth-scroll that would itself disturb the
// scroll() timeline initialization on the incoming page.
window.scrollTo({ top: savedScrollY, behavior: 'instant' });
}
Step 5 — Guard for prefers-reduced-motion
Call skipTransition() immediately inside the startViewTransition callback when reduced motion is active. The DOM still updates — the visual animation does not play:
const prefersReducedMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const transition = document.startViewTransition(() => {
startTransition(() => navigate(to));
});
if (prefersReducedMotion) {
transition.skipTransition();
}
await transition.finished;
You can also apply this at the CSS level by resetting animation-duration on the ::view-transition-* pseudo-elements, but the JavaScript approach prevents any compositor work from starting at all, which is preferable on low-power devices.
Verification
DevTools animation trace
- Open DevTools → Animations panel (three-dot menu → Animations in Chrome).
- Trigger a route change. A
ViewTransitiongroup should appear, containing an::view-transition-old(root)and::view-transition-new(root)track. - Scrub the animation playhead. Both tracks should animate
opacityonly (for the default crossfade) ortransform+opacityfor named elements. - If neither track appears, the update callback returned before React committed — check that
startTransitionwrapsnavigate().
Performance panel — updateDOM budget
The updateDOM callback must complete within 16 ms to keep the transition on the compositor at 60 fps:
- Performance → start recording → trigger a route change → stop.
- Find the
ViewTransition: updateCallbacktask in the Main thread lane. - Its duration must be under 16 ms. If it exceeds that, move non-critical DOM work outside the
startViewTransitioncallback, into code that runs aftertransition.finished.
Layers panel check
In DevTools → Layers, elements with an active view-transition-name must appear as dedicated compositor layers (shown with a Layerize marker). If a named element does not have its own layer, the GPU bitmap capture may stall and the morph will appear to skip.
Automated assertion
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('navigate triggers view transition and resolves', async () => {
// Mock startViewTransition so tests run in jsdom
const mockTransition = {
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: jest.fn(),
};
vi.spyOn(document, 'startViewTransition').mockReturnValue(mockTransition as any);
render(<AppWithRouter />);
await userEvent.click(screen.getByRole('link', { name: /about/i }));
await waitFor(() => {
expect(document.startViewTransition).toHaveBeenCalledTimes(1);
});
});
Edge Cases and Gotchas
ReactDOM.flushSync as an alternative to React.startTransition. Some guides recommend ReactDOM.flushSync(() => navigate(to)) inside the startViewTransition callback instead of React.startTransition. This forces a fully synchronous render and is simpler in practice. The trade-off: flushSync blocks the main thread for the duration of the render, which can push updateDOM past the 16 ms budget on large component trees. Prefer startTransition for routes with heavy initial renders; use flushSync only when startTransition results in an incomplete snapshot.
React Strict Mode fires effects twice in development. useEffect double-invocation in development causes the requestAnimationFrame assignment in TransitionWrapper to run twice. The second call overrides the first — the final view-transition-name is still correct — but the first cancelAnimationFrame call cancels a valid frame ID that no longer matches. This is benign in development and does not occur in production builds.
view-transition-name must be absent during SSR. React 18 with server-side rendering hydrates viewTransitionName from the server HTML. If the server sets a static name and the client-side TransitionWrapper initially renders with null, React’s hydration will emit a mismatch warning. Set viewTransitionName: undefined (not "") on the server-rendered shell, or use a useEffect-only assignment so the name is never present in the initial HTML.
Rapid navigation cancels in-flight React updates. If a user navigates again before transition.finished resolves, the safeNavigate registry calls skipTransition() on the previous ViewTransition object. React’s concurrent scheduler may still have the first navigate() in its queue. The new navigate() will supersede it, but the resulting history entries can be inconsistent. Add a debounce of 50–100 ms on the navigation trigger if rapid repeated clicks are possible.
Named element morphing conflicts with React Portal. A component rendered into a ReactDOM.createPortal target that sits outside the route container will receive a view-transition-name from TransitionWrapper, but the browser captures the portal’s DOM position — which may be in a fixed overlay or document.body child — not the logical position in the React tree. This causes the morph to animate from and to unexpected screen coordinates. Remove view-transition-name from portal-rendered elements, or assign the name to the portal’s mount point element directly.
Browser-Specific Notes
Chrome 111+ — document.startViewTransition shipped in Chrome 111 (March 2023). React.startTransition + startViewTransition integration works correctly: the scheduler flushes the navigate() call within the same macrotask as the startViewTransition callback. Compositor promotion for ::view-transition-* pseudo-elements is automatic.
Safari 18.0+ — View Transitions shipped in Safari 18.0 (September 2024). Safari’s handling of startViewTransition with concurrent React rendering is identical to Chrome’s — startTransition is the correct bridging approach. One Safari-specific issue: if the startViewTransition callback is an async function (returns a Promise), Safari 18.0 and 18.1 may capture the new snapshot before the awaited React render has committed. Use a synchronous callback with startTransition rather than an async callback with await.
Firefox 131+ — document.startViewTransition shipped in Firefox 131 (November 2024). The same startTransition pattern works. Firefox’s Animations panel does not display ViewTransition groups as distinct tracks — use the transition.ready and transition.finished promise hooks to observe lifecycle timing during debugging.
React Router v6.23+ built-in support — React Router v6.23 introduced an unstable_viewTransition prop on <Link> and <NavLink>, and a viewTransition option on the navigate() function. When this flag is set, React Router calls document.startViewTransition internally with ReactDOM.flushSync. If you upgrade to v6.23+ and adopt the built-in support, remove the custom hook and registry described on this page — layering both will cause double-transition calls.
Related
- SPA Page Swap Animations — parent page:
startViewTransitionsetup,::view-transition-old/newchoreography, and accessibility focus management - Cross-Route Element Morphing —
view-transition-namenaming strategy and GPU bitmap allocation for shared-element morphs - Implementing
prefers-reduced-motion— guard patterns for disabling transitions for users with vestibular sensitivities - Parallax Effects with Pure CSS —
animation-timeline: view()setup for the scroll-driven content that incoming routes need to initialize correctly