EponwebPractical guides to web development and technology
Frontend Engineering

Smooth SPA Navigation: Implementing the View Transitions API without Polyfills

Creating native iOS-style view transitions in Single Page Applications has become effortless by leveraging the browser's View Transitions API, eliminating the need for heavy JavaScript libraries.

Lucas Ferreira
Lucas FerreiraLead Frontend Engineer8 min read
Editorial image illustrating Smooth SPA Navigation: Implementing the View Transitions API without Polyfills

Rewriting the routing layer of a Single Page Application has historically been a painful exercise in balancing performance against visual fidelity. We used to rely on libraries like GSAP or Framer Motion to animate between routes, often adding significant kilobytes to our bundle and introducing synchronization bugs where the JavaScript state drifted away from the DOM.

In 2026, the landscape has shifted. The View Transitions API is no longer an experimental feature buried behind flags; it is a stable, cross-browser standard that handles the heavy lifting of capturing the current state, pausing the render, and animating to the new state. It allows us to create that slick, native iOS-style slide navigation with a fraction of the code we needed three years ago.

We will implement this strictly using native browser capabilities. No polyfills, no heavy frameworks, and no external CSS dependencies.

The Overhead of Third-Party Animation Libraries

Before we touch the code, it is worth establishing why we are avoiding libraries. When we moved our architecture to a more granular component model similar to what is discussed in React Server Components vs Client Components: When to Choose Which for Your E-commerce Cart, we realized that shipping animation code to the client was often redundant.

Libraries like Swiper or complex transition wrappers usually require the browser to calculate layout thrashing—forcing reflows multiple times per frame to track element positions. The native View Transitions API avoids this by taking a static snapshot of the DOM (a raster) and animating the pseud-elements representing the old and new states. This happens on the compositor thread, ensuring the main thread remains unblocked for user interactions.

Step 1: Detecting API Support

While browser support is excellent in 2026, covering Chromium, Firefox, and WebKit, we must never assume availability. We need a guard clause that falls back to an instant update if the API is missing.

Create a utility function in your navigation module. We check for the existence of document.startViewTransition.

function navigateWithTransition(url) {
  if (!document.startViewTransition) {
    // Fallback for older browsers or specific user settings
    window.location.href = url;
    return;
  }
  
  // The implementation will go here
}

This step is crucial. If a user is on a specialized device or an outdated enterprise browser, your application must still function. The API is designed to be progressive enhancement; the content updates, the animation is the bonus.

Step 2: Wrapping the DOM Update

The core mechanic involves wrapping your DOM mutation logic inside the document.startViewTransition callback. The browser captures the current page state before executing the callback, waits for the DOM to update, and then captures the new state after the callback resolves.

If you are using a client-side router, you likely have a function similar to updateRoute(path).

function navigateWithTransition(url) {
  if (!document.startViewTransition) {
    window.location.href = url;
    return;
  }

  document.startViewTransition(async () => {
    // 1. Fetch new data or content
    const newContent = await fetchPageData(url);
    
    // 2. Update the DOM
    // This specific selector depends on your app structure,
    // e.g., replacing the <main> tag or updating a root div.
    const mainElement = document.querySelector('main');
    mainElement.innerHTML = newContent.html;
    
    // 3. Update the URL history without reloading
    window.history.pushState({}, '', url);
  });
}

During this process, the browser freezes the rendering of the old state. The user sees a static image of the previous page while your JavaScript swaps the HTML strings in the background. Once the callback finishes, the browser un-freezes, holding both the old and new visual states in memory to perform the cross-fade.

How the Browser Calculates the Transition

By default, the API provides a simple cross-fade and a slight scale-up effect. This is because the default root animation moves the old state out and the new state in.

To customize this, we don't touch the JavaScript. We rely on CSS pseudo-elements that the API dynamically injects into the page. The most important one is ::view-transition-group(root). This element contains both the old and new page snapshots.

Photographic detail related to Smooth SPA Navigation: Implementing the View Transitions API without Polyfills

Understanding this structure allows us to override the default fade with a directional slide. We are essentially animating two images: the "old" page moving left and the "new" page entering from the right.

Step 3: Configuring the iOS-Style Slide Animation

To replicate the push-pop navigation found in iOS, we need to define a keyframe animation that moves the ::view-transition-old(root) to the left (negative X translation) and the ::view-transition-new(root) from the right (positive X translation to 0).

Add this to your global CSS file. Note that we place these inside the ::view-transition-old and ::new pseudo-elements.

/* Active only when a View Transition is running */
::view-transition-old(root),
::view-transition-new(root) {
  /* Prevent the default animation (fade/scale) */
  animation-duration: 0.4s;
}

@keyframes slide-out {
  from { transform: translateX(0); }
  to { transform: translateX(-100%); }
}

@keyframes slide-in {
  from { transform: translateX(100%); }
  to { transform: translateX(0); }
}

::view-transition-old(root) {
  animation-name: slide-out;
}

::view-transition-new(root) {
  animation-name: slide-in;
}

With just 20 lines of CSS, the browser now handles the complex interpolation of the page slide. The GPU acceleration is automatic because we are animating transform properties.

However, this implementation assumes every navigation is a "forward" push. To handle "back" navigation (where the page slides in from the left), we need a way to signal direction to our CSS.

