View Transitions API with Next.js App Router: Smooth Page Animations Without Libraries

Page transitions have always been one of the hardest things to get right in web applications. Traditional approaches require heavy animation libraries, complex state management, and careful orchestration of entering and leaving elements. The View Transitions API changes everything by giving browsers native support for smooth transitions between page states.
In this tutorial, you will build a Next.js App Router application with buttery-smooth page transitions using nothing but the View Transitions API and CSS. No Framer Motion, no GSAP, no third-party animation libraries — just the platform.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed (Node 20 recommended)
- npm or pnpm as your package manager
- Intermediate knowledge of React and Next.js App Router
- Basic understanding of CSS animations and transitions
- A modern browser (Chrome 111+, Edge 111+, Safari 18+, Firefox 126+)
What You Will Build
By the end of this tutorial, you will have:
- A Next.js app with smooth crossfade transitions between pages
- Morph animations that seamlessly connect shared elements across routes
- Custom directional slide transitions based on navigation direction
- A reusable transition hook you can drop into any Next.js project
Understanding the View Transitions API
The View Transitions API works by capturing a screenshot of the current page state, performing the DOM update, then animating between the old and new states. The browser handles all the heavy lifting — you just tell it what to transition.
Here is the core concept:
// The basic API
document.startViewTransition(() => {
// Update the DOM here
updateThePage();
});When you call startViewTransition, the browser:
- Captures the current visual state as a screenshot
- Runs your callback to update the DOM
- Captures the new visual state
- Creates pseudo-elements (
::view-transition-oldand::view-transition-new) - Animates between old and new using CSS animations
The magic is that you control the animation entirely with CSS.
View Transition Pseudo-Elements
The browser creates a tree of pseudo-elements during a transition:
::view-transition
├── ::view-transition-group(root)
│ └── ::view-transition-image-pair(root)
│ ├── ::view-transition-old(root)
│ └── ::view-transition-new(root)
├── ::view-transition-group(header)
│ └── ::view-transition-image-pair(header)
│ ├── ::view-transition-old(header)
│ └── ::view-transition-new(header)
Each named transition group gets its own set of pseudo-elements, which you can animate independently.
Step 1: Project Setup
Create a new Next.js project with the App Router:
npx create-next-app@latest view-transitions-demo \
--typescript \
--tailwind \
--app \
--src-dir \
--import-alias "@/*"
cd view-transitions-demoInstall the only additional dependency you need — a small utility for conditional class names:
npm install clsxThat is it. No animation libraries.
Step 2: Enable View Transitions in Next.js
Next.js does not enable View Transitions by default. You need to opt in through the next.config.ts file:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
viewTransition: true,
},
};
export default nextConfig;This flag tells Next.js to wrap route navigations in document.startViewTransition() automatically. Without it, navigations happen instantly with no transition.
Step 3: Create the Basic Layout
Set up a layout with a navigation bar that persists across pages:
// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Link from "next/link";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "View Transitions Demo",
description: "Smooth page transitions with the View Transitions API",
};
const navItems = [
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
{ href: "/projects", label: "Projects" },
{ href: "/blog", label: "Blog" },
];
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<nav className="sticky top-0 z-50 border-b border-gray-200 bg-white/80 backdrop-blur-sm">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<Link
href="/"
className="text-xl font-bold text-gray-900"
style={{ viewTransitionName: "site-logo" }}
>
VT Demo
</Link>
<ul className="flex gap-6">
{navItems.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-gray-600 transition-colors hover:text-gray-900"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
</nav>
<main className="mx-auto max-w-5xl px-6 py-12">{children}</main>
</body>
</html>
);
}Notice the viewTransitionName on the logo. This is the key CSS property that tells the browser to morph this element between pages instead of crossfading the entire page.
Step 4: Add Global View Transition Styles
Add the base transition styles to your global CSS:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Default crossfade transition for the whole page */
::view-transition-old(root) {
animation: fade-out 0.3s ease-in-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in-out;
}
@keyframes fade-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.98);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: scale(1.02);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Morph animation for named elements (like the logo) */
::view-transition-group(site-logo) {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
}
/* Prevent layout shift during transitions */
::view-transition {
pointer-events: none;
}With just this CSS, every page navigation now has a smooth crossfade with a subtle zoom effect. The logo morphs seamlessly because it has a viewTransitionName.
Step 5: Build Pages with Shared Elements
Create the home page with project cards that will morph into their detail pages:
// src/app/page.tsx
import Link from "next/link";
const projects = [
{
id: "aurora",
title: "Aurora",
description: "A real-time collaboration platform built with WebSockets",
color: "bg-gradient-to-br from-purple-500 to-pink-500",
tags: ["React", "WebSocket", "Redis"],
},
{
id: "nebula",
title: "Nebula",
description: "Cloud-native deployment pipeline with zero-downtime releases",
color: "bg-gradient-to-br from-blue-500 to-cyan-500",
tags: ["Docker", "Kubernetes", "Go"],
},
{
id: "prism",
title: "Prism",
description: "AI-powered code review tool that catches bugs before they ship",
color: "bg-gradient-to-br from-amber-500 to-orange-500",
tags: ["Python", "ML", "TypeScript"],
},
];
export default function HomePage() {
return (
<div>
<h1
className="mb-4 text-5xl font-bold tracking-tight text-gray-900"
style={{ viewTransitionName: "page-title" }}
>
Welcome
</h1>
<p className="mb-12 text-xl text-gray-600">
Explore our latest projects with smooth transitions.
</p>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="group block"
>
<div
className={`${project.color} mb-4 h-48 rounded-2xl transition-shadow group-hover:shadow-xl`}
style={{
viewTransitionName: `project-image-${project.id}`,
}}
/>
<h2
className="mb-2 text-xl font-semibold text-gray-900"
style={{
viewTransitionName: `project-title-${project.id}`,
}}
>
{project.title}
</h2>
<p className="text-gray-600">{project.description}</p>
<div className="mt-3 flex gap-2">
{project.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700"
>
{tag}
</span>
))}
</div>
</Link>
))}
</div>
</div>
);
}Each project card has unique viewTransitionName values for its image and title. These names must match exactly on the detail page for the morph to work.
Step 6: Create the Project Detail Page with Morph Transitions
// src/app/projects/[id]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
const projects: Record<
string,
{
title: string;
description: string;
color: string;
tags: string[];
content: string;
}
> = {
aurora: {
title: "Aurora",
description: "A real-time collaboration platform built with WebSockets",
color: "bg-gradient-to-br from-purple-500 to-pink-500",
tags: ["React", "WebSocket", "Redis"],
content:
"Aurora enables teams to collaborate in real-time with sub-50ms latency. Built on a WebSocket backbone with Redis pub/sub for horizontal scaling, it supports live cursors, concurrent editing, and presence awareness across distributed teams.",
},
nebula: {
title: "Nebula",
description: "Cloud-native deployment pipeline with zero-downtime releases",
color: "bg-gradient-to-br from-blue-500 to-cyan-500",
tags: ["Docker", "Kubernetes", "Go"],
content:
"Nebula automates the entire deployment lifecycle from code push to production. With built-in canary deployments, automatic rollbacks, and comprehensive health checks, it ensures your services stay available during every release.",
},
prism: {
title: "Prism",
description:
"AI-powered code review tool that catches bugs before they ship",
color: "bg-gradient-to-br from-amber-500 to-orange-500",
tags: ["Python", "ML", "TypeScript"],
content:
"Prism uses machine learning models trained on millions of code reviews to identify potential bugs, security vulnerabilities, and performance issues before they reach production. It integrates directly into your CI pipeline.",
},
};
export default async function ProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = projects[id];
if (!project) notFound();
return (
<div>
<Link
href="/"
className="mb-8 inline-flex items-center text-gray-500 hover:text-gray-900"
>
← Back to projects
</Link>
<div
className={`${project.color} mb-8 h-64 rounded-2xl md:h-80`}
style={{ viewTransitionName: `project-image-${id}` }}
/>
<h1
className="mb-4 text-4xl font-bold text-gray-900"
style={{ viewTransitionName: `project-title-${id}` }}
>
{project.title}
</h1>
<div className="mb-6 flex gap-2">
{project.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-gray-100 px-4 py-1.5 text-sm font-medium text-gray-700"
>
{tag}
</span>
))}
</div>
<p className="text-lg leading-relaxed text-gray-700">
{project.content}
</p>
</div>
);
}When you click a project card on the home page, the colored gradient box smoothly morphs from its card size to the full-width hero. The title slides into its new position. Everything else crossfades. All of this happens with zero JavaScript animation code.
Step 7: Build a Custom Transition Hook
For more control over transitions, create a custom hook that lets you trigger transitions programmatically:
// src/hooks/use-view-transition.ts
"use client";
import { useCallback, useRef } from "react";
type TransitionCallback = () => void | Promise<void>;
interface ViewTransitionOptions {
onStart?: () => void;
onFinish?: () => void;
onError?: (error: unknown) => void;
}
export function useViewTransition(options: ViewTransitionOptions = {}) {
const transitionRef = useRef<ViewTransition | null>(null);
const startTransition = useCallback(
async (callback: TransitionCallback) => {
// Fallback for browsers without View Transitions support
if (!document.startViewTransition) {
await callback();
return;
}
options.onStart?.();
try {
const transition = document.startViewTransition(async () => {
await callback();
});
transitionRef.current = transition;
await transition.finished;
options.onFinish?.();
} catch (error) {
options.onError?.(error);
} finally {
transitionRef.current = null;
}
},
[options]
);
const skipTransition = useCallback(() => {
transitionRef.current?.skipTransition();
}, []);
return { startTransition, skipTransition };
}Use this hook for client-side state changes that should animate:
// Example: Animated tab switcher
"use client";
import { useState } from "react";
import { useViewTransition } from "@/hooks/use-view-transition";
export function AnimatedTabs() {
const [activeTab, setActiveTab] = useState(0);
const { startTransition } = useViewTransition();
const tabs = ["Overview", "Features", "Pricing"];
const switchTab = (index: number) => {
startTransition(() => {
setActiveTab(index);
});
};
return (
<div>
<div className="flex gap-1 rounded-lg bg-gray-100 p-1">
{tabs.map((tab, i) => (
<button
key={tab}
onClick={() => switchTab(i)}
className="relative rounded-md px-4 py-2 text-sm font-medium"
>
{activeTab === i && (
<span
className="absolute inset-0 rounded-md bg-white shadow-sm"
style={{ viewTransitionName: "active-tab" }}
/>
)}
<span className="relative z-10">{tab}</span>
</button>
))}
</div>
<div
className="mt-6"
style={{ viewTransitionName: "tab-content" }}
>
<p>Content for {tabs[activeTab]}</p>
</div>
</div>
);
}The active tab indicator morphs smoothly between positions, and the content crossfades — all driven by CSS.
Step 8: Directional Slide Transitions
One common pattern is sliding pages in different directions based on navigation. You can achieve this by adding CSS classes that control the animation direction:
/* src/app/globals.css — add these */
/* Slide-left transition (navigating forward) */
.slide-forward::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in-out;
}
.slide-forward::view-transition-new(root) {
animation: slide-in-right 0.3s ease-in-out;
}
/* Slide-right transition (navigating back) */
.slide-back::view-transition-old(root) {
animation: slide-out-right 0.3s ease-in-out;
}
.slide-back::view-transition-new(root) {
animation: slide-in-left 0.3s ease-in-out;
}
@keyframes slide-out-left {
to {
opacity: 0;
transform: translateX(-30px);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(30px);
}
}
@keyframes slide-out-right {
to {
opacity: 0;
transform: translateX(30px);
}
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-30px);
}
}Create a navigation wrapper that sets the direction class:
// src/components/directional-link.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type ComponentProps, useCallback } from "react";
type Direction = "forward" | "back";
interface DirectionalLinkProps extends ComponentProps<typeof Link> {
direction?: Direction;
}
export function DirectionalLink({
direction = "forward",
onClick,
...props
}: DirectionalLinkProps) {
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
// Set the transition class on documentElement before navigation
document.documentElement.classList.remove(
"slide-forward",
"slide-back"
);
document.documentElement.classList.add(
direction === "forward" ? "slide-forward" : "slide-back"
);
onClick?.(e);
},
[direction, onClick]
);
return <Link {...props} onClick={handleClick} />;
}Now use DirectionalLink in your pages:
// On the detail page back button
<DirectionalLink href="/" direction="back">
← Back to projects
</DirectionalLink>
// On the home page project cards
<DirectionalLink href={`/projects/${project.id}`} direction="forward">
View project →
</DirectionalLink>Step 9: Transition Groups for Independent Animations
Sometimes you want different parts of the page to animate independently. View Transition names let you create separate animation groups:
// src/app/blog/page.tsx
const posts = [
{ id: 1, title: "Getting Started with Rust", date: "2026-03-15" },
{ id: 2, title: "Why TypeScript 6 Matters", date: "2026-03-10" },
{ id: 3, title: "Edge Computing in 2026", date: "2026-03-05" },
];
export default function BlogPage() {
return (
<div>
<h1
className="mb-8 text-4xl font-bold"
style={{ viewTransitionName: "page-title" }}
>
Blog
</h1>
<aside
className="mb-8 rounded-xl bg-blue-50 p-6"
style={{ viewTransitionName: "blog-sidebar" }}
>
<p className="text-blue-800">Subscribe to get new posts delivered.</p>
</aside>
<div className="space-y-6">
{posts.map((post, index) => (
<article
key={post.id}
className="rounded-xl border p-6"
style={{
viewTransitionName: `blog-post-${post.id}`,
}}
>
<time className="text-sm text-gray-500">{post.date}</time>
<h2 className="mt-1 text-xl font-semibold">{post.title}</h2>
</article>
))}
</div>
</div>
);
}Add staggered animations for the blog posts:
/* src/app/globals.css — add these */
/* Staggered entrance for blog posts */
::view-transition-new(blog-post-1) {
animation: fade-in 0.3s ease-out 0.05s both;
}
::view-transition-new(blog-post-2) {
animation: fade-in 0.3s ease-out 0.1s both;
}
::view-transition-new(blog-post-3) {
animation: fade-in 0.3s ease-out 0.15s both;
}
/* Sidebar slides in from the side */
::view-transition-new(blog-sidebar) {
animation: slide-in-left 0.4s ease-out;
}
::view-transition-old(blog-sidebar) {
animation: slide-out-left 0.3s ease-in;
}Each named element transitions independently — the sidebar slides, posts stagger in, and the page title morphs. All declarative CSS, no JavaScript animation logic.
Step 10: Handling Reduced Motion Preferences
Always respect users who prefer reduced motion. The View Transitions API makes this straightforward:
/* src/app/globals.css — add at the top */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}This single rule disables all view transition animations for users who have enabled "Reduce motion" in their OS settings. The DOM update still happens — only the animation is removed.
You should also handle this in your custom hook:
// Updated use-view-transition.ts
export function useViewTransition(options: ViewTransitionOptions = {}) {
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const startTransition = useCallback(
async (callback: TransitionCallback) => {
if (!document.startViewTransition || prefersReducedMotion) {
await callback();
return;
}
// ... rest of the implementation
},
[options, prefersReducedMotion]
);
// ...
}Step 11: Advanced Morph Animations for Image Galleries
One of the most impressive uses of View Transitions is image galleries where thumbnails morph into full-size views:
// src/app/about/page.tsx
const team = [
{ id: "sarah", name: "Sarah Chen", role: "Engineering Lead" },
{ id: "omar", name: "Omar Khalil", role: "Design Director" },
{ id: "alex", name: "Alex Rivera", role: "Product Manager" },
];
export default function AboutPage() {
return (
<div>
<h1
className="mb-4 text-4xl font-bold"
style={{ viewTransitionName: "page-title" }}
>
About
</h1>
<p className="mb-12 text-lg text-gray-600">Meet the team behind it all.</p>
<div className="grid gap-8 md:grid-cols-3">
{team.map((member) => (
<div key={member.id} className="text-center">
<div
className="mx-auto mb-4 h-32 w-32 rounded-full bg-gradient-to-br from-gray-300 to-gray-400"
style={{
viewTransitionName: `avatar-${member.id}`,
}}
/>
<h3
className="font-semibold"
style={{
viewTransitionName: `name-${member.id}`,
}}
>
{member.name}
</h3>
<p className="text-sm text-gray-500">{member.role}</p>
</div>
))}
</div>
</div>
);
}Add CSS for smooth avatar morphing:
/* Smooth morphing for avatar images */
::view-transition-group(avatar-sarah),
::view-transition-group(avatar-omar),
::view-transition-group(avatar-alex) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}When navigating between pages that share the same viewTransitionName, the browser calculates the position and size difference and smoothly interpolates between them.
Step 12: Testing and Debugging Transitions
Chrome DevTools
Chrome DevTools has built-in support for debugging View Transitions:
- Open DevTools and go to the Animations panel
- Navigate between pages — each transition appears in the timeline
- You can slow down, replay, and inspect individual animations
- The Elements panel shows the
::view-transitionpseudo-elements during a transition
Slowing Down Transitions for Development
Add a temporary CSS rule to slow everything down while developing:
/* DEVELOPMENT ONLY — remove before production */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 2s !important;
}Common Debugging Issues
Problem: Elements flash instead of morphing
This happens when viewTransitionName values do not match exactly between the old and new pages. Double-check:
// Page A
style={{ viewTransitionName: "project-image-aurora" }}
// Page B — must match exactly
style={{ viewTransitionName: "project-image-aurora" }}Problem: Multiple elements with the same transition name
Each viewTransitionName must be unique on the page at any given time. If two elements share a name, the transition breaks silently:
// This will break — two elements with the same name
{items.map((item) => (
<div style={{ viewTransitionName: "card" }}> {/* All have "card" */}
{item.title}
</div>
))}
// This works — unique names
{items.map((item) => (
<div style={{ viewTransitionName: `card-${item.id}` }}>
{item.title}
</div>
))}Problem: Transitions not triggering
Make sure the experimental.viewTransition flag is set in next.config.ts. Also verify that you are using next/link for navigation — full page reloads bypass View Transitions.
Performance Considerations
View Transitions are remarkably performant because:
- GPU-accelerated: The browser composites the animation on the GPU
- No layout thrashing: Animations run on snapshots, not live DOM
- Automatic optimization: The browser only captures what it needs
However, keep these tips in mind:
- Limit named transitions: Each named element adds a capture step. Keep it under 10-15 named elements per page
- Avoid large images: The browser captures elements at their rendered size. Enormous images slow down the snapshot
- Use
will-changesparingly: The browser already optimizes View Transitions — addingwill-changecan actually hurt performance - Keep transition durations short: 200-400ms feels responsive. Anything over 500ms feels sluggish
Browser Support and Progressive Enhancement
As of 2026, View Transitions are supported in:
| Browser | Version | Support |
|---|---|---|
| Chrome | 111+ | Full |
| Edge | 111+ | Full |
| Safari | 18+ | Full |
| Firefox | 126+ | Full |
For older browsers, the transition simply does not play — the navigation happens instantly. Your app works perfectly either way. This is true progressive enhancement: the core experience is identical, with transitions as an added bonus.
Check for support in JavaScript:
if (document.startViewTransition) {
// View Transitions are supported
}In CSS, use @supports:
@supports (view-transition-name: test) {
/* Styles that only apply when View Transitions are supported */
.card {
view-transition-name: var(--card-name);
}
}Troubleshooting
Transition interruption
If a user clicks a link while a transition is still playing, the current transition is skipped and the new one starts. This is the correct browser behavior — do not try to prevent it.
Server Components and View Transitions
View Transitions work with both Server and Client Components in Next.js. The viewTransitionName CSS property can be set inline on any component. The useViewTransition hook only works in Client Components, but you rarely need it for route-based transitions.
Dynamic transition names
When generating viewTransitionName dynamically, make sure the value is a valid CSS custom-ident. Avoid names starting with numbers or containing special characters:
// Bad — starts with number
style={{ viewTransitionName: `123-card` }}
// Good — starts with letter
style={{ viewTransitionName: `card-123` }}Next Steps
Now that you have smooth page transitions, here are ways to take it further:
- Combine with Server Actions: Use View Transitions for optimistic UI updates when submitting forms
- Build a shared layout animation: Create a persistent sidebar that morphs its active indicator
- Add page-specific transitions: Use CSS
view-transition-nameon thebodywith different classes per route to create unique transitions for each page pair - Explore cross-document transitions: The upcoming Level 2 spec supports transitions across full page navigations (MPA)
Conclusion
The View Transitions API eliminates the need for complex animation libraries in most navigation scenarios. With Next.js App Router integration, you get smooth crossfades automatically, and with a few viewTransitionName declarations, you get impressive morph animations that feel native.
The key takeaways:
- Enable
experimental.viewTransitioninnext.config.tsfor automatic route transition support - Use
viewTransitionNameCSS property to create morph animations between shared elements - Control animations entirely with CSS pseudo-elements — no JavaScript needed
- Always respect
prefers-reduced-motionfor accessibility - View Transitions are progressive enhancement — apps work perfectly without them
The best animation library is the one you do not need to install.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Motion for React: Build Production-Grade Animations, Gestures, and Transitions
Master the Motion animation library (formerly Framer Motion) for React. Learn to build smooth animations, interactive gestures, layout transitions, scroll-driven effects, and exit animations with TypeScript.

Zustand + Next.js App Router: Modern React State Management from Zero to Production
Master modern React state management with Zustand and Next.js 15 App Router. This hands-on tutorial covers store creation, middleware, persistence, server-side hydration, and real-world patterns for scalable applications.

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.