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 startViewTransition and need to adapt the basic integration for React’s concurrent rendering model.
  • You need view-transition-name morphing 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 the unstable_viewTransition prop (available in React Router v6.23+) — that built-in support handles the startViewTransition call 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.

startViewTransition + React Router timing sequence Three swim-lanes: Browser, React Scheduler, and DOM. The browser captures an old-state snapshot when startViewTransition is called, then awaits the updateCallback. Inside the callback React.startTransition marks the navigate() call as a low-priority update. The React scheduler commits the new route synchronously in the current task, updating the DOM. The browser then captures the new-state snapshot and begins the GPU crossfade. transition.finished resolves after the animation completes. Browser React DOM time → startViewTransition() capture old-state bitmap React.startTransition( () => navigate(to) ) DOM commit new route renders capture new-state bitmap GPU crossfade begins ① call ② schedule ③ commit ④ animate

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

  1. Open DevTools → Animations panel (three-dot menu → Animations in Chrome).
  2. Trigger a route change. A ViewTransition group should appear, containing an ::view-transition-old(root) and ::view-transition-new(root) track.
  3. Scrub the animation playhead. Both tracks should animate opacity only (for the default crossfade) or transform + opacity for named elements.
  4. If neither track appears, the update callback returned before React committed — check that startTransition wraps navigate().

Performance panel — updateDOM budget

The updateDOM callback must complete within 16 ms to keep the transition on the compositor at 60 fps:

  1. Performance → start recording → trigger a route change → stop.
  2. Find the ViewTransition: updateCallback task in the Main thread lane.
  3. Its duration must be under 16 ms. If it exceeds that, move non-critical DOM work outside the startViewTransition callback, into code that runs after transition.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.


← SPA Page Swap Animations