EmpireUI
Get Pro
← Blog8 min read#page transitions#next.js#view transitions

Page Transitions in Next.js App Router: View Transitions API

How to add smooth page transitions in Next.js App Router using the View Transitions API — no libraries, just native browser APIs and a bit of wiring.

Abstract colorful gradient motion blur representing smooth animated page transitions

What the View Transitions API Actually Does

The View Transitions API lets the browser snapshot the current DOM, swap in the new content, then animate between the two snapshots — all without you hand-rolling GSAP timelines or dealing with exit animations that race against React unmounting. It landed in Chrome 111 in 2023, and as of mid-2026, Firefox has shipped it behind a flag while Safari is pushing support in the 18.x line.

The mental model is simple. You call document.startViewTransition(() => updateDOM()) and the browser handles the crossfade. By default you get a 250ms opacity fade — not exciting, but it's a working foundation you can restyle with one CSS rule.

Honestly, most developers overcomplicate this. You don't need a transition library. You don't need Framer Motion for route changes. What you need is to understand where the App Router hands you a seam to hook into, then do the minimum work to wire it up.

Why App Router Makes This Harder Than Pages Router

In the old Pages Router you had router.events — a simple pub/sub you could subscribe to and fire startViewTransition exactly when navigation started. App Router removed that. Navigation is now driven by React's concurrent rendering engine, and there's no equivalent synchronous event you can hook.

That said, a pattern emerged in 2024 that works reliably: wrap the router.push() calls you control in startViewTransition, and use a custom <Link> component that does the same. You won't catch every navigation edge case, but you'll cover 90% of real user journeys.

Worth noting: server components re-render on navigation too, which means your transition can't simply swap two pre-rendered snapshots the way a SPA would. The browser still needs to wait for the new RSC payload. Keep your transitions under 300ms so they don't feel like they're hiding a slow network.

Wiring It Up: The Custom Link Component

Here's the pattern that actually works. You create a TransitionLink component that intercepts clicks, fires startViewTransition, then calls router.push() inside the callback. React updates the DOM, the API animates the diff.

'use client';

import { useRouter } from 'next/navigation';
import { startTransition, useCallback } from 'react';
import Link, { LinkProps } from 'next/link';

type TransitionLinkProps = LinkProps & {
  children: React.ReactNode;
  className?: string;
};

export function TransitionLink({
  href,
  children,
  className,
  ...props
}: TransitionLinkProps) {
  const router = useRouter();

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLAnchorElement>) => {
      e.preventDefault();
      const target = href.toString();

      if (!document.startViewTransition) {
        router.push(target);
        return;
      }

      document.startViewTransition(() => {
        startTransition(() => {
          router.push(target);
        });
      });
    },
    [href, router]
  );

  return (
    <a href={href.toString()} onClick={handleClick} className={className} {...props}>
      {children}
    </a>
  );
}

A few things to call out in that snippet. The if (!document.startViewTransition) guard is non-negotiable — Firefox without the flag, older Chrome, and most crawlers will hit that branch. The startTransition wrapper from React tells the scheduler this update can be deferred, which plays nicer with concurrent features in Next.js 14+.

One more thing — don't use this component for external links or anchor links. The href.toString() assumption breaks in those cases. Keep TransitionLink internal-navigation-only and fall back to the native Next.js <Link> for everything else.

Customising the Animation with CSS

The default crossfade is fine for a content site. For a UI that has real visual weight — like the kind of interfaces you'd build from Empire UI components — you probably want something more intentional. Slide transitions, shared element morphing, or even a quick clip-path wipe.

The API exposes two pseudo-elements: ::view-transition-old(root) for the outgoing snapshot and ::view-transition-new(root) for the incoming one. Drop this in your global CSS:

/* global.css */
@keyframes slide-from-right {
  from {
    transform: translateX(30px);
    opacity: 0;
  }
}

@keyframes slide-to-left {
  to {
    transform: translateX(-30px);
    opacity: 0;
  }
}

::view-transition-old(root) {
  animation: 200ms ease-in slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out slide-from-right;
}

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

That 200ms / 300ms asymmetry is intentional. The exit snaps away fast so the enter feels like it has room to breathe. Play with those numbers — 30px translate is subtle enough that it doesn't feel like you're surfing between pages, but noticeable enough to read as intentional.

