Use the View Transitions API for smooth page and component transitions
rule · view-transitions
The View Transitions API (opens in a new tab) provides a two-step mechanism: capture a screenshot of the current state, update the DOM to the new state, then animate between the two screenshots using CSS. The Chrome platform guide (opens in a new tab) is helpful because it covers both same-document and cross-document transitions with the same mental model.
Code Examples
// Wrap any DOM mutation in startViewTransition
async function navigateTo(newRoute: string) {
if (!document.startViewTransition) {
// Fallback: instant update for unsupported browsers
await renderRoute(newRoute)
return
}
const transition = document.startViewTransition(async () => {
await renderRoute(newRoute)
})
// Optionally wait for the transition to complete
await transition.finished
}React Router v6 integration
// hooks/use-view-transition.ts
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
export function useViewTransitionNavigate() {
const navigate = useNavigate()
return useCallback(
(to: string) => {
if (!document.startViewTransition) {
navigate(to)
return
}
document.startViewTransition(() => {
navigate(to)
})
},
[navigate]
)
}// components/nav-link.tsx
import { useViewTransitionNavigate } from '@/hooks/use-view-transition'
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const navigate = useViewTransitionNavigate()
return (
<a
href={href}
onClick={(e) => {
e.preventDefault()
navigate(href)
}}
>
{children}
</a>
)
}Why It Matters
Jarring instant page changes are one of the most noticeable perceived- performance problems in web applications. Smooth transitions help users maintain context during navigation — they understand where they came from and where they are going. Previously, achieving this required complex JavaScript animation libraries; the View Transitions API delivers it natively at a fraction of the code cost.
How It Works
document.startViewTransition(callback)is called- The browser captures a screenshot of the current DOM (the "old" state)
- The
callbackupdates the DOM to the new state - The browser animates between old and new screenshots using
::view-transition-*pseudo-elements - The screenshots are discarded and the live DOM takes over
Cross-Document Transitions (MPA)
For multi-page applications (or Next.js with full page navigation), enable transitions in CSS — no JavaScript required:
/* opt into cross-document transitions */
@view-transition {
navigation: auto;
}That single CSS rule enables a default cross-fade between pages. Supported in Chrome 126+ and progressively enhanced for other browsers.
Customising the Default Transition
/* Override the default cross-fade animation */
::view-transition-old(root) {
animation: 200ms ease-out both fade-out, 200ms ease-out both slide-out;
}
::view-transition-new(root) {
animation: 300ms ease-in both fade-in, 300ms ease-in both slide-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
@keyframes slide-out {
to { transform: translateX(-30px); }
}
@keyframes slide-in {
from { transform: translateX(30px); }
}Shared-Element Transitions
Assign view-transition-name to elements that should animate as a unit between states — the classic "magic move" effect:
/* Hero image on the list page */
.product-card .hero-image {
view-transition-name: product-hero;
}
/* Hero image on the detail page — same name = shared transition */
.product-detail .hero-image {
view-transition-name: product-hero;
}// Navigating between list and detail triggers the shared-element animation
document.startViewTransition(async () => {
await renderProductDetail(productId)
})Dynamic view-transition-name (unique per item)
When generating list items with repeated structure, each element needs a unique name:
// Each product card gets its own unique transition name
export function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<img
src={product.image}
alt={product.name}
style={{ viewTransitionName: `product-${product.id}` }}
/>
</li>
))}
</ul>
)
}Respecting Reduced Motion
Always wrap custom transition animations in a prefers-reduced-motion media query:
/* Default: smooth animation */
::view-transition-old(root) {
animation: 250ms ease-out both fade-and-slide-out;
}
::view-transition-new(root) {
animation: 250ms ease-in both fade-and-slide-in;
}
/* Reduced motion: instant swap (browser default cross-fade is also acceptable) */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}Next.js App Router
Next.js does not natively expose view transitions yet, but you can use them via route change events:
// app/layout.tsx — inject the MPA opt-in globally
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<style>{`@view-transition { navigation: auto; }`}</style>
</head>
<body>{children}</body>
</html>
)
}Feature Detection
function supportsViewTransitions(): boolean {
return 'startViewTransition' in document
}
// Type augmentation for TypeScript
interface Document {
startViewTransition(callback: () => Promise<void> | void): ViewTransition
}
interface ViewTransition {
ready: Promise<void>
finished: Promise<void>
updateCallbackDone: Promise<void>
skipTransition(): void
}Support Notes
- Current project targets outside the feature support range: chrome 109, firefox 140.
- Baseline-compatible minimums: chrome 115, edge 115, firefox 116, safari 16.4, safari_ios 16.4.
- Add a fallback or progressive-enhancement note when a required project target falls outside that support range.
Verification
- Trigger a page transition and open DevTools → Animations panel — you should see the
::view-transition-*animations recorded. - Toggle prefers-reduced-motion in DevTools → Rendering and confirm transitions are disabled or reduced appropriately.
- Verify that
document.startViewTransitioncalls have a fallback code path for browsers that do not support the API. - Check that shared-element transitions use unique
view-transition-namevalues — inspect the DOM before the transition fires and confirm no duplicates exist.