React View Transitions: The Native Way to Animate Route Changes
React's View Transitions API finally makes route animations native — no framer-motion overhead, no CSS hacks. Here's how to wire it up properly in 2026.
Why View Transitions Matter for React Apps
Honestly, most React route animations are a mess. You're installing Framer Motion, wiring up AnimatePresence, fighting with exit animations that fire at the wrong time, and watching your bundle climb past 200 KB just so a page can fade in. It doesn't have to be this way.
The browser's View Transitions API has been shipping in Chrome since 111, Safari since 18, and Firefox caught up in 2025. It's a first-class platform feature. No library. No dependency. Just a single document.startViewTransition() call that tells the browser to snapshot the current state, run your update, snapshot the next state, and tween between them.
React 19 ships with built-in support for this through the startTransition + View Transition integration. When you call startTransition around a navigation, React can optionally trigger a cross-fade or custom transition. It's not magic — you still write the CSS — but the plumbing is handled for you.
This article shows you how to actually use it. Not the toy examples. The real patterns that hold up in production.
How document.startViewTransition Works Under the Hood
The API is surprisingly simple. You call document.startViewTransition(callback), where the callback mutates the DOM. The browser captures a screenshot before, runs your callback, captures a screenshot after, and then plays a CSS animation between those two states. By default it's a 0.25s cross-fade — which is actually decent.
What makes it interesting is view-transition-name. You assign this CSS property to specific elements, and the browser tracks them individually across the transition. So if you have a product card on a list page and a hero image on the detail page, you name both the same thing and the browser morphs one into the other. That's the shared-element transition you've seen in native mobile apps since 2015, finally landing in the browser.
Under the hood, the browser creates a pseudo-element tree: ::view-transition, ::view-transition-old(), and ::view-transition-new(). You can target those with CSS to override timing, easing, or even swap in completely different keyframes. The default uses animation-duration: 0.25s and animation-timing-function: ease. You'll almost always want to tweak at least the duration.
Wiring View Transitions into React Router v7
React Router v7 ships with a viewTransition prop on <Link> and <NavLink>. That's it. One prop. When it's present, React Router wraps the navigation in document.startViewTransition() for you. No configuration needed.
Here's a working example with a named shared element transition:
// routes/ProductList.tsx
import { Link } from 'react-router';
type Product = { id: string; name: string; image: string };
export function ProductList({ products }: { products: Product[] }) {
return (
<ul className="grid grid-cols-3 gap-8">
{products.map((p) => (
<li key={p.id}>
<Link to={`/products/${p.id}`} viewTransition>
<img
src={p.image}
alt={p.name}
style={{
viewTransitionName: `product-image-${p.id}`,
borderRadius: '12px',
}}
/>
<span>{p.name}</span>
</Link>
</li>
))}
</ul>
);
}
// routes/ProductDetail.tsx
import { useParams } from 'react-router';
import { useProduct } from '../hooks/useProduct';
export function ProductDetail() {
const { id } = useParams();
const product = useProduct(id!);
return (
<div>
<img
src={product.image}
alt={product.name}
style={{
viewTransitionName: `product-image-${id}`,
width: '100%',
borderRadius: '16px',
}}
/>
<h1>{product.name}</h1>
</div>
);
}The view-transition-name must be unique on the page at any given time. If two elements share the same name simultaneously, the browser silently drops the transition for both. That's a footgun worth knowing — especially if you render the list and detail on the same screen in a split layout.
Custom Animations with CSS View Transition Pseudo-Elements
The default cross-fade is fine for a prototype. For production you'll want custom timing at minimum. Here's a setup that does a slide-in from the right for forward navigation and slide-from-left for back navigation — the pattern users expect from mobile apps.
/* global.css */
/* Override the root transition */
::view-transition-old(root) {
animation: 280ms cubic-bezier(0.4, 0, 0.2, 1) both slide-out-to-left;
}
::view-transition-new(root) {
animation: 280ms cubic-bezier(0.4, 0, 0.2, 1) both slide-in-from-right;
}
/* Reverse for back navigation — add a class to <html> */
html.navigate-back::view-transition-old(root) {
animation-name: slide-out-to-right;
}
html.navigate-back::view-transition-new(root) {
animation-name: slide-in-from-left;
}
@keyframes slide-out-to-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-from-right {
from { transform: translateX(100%); }
}
@keyframes slide-out-to-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-from-left {
from { transform: translateX(-100%); }
}
/* Shared element: product image morphs between pages */
::view-transition-old(product-hero),
::view-transition-new(product-hero) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}Notice the cubic-bezier(0.34, 1.56, 0.64, 1) on the shared element — that's a spring-like overshoot curve. It makes the image feel like it snaps into place rather than just sliding. Combine this with background effects like the ones in our aurora background component and you get a pretty striking page transition experience.
For back navigation detection, you can set html.navigate-back in a window.navigation listener if the browser supports it, or fall back to tracking history.state yourself. It's 12 lines of JavaScript and worth it.
Using View Transitions with Next.js App Router
Next.js App Router as of 15.2 doesn't give you a first-party viewTransition prop the way React Router does. But you can wire it up manually in about 30 lines. The trick is intercepting router.push() calls and wrapping them in document.startViewTransition().
Create a custom hook that wraps useRouter from next/navigation:
// hooks/useViewTransitionRouter.ts
'use client';
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';
export function useViewTransitionRouter() {
const router = useRouter();
const push = useCallback(
(href: string) => {
if (!document.startViewTransition) {
router.push(href);
return;
}
document.startViewTransition(() => {
router.push(href);
});
},
[router]
);
return { ...router, push };
}
// Usage in a component
'use client';
import { useViewTransitionRouter } from '@/hooks/useViewTransitionRouter';
export function ProductCard({ id, name }: { id: string; name: string }) {
const router = useViewTransitionRouter();
return (
<button onClick={() => router.push(`/products/${id}`)}>
{name}
</button>
);
}The if (!document.startViewTransition) guard is not optional. Firefox had View Transitions behind a flag until mid-2025, and you might still have users on older builds. The graceful fallback to a plain navigation is exactly what you want — the feature should enhance, not break.
Performance Considerations and Browser Support Gaps
Here's something you won't read in the MDN docs: shared element transitions can cause layout thrash if you're not careful. The browser composites those pseudo-elements on the GPU, which is good, but if the element's dimensions change between pages (e.g., a 120x120 thumbnail morphing to an 800px hero), the browser has to compute that geometry. Keep your images on the compositor layer — use will-change: transform on elements you're transitioning — and you'll stay at 60fps.
What about prefers-reduced-motion? You have to handle that yourself. The View Transitions API doesn't automatically respect it. Wrap your CSS in a media query and drop to instant transitions for users who've opted out:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.001ms !important;
}
}Browser support is at roughly 89% globally as of late 2026, with Firefox 128+ and Safari 18+ both shipping full support. The remaining 11% is mostly older Android WebViews and some enterprise Chromium forks. The fallback is just no animation, which is perfectly acceptable. If your analytics show heavy Firefox ESR usage, budget an extra week to test there — ESR moves slower than release.
Does this replace Framer Motion entirely? Probably not for complex interaction-driven animations like drag gestures or spring physics. But for route-level transitions and shared element morphs, you can drop the library dependency entirely and save 45 KB gzipped.
Pairing View Transitions with Tailwind v4 and CSS Variables
Tailwind v4.0.2 brings native CSS variable support that plays nicely with view transition theming. Instead of hardcoding transition colors, you can drive them from your design tokens. If you're already using a theme toggle component, this becomes really clean — the transition can actually cross-fade between dark and light states as part of the animation.
Set your transition background via a CSS variable: --transition-overlay: rgba(255,255,255,0.15) in light mode and rgba(0,0,0,0.15) in dark mode. Then apply it to ::view-transition-image-pair(root) as a backdrop. You get a subtle color wash that matches the theme rather than a harsh flash.
The other thing worth knowing: Tailwind's @starting-style support in v4 means you can use entering animations on elements that were just added to the DOM — which pairs well with view transitions where new content slides in. You'd use starting:opacity-0 starting:translate-x-4 on your page wrapper and let the browser handle the rest. The combination of @starting-style and View Transitions is one of those things that makes CSS feel like a real animation system rather than an afterthought. Check out how it complements purely decorative effects like particles and motion backgrounds for landing pages where you want both interactivity and visual flair.
Real-World Patterns Worth Stealing
A few patterns that show up repeatedly in production codebases. First: stagger the transition names by index for list-to-detail flows. Use view-transition-name: item-${index} on list items and view-transition-name: item-${index} on the detail page. The catch is you need to store that index in the URL or in session state so the detail page knows which transition name to use. A search param like ?from=3 works.
Second: use view-transition-name: none to explicitly opt elements out of the transition. Navigation bars, modals, and toast containers should almost always be excluded — you don't want your nav bar to fade out and back in during every route change.
Third: for SaaS dashboards where users navigate between heavily data-driven pages, consider skipping shared element transitions entirely and just doing a fast root cross-fade at 150ms. Users aren't looking at the animation — they're looking at the new data. A snappy cross-fade is less disorienting than a complex morph that takes 350ms.
If you're building something with heavier visual effects — spotlight hovers, shooting stars on hero sections — the spotlight effect component from Empire UI handles its own animation layer independently of View Transitions, so they compose without conflicts. That's intentional in the Empire UI architecture: effects run on isolated canvas or pseudo-element layers that don't interfere with view-transition captures.
FAQ
No. The View Transitions API is a browser feature, not a React feature. You can call document.startViewTransition() in any React version as long as you wrap a DOM mutation. React 19's integration just makes it more convenient by tying into startTransition, but it's not required.
The most common cause is that the view-transition-name is still present on the old page during the back navigation, creating two elements with the same name simultaneously. The browser silently drops the transition. Use React Router's useNavigationType or Next.js's usePathname to conditionally apply the transition name only on the current active route.
Give your header view-transition-name: header and your page content view-transition-name: page-content. Then in CSS, set ::view-transition-old(header), ::view-transition-new(header) { animation: none; }. The header won't animate; only the content area will.
Yes, but with caveats. The transition callback needs to resolve for the browser to play the animation. If your navigation triggers a Suspense boundary, the DOM update is deferred and the browser waits — which can make the outgoing screenshot hold on screen until data loads. Pair it with a small timeout or use optimistic navigation to avoid the freeze.
Yes. document.startViewTransition() returns a ViewTransition object with .ready, .finished, and .updateCallbackDone promises. You can use .ready to know when the pseudo-elements are in the DOM (good for triggering additional animations), and .finished to run cleanup after the transition completes.
It works but you need to use the style prop rather than a CSS class, because view-transition-name values need to be unique per element and are usually dynamic. Most CSS-in-JS libraries don't have special handling for it, so inline style is the practical approach: style={{ viewTransitionName: 'my-element' }}.