In practice, you'll also want to suppress the transition for back/forward navigation in some cases. The Navigation API (separate from View Transitions) gives you navigation.navigate events where you can detect navigationType === 'traverse' and skip the transition. Browser support is still catching up as of 2026, so test before you ship it.

Shared Element Transitions: The Real Power Move

Beyond page-level fades and slides, the API supports named view transitions — you give the same view-transition-name CSS property to an element on page A and its counterpart on page B, and the browser morphs between them automatically. It's the same trick you've seen in native mobile apps for years.

/* On the card thumbnail */
.card-image {
  view-transition-name: hero-image;
}

/* On the detail page hero */
.detail-hero {
  view-transition-name: hero-image;
}

The view-transition-name values must be unique per document — if you're rendering a list of cards, you need dynamic names like view-transition-name: card-${id}. In React, that means an inline style: style={{ viewTransitionName: \card-${item.id}\ }}. Tailwind doesn't support arbitrary view-transition-name values yet, so inline styles are your friend here.

If you're building anything with image-heavy layouts — portfolio grids, product galleries, the kind of thing you'd wire up alongside glassmorphism components for a high-end feel — shared element transitions are worth the extra 20 minutes to set up. They make your UI feel genuinely native.

Gotchas You'll Hit in Production

First gotcha: transitions don't fire on hard refreshes or on the initial page load. They're navigation-only, which is correct behaviour but surprises people who test by refreshing the page constantly.

Second: if your page has a lot of layout shift between the snapshot and the final render — think skeleton loaders that resolve to real content — the transition will look janky. The outgoing snapshot is pixel-perfect, but if the incoming page jumps around during hydration, you'll see a flash. Solve this by making sure your loading states are stable in size before you commit to a transition.

Third gotcha, and this one bites everyone eventually: view-transition-name values must be globally unique in the document at the moment the transition fires. If you accidentally render two elements with the same name — say, a sticky header image and a page body image both named hero — the API silently skips the transition for both. No error, no warning, just nothing. Check the DevTools "Animations" panel when debugging this. Quick aside: Chrome's DevTools added a dedicated View Transitions inspector in version 122 that's genuinely useful for catching this.

For performance, the API uses GPU compositing for the animation itself, so it's smooth at 60fps on mid-range hardware. But the snapshot capture can be slow if your page has hundreds of DOM nodes with complex paint. Keep your page complexity reasonable and you won't notice it. If you're building very dense UIs, consider scoping transitions to a specific element rather than the full root.

Where to Go From Here

The View Transitions API pairs well with the gradient generator if you're designing transition overlays — a quick gradient wipe between pages is two CSS rules and looks sharp. You can also use it in combination with CSS @starting-style for enter animations that don't need JavaScript at all.

Is this production-ready today? For Chrome-first apps, yes. For general web traffic in 2026, you need the progressive enhancement fallback, but that's just the if (!document.startViewTransition) guard you already wrote. The baseline experience is a normal navigation; the enhanced experience is a smooth animation. That's a good deal.

Look, the complexity ceiling here is low. You're not managing animation state, you're not fighting React's rendering cycle with exit animations, and you're not shipping a 40kb animation library. The hard part was understanding where the App Router gives you an entry point. Now you have it — go ship something that doesn't feel like a webpage from 2014.

FAQ

Does the View Transitions API work with Next.js App Router out of the box?

No, App Router doesn't wire it up automatically. You need a custom TransitionLink component that calls document.startViewTransition before triggering router.push(), as the old router.events API no longer exists.

What's the browser support situation in 2026?

Chrome has had it since version 111 (2023), Safari shipped it in 18.x, and Firefox supports it behind a flag. You need the if (!document.startViewTransition) fallback or you'll break those users entirely.

Can I use Framer Motion alongside View Transitions?

You can, but they'll fight over exit animations. Framer's AnimatePresence delays unmounting, which confuses the View Transitions snapshot. Pick one system per page — don't mix them.

Why is my shared element transition not animating?

Almost always a duplicate view-transition-name. The names must be globally unique at transition time — check for list items that accidentally share the same name, then use the Chrome DevTools Animations panel to confirm.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

View Transitions API: Cross-Document Animations in 2026Service Workers in Next.js: Offline Support, Background SyncCSS Scroll Animations in 2026: @scroll-timeline, animation-timeline and View TransitionsCSS View Transitions Advanced: Cross-Document, Custom Animations