Step 4: Differentiating Forward and Back Navigation

We can't determine direction purely from the API; we need to track it in our navigation logic. A simple way to do this is to set a class on the <html> or <body> element before starting the transition, based on the browser history stack.

Modify the JavaScript function:

function navigateWithTransition(url, isBack = false) {
  if (!document.startViewTransition) {
    window.location.href = url;
    return;
  }

  // Set direction class on the document element
  document.documentElement.className = isBack ? 'back-transition' : 'forward-transition';

  document.startViewTransition(async () => {
    const newContent = await fetchPageData(url);
    const mainElement = document.querySelector('main');
    mainElement.innerHTML = newContent.html;
    window.history.pushState({}, '', url);
  }).finished.finally(() => {
    // Clean up the class once animation completes
    document.documentElement.className = '';
  });
}

Now, update the CSS to handle the "back" scenario:

/* Forward navigation (default) */
::view-transition-old(root) {
  animation-name: slide-out;
}
::view-transition-new(root) {
  animation-name: slide-in;
}

/* Back navigation */
.back-transition::view-transition-old(root) {
  animation-name: slide-in-reverse; /* Moves old page FROM left TO center? No, usually old page stays or fades while new comes from left */
}

/* Corrected Back Logic: 
   New page enters from left (-100% -> 0)
   Old page exits to right (0 -> 100%) 
*/
@keyframes slide-in-from-left {
  from { transform: translateX(-100%); }
  to { transform: translateX(0); }
}

@keyframes slide-out-to-right {
  from { transform: translateX(0); }
  to { transform: translateX(100%); }
}

.back-transition::view-transition-old(root) {
  animation-name: slide-out-to-right;
}

.back-transition::view-transition-new(root) {
  animation-name: slide-in-from-left;
}

This specific logic ensures that when a user hits the "Back" button, the visual hierarchy feels physically accurate—the previous screen slides back in from the left, pushing the current screen out to the right.

Step 5: Managing Accessibility and Reduced Motion

Visual polish means nothing if it compromises usability. Accessibility is not an optional layer; it is a requirement for production-grade engineering. If a user has requested reduced motion via their operating system preferences (e.g., prefers-reduced-motion: reduce), we must suppress the animation entirely.

The View Transitions API respects the updateCaptureDone and ready promises, but CSS media queries are our most efficient tool here.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(root) {
    animation-duration: 0s !important;
  }
}

Additionally, we must ensure that focus management is handled correctly inside the transition callback. When the DOM swaps, focus is lost. We should programmatically set focus to the new main heading or the first interactive element.

document.startViewTransition(async () => {
    const mainElement = document.querySelector('main');
    mainElement.innerHTML = newContent.html;
    window.history.pushState({}, '', url);
    
    // Accessibility: Set focus to the new content
    const newHeading = mainElement.querySelector('h1');
    if (newHeading) {
        newHeading.focus(); // Ensure h1 has tabindex="-1" if it's not focusable by default
    }
});

We must also be careful about semantic HTML during the swap. If we replace a generic <div> with a semantic <main> or <article>, screen readers will announce the change correctly. As we discussed in 5 Semantic HTML Tags You Should Use Instead of Divs (And Why Screen Readers Prefer Them), using the correct container tags is vital for announcing page transitions to users who cannot see the slide animation.

Handling Transition Interruptions

A common pitfall in SPA navigation is the "double tap." A user clicks a link, the transition starts, and they immediately click another link. The native API handles this gracefully, but our JavaScript logic must not queue multiple DOM updates chaotically.

Because document.startViewTransition returns a promise, we can track the active transition state.

let transitionInProgress = false;

function navigateWithTransition(url, isBack = false) {
  if (transitionInProgress) return; // Skip or queue if needed
  
  transitionInProgress = true;

  // ... existing logic ...

  document.startViewTransition(async () => {
     // ... DOM updates ...
  }).finished.finally(() => {
    transitionInProgress = false;
    document.documentElement.className = '';
  });
}

This guard clause prevents the browser from trying to animate two conflicting DOM states, which would otherwise result in a jarring "jump" to the final destination or a visual glitch.

The Trade-Off of Snapshotting

While this approach is incredibly performant, it relies on the browser taking a screenshot of the page. This means that if you have a <video> element playing or a WebGL canvas running (like a Three.js scene), the screenshot might capture it correctly, but the "live" element underneath might freeze briefly.

For most content-heavy sites—blogs, dashboards, and e-commerce stores—this is a non-issue. The snapshot is rasterized instantly. However, if your SPA relies on high-frequency background updates, you will notice the animation freezes those elements for 400ms.

The Future of Navigation

The View Transitions API changes the mental model of routing. We stop thinking about moving elements from coordinate A to coordinate B and start thinking about state transitions. By delegating the animation to the browser's compositor, we gain 60fps performance even on mid-range mobile devices, with zero JavaScript payload cost for the animation logic itself.

As browsers continue to optimize rendering pipelines, APIs like this will render heavy animation libraries obsolete for standard UI tasks. The code we wrote today—around 50 lines of CSS and JS—does what used to require entire dependencies. That is the direction of frontend engineering in 2026: leaner, native, and increasingly capable.

Read next