Implementing CSS View Transitions API for Page Transitions
View Transitions API allows creating smooth animated transitions between DOM states — or between pages in MPA/SPA — using native browser mechanisms. The browser takes a screenshot of the current state, applies the new one, then animates the transition. By default, crossfade is used, but through CSS pseudo-elements, any animation can be implemented.
Browser Support
Chrome 111+, Edge 111+, Safari 18+. Firefox — with flag, no stable support in 2025. Must check document.startViewTransition before use.
Basic Usage (SPA)
// utils/view-transition.ts
export async function navigateWithTransition(
updateDOM: () => void | Promise<void>
): Promise<void> {
// Fallback for unsupported browsers
if (!document.startViewTransition) {
await updateDOM()
return
}
const transition = document.startViewTransition(async () => {
await updateDOM()
})
try {
await transition.finished
} catch (e) {
// Transition was interrupted (new transition started)
if (!(e instanceof DOMException && e.name === 'AbortError')) {
throw e
}
}
}
Integration with React Router / Next.js
React Router v6
// router/transition-router.tsx
import { useNavigate } from 'react-router-dom'
import { navigateWithTransition } from '../utils/view-transition'
export function useTransitionNavigate() {
const navigate = useNavigate()
return (to: string, options?: { replace?: boolean }) => {
navigateWithTransition(() => {
navigate(to, options)
})
}
}
Next.js App Router
Next.js 14+ supports View Transitions via unstable_viewTransition in next/link:
// app/components/TransitionLink.tsx
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export function TransitionLink({
href,
children,
className,
}: {
href: string
children: React.ReactNode
className?: string
}) {
const router = useRouter()
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
if (!document.startViewTransition) {
router.push(href)
return
}
document.startViewTransition(() => {
router.push(href)
})
}
return (
<a href={href} onClick={handleClick} className={className}>
{children}
</a>
)
}
CSS: Custom Transition Animations
/* styles/view-transitions.css */
/* Default pseudo-elements */
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-out;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
/* Slide for navigation */
@keyframes slide-from-right {
from { transform: translateX(40px); opacity: 0; }
}
@keyframes slide-to-left {
to { transform: translateX(-40px); opacity: 0; }
}
/* Apply to specific direction (attribute on html) */
html[data-transition="forward"] {
&::view-transition-old(root) {
animation: slide-to-left 0.25s ease-in both;
}
&::view-transition-new(root) {
animation: slide-from-right 0.25s ease-out both;
}
}
html[data-transition="backward"] {
&::view-transition-old(root) {
animation: 0.25s ease-in both reverse slide-from-right;
}
&::view-transition-new(root) {
animation: 0.25s ease-out both reverse slide-to-left;
}
}
Set attribute on navigation:
// Determine direction before starting transition
function navigate(to: string, history: string[]) {
const isBack = history[history.length - 2] === to
document.documentElement.dataset.transition = isBack ? 'backward' : 'forward'
document.startViewTransition(() => {
// Update DOM/route
router.push(to)
})
}
Named view-transition-name: Shared Elements
Named transitions allow smoothly "moving" an element between pages (shared element transition):
/* List page */
.product-card-image {
view-transition-name: product-image; /* unique name */
}
/* Detail page */
.product-detail-image {
view-transition-name: product-image; /* same name */
}
/* Browser animates transition between them */
::view-transition-group(product-image) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
For dynamic names (product list):
// components/ProductCard.tsx
export function ProductCard({ product }: { product: Product }) {
return (
<div>
<img
src={product.image}
alt={product.name}
style={{
// view-transition-name must be unique in DOM
viewTransitionName: `product-image-${product.id}`,
}}
/>
</div>
)
}
// pages/product/[id]/page.tsx
export default function ProductDetail({ params }: { params: { id: string } }) {
return (
<img
src={product.image}
style={{ viewTransitionName: `product-image-${params.id}` }}
/>
)
}
Pause and Readiness Control
async function transitionWithControl(update: () => void) {
const transition = document.startViewTransition(update)
// Wait until old content is copied
await transition.ready
// Can launch additional Web Animations here
document.documentElement.animate(
{ opacity: [1, 0.8, 1] },
{ duration: 300, pseudoElement: '::view-transition-new(root)' }
)
await transition.finished
}
Disable for prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
Typical Timelines
Basic crossfade between Next.js/React Router pages — 4–6 hours. Directional slide transitions + shared element transitions for list/detail — 2–3 working days. Complete system with direction management, fallback, tests, Safari support — 3–4 working days.







