Next.js ViewTransition Not Working in App Router: Fix

Next.js ViewTransition not animating in the App Router? Here is the real fix for flashes, full reloads, broken shared elements, and transitions that never start.
Next.js ViewTransition Not Working in App Router: Fix
Next.jsReactApp Router
April 22, 20266 min read1028 words

The Problem

I hit this on a polished marketing build last week. Navigation was technically fast, but it felt abrupt. The product grid snapped into the detail page with no continuity, and on some routes the screen flashed black for a frame before the next page appeared. We already upgraded to Next.js 16, added transition styling, and still got a hard cut.

If your App Router navigation still feels like a full repaint after adding ViewTransition, one of five things is usually wrong:

  1. The tree is not wrapped in <ViewTransition>
  2. The navigation is triggering a document reload instead of a client transition
  3. The element you want to animate is being remounted with a new key
  4. The source and destination nodes do not share the same viewTransitionName
  5. A loading state or router.refresh() is blowing away the transition before it can settle

This is the setup I use when I want the animation to work reliably, not just in a demo.

What Actually Enables the Transition

The App Router does not infer view transitions just because your site uses Link. You need the wrapper from next/view-transition at the layout boundary that should animate:

// app/layout.tsx
import { ViewTransition } from 'next/view-transition'

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        <ViewTransition>{children}</ViewTransition>
      </body>
    </html>
  )
}

That wrapper is the switch that tells Next.js to coordinate navigation snapshots instead of replacing the old tree immediately. If it is missing, you can add all the view-transition-name styles you want and nothing will happen.

Why It Still Fails After You Add the Wrapper

1. Your link is not doing a soft navigation. I still see teams wire cards like this:

<button onClick={() => (window.location.href = `/blog/${slug}`)}>
  Read article
</button>

That is a full document navigation, not an App Router transition. Use Link or router.push() instead:

<Link href={`/blog/${slug}`}>Read article</Link>

If a proxy redirect, locale redirect, or auth gate rewrites the request into a full page load, the transition also dies. The page can be correct after navigation and still skip the animation because the browser reloaded the document.

2. The source card and destination hero do not share a stable name. For a shared-element effect, both sides need the same identifier:

<Link
  href={`/blog/${post.slug}`}
  style={{ viewTransitionName: `post-cover-${post.slug}` }}
>
  <img src={post.image} alt="" />
</Link>

And on the destination page:

<div style={{ viewTransitionName: `post-cover-${post.slug}` }}>
  <Image src={post.image} alt={post.title} fill />
</div>

If one side uses post-cover-123 and the other uses hero-123, the browser has nothing to match.

3. You are remounting the layout with unstable keys. Any random key at the shell layer breaks continuity:

<main key={Math.random()}>{children}</main>

That forces React to throw away the old subtree. The transition engine only works when the framework can capture a before/after snapshot of the same navigation boundary.

4. router.refresh() fires too early. I have seen forms submit, navigate, and immediately call router.refresh() in an effect. That refresh tears down the destination tree while the transition is still running. If you need fresh data, revalidate on the server and let the next navigation render the updated payload cleanly. If caching is involved, my Next.js 16 revalidateTag fix covers that side of the stack.

5. Your loading UI replaces the destination instantly. A full-screen loading.tsx is effectively a hard cut. If the target route suspends, the browser transitions into the loading shell instead of the final page. That can still look good, but it needs to be intentional.

A Pattern That Works

Here is the structure I keep coming back to:

// app/blog/page.tsx
<Link
  href={`/blog/${post.slug}`}
  className={styles.card}
  style={{ viewTransitionName: `post-card-${post.slug}` }}
>
  <span>{post.title}</span>
</Link>
// app/blog/[slug]/page.tsx
<article>
  <header style={{ viewTransitionName: `post-card-${slug}` }}>
    <h1>{post.title}</h1>
  </header>
</article>
.card {
  contain: layout paint;
}

Three things matter there:

  1. The names are identical on both routes
  2. The animated element is not wrapped in an unstable keyed parent
  3. The element has predictable layout so the browser is not trying to animate a shape that completely reflows mid-flight

If the effect feels janky, simplify the element first. Animate the card container or cover image before trying to animate six nested child nodes and a background blur.

Debugging Checklist

When a transition does not fire, I check these in order:

Open DevTools and watch for a document request. If navigation triggers a full HTML load, stop there. Fix the redirect, href, or router usage first.

Search for unstable keys. rg -n "key={" app components catches most of the accidental remounts.

Turn off loading fallbacks temporarily. If transitions suddenly start working, your loading.tsx shell is replacing the destination too early.

Remove router.refresh() and optimistic effects for one pass. You want to prove the base transition works before adding extra churn back in.

Check reduced motion. The browser respects user preferences. If motion is disabled at the OS level, you may see no transition and assume the code is broken.

The Practical Rule

Treat view transitions as a rendering contract, not a styling trick. The wrapper enables it, soft navigation preserves it, stable names connect it, and stable DOM makes it believable.

Most broken implementations are not failing because the API is bad. They fail because some other part of the page lifecycle is still behaving like an old full-page app.

Need This Fixed on a Live Build?

I clean up App Router navigation, transitions, caching, and production rendering issues on sites that already ship real traffic. If your UI feels almost right but not quite finished, start a project and I will tighten the implementation properly.

Back to blogStart a project