
View Transitions API: The Future of Page Transition Animations
Can CSS animations alone make page transitions feel native? See how a single document.startViewTransition() call transforms your UX.

Can CSS animations alone make page transitions feel native? See how a single document.startViewTransition() call transforms your UX.
Tired of naming classes? Writing CSS directly inside HTML sounds ugly, but it became the world standard. Why?

For visually impaired, for keyboard users, and for your future self. Small `alt` tag makes a big difference.

Class is there, but style is missing? Debugging Tailwind CSS like a detective.

Clicked a button, but the parent DIV triggered too? Events bubble up like water. Understand Propagation and Delegation.

You tap a card in an iOS app and it smoothly unfolds. In Android, screens slide in from the side. Replicating this on the web used to mean installing a JavaScript animation library, manually orchestrating DOM operations, and wrestling with performance issues.
Framer Motion and GSAP helped, but cross-page transitions were still a headache. Controlling the exact moment when the old page disappears and the new one appears required synchronized state management and animation timing — a rabbit hole of complexity.
Then the browser decided to solve this itself. Meet the View Transitions API: the browser takes screenshots, updates the DOM, then animates between the two states using CSS. No library. No complex state.
Think about what happens when you navigate from page A to page B.
Traditional MPA:1. User clicks link
2. Browser requests new URL
3. Server returns HTML
4. Old page destroyed
5. New page rendered
→ White flash, jarring jump
Old SPA approach:
// React Router + Framer Motion — already complex
const variants = {
initial: { opacity: 0, x: -200 },
in: { opacity: 1, x: 0 },
out: { opacity: 0, x: 200 }
};
function PageWrapper({ children }) {
return (
<motion.div
initial="initial"
animate="in"
exit="out"
variants={variants}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}
// Plus AnimatePresence setup, exit animations...
Even this only handles CSS-layer transitions. Shared element transitions — where a card image morphs into a detail page hero — required an entirely different approach.
The browser steps in and handles it. Think of it like film editing:
Like a camera operator who shoots one scene, swaps the set, then morphs between the two shots.
old)new)That's the whole thing. The rest is controlled via CSS ::view-transition pseudo-elements.
// Wrap your DOM mutation and you're done
document.startViewTransition(() => {
document.querySelector('#content').innerHTML = newContent;
});
Before:
document.querySelector('#content').innerHTML = newContent;
Just wrapping with startViewTransition adds a crossfade animation automatically. Zero extra CSS.
async function navigateToPage(url) {
const transition = document.startViewTransition(async () => {
const response = await fetch(url);
const html = await response.text();
const parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html');
document.querySelector('main').replaceWith(
newDoc.querySelector('main')
);
document.title = newDoc.title;
});
// transition.ready: animation has started
// transition.finished: animation is complete
await transition.finished;
console.log('Navigation complete');
}
const transition = document.startViewTransition(updateFn);
// Ready: pseudo-elements created, ready to animate
transition.ready.then(() => {
// Customize with Web Animations API here
});
// Finished: all animations done
transition.finished.then(() => {
console.log('done');
});
// updateCallbackDone: DOM updated but animation still running
transition.updateCallbackDone.then(() => {});
// Skip animation (accessibility, testing)
transition.skipTransition();
When a View Transition starts, the browser builds this tree:
::view-transition ← root overlay
└── ::view-transition-group(root) ← full page group
└── ::view-transition-image-pair(root)
├── ::view-transition-old(root) ← screenshot of old state
└── ::view-transition-new(root) ← screenshot of new state
/* Adjust crossfade speed */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}
/* Old page slides out left */
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in forwards;
}
/* New page slides in from right */
::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out forwards;
}
@keyframes slide-out-left {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
This is the killer feature. A card image that smoothly expands into a detail page hero.
/* Give the card image a unique name */
.card-image {
view-transition-name: hero-image;
}
/* Same name on the detail page hero */
.detail-hero {
view-transition-name: hero-image;
}
Elements sharing the same view-transition-name are automatically morphed by the browser — position, size, and all.
// Only assign transition name to the clicked card
function handleCardClick(cardId) {
const card = document.querySelector(`[data-id="${cardId}"] img`);
document.startViewTransition(() => {
card.style.viewTransitionName = 'selected-card';
navigateToDetail(cardId);
});
}
import { useNavigate } from 'react-router-dom';
function ProductCard({ product }) {
const navigate = useNavigate();
const handleClick = () => {
const imgEl = document.querySelector(`#product-img-${product.id}`);
if (imgEl) {
imgEl.style.viewTransitionName = 'product-hero';
}
if ('startViewTransition' in document) {
document.startViewTransition(() => {
navigate(`/products/${product.id}`);
});
} else {
navigate(`/products/${product.id}`);
}
};
return (
<div className="card" onClick={handleClick}>
<img
id={`product-img-${product.id}`}
src={product.image}
alt={product.name}
/>
<h3>{product.name}</h3>
</div>
);
}
function ProductDetail({ product }) {
return (
<div>
<img
style={{ viewTransitionName: 'product-hero' }}
src={product.image}
alt={product.name}
/>
</div>
);
}
| MPA | SPA | |
|---|---|---|
| Support | Chrome 126+ natively | Already supported (JS) |
| Setup | HTML meta tag or HTTP header | document.startViewTransition() |
| Complexity | Very simple | Medium |
| Shared elements | CSS only | JS + CSS |
<!-- Just add this to your HTML head -->
<meta name="view-transition" content="same-origin" />
Or via HTTP header:
View-Transition: same-origin
That's it. Same-origin navigation gets automatic crossfade. Works with PHP, Django, Rails — no JavaScript required.
<!-- List page -->
<img
src="/products/1.jpg"
style="view-transition-name: product-1"
/>
<!-- Detail page -->
<img
src="/products/1.jpg"
style="view-transition-name: product-1"
/>
Pure CSS. The image morphs between pages. It genuinely feels like magic.
// src/components/ViewTransitionLink.tsx
'use client';
import { useRouter } from 'next/navigation';
import { MouseEvent } from 'react';
interface Props {
href: string;
children: React.ReactNode;
className?: string;
}
export function ViewTransitionLink({ href, children, className }: Props) {
const router = useRouter();
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (!('startViewTransition' in document)) {
router.push(href);
return;
}
document.startViewTransition(() => {
router.push(href);
});
};
return (
<a href={href} onClick={handleClick} className={className}>
{children}
</a>
);
}
type Direction = 'forward' | 'backward';
function navigate(url: string, direction: Direction) {
if (!('startViewTransition' in document)) {
window.location.href = url;
return;
}
document.documentElement.dataset.direction = direction;
document.startViewTransition(() => {
window.location.href = url;
});
}
[data-direction="forward"]::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in forwards;
}
[data-direction="forward"]::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out forwards;
}
[data-direction="backward"]::view-transition-old(root) {
animation: slide-out-right 0.3s ease-in forwards;
}
[data-direction="backward"]::view-transition-new(root) {
animation: slide-in-left 0.3s ease-out forwards;
}
function startViewTransitionSafe(
callback: () => void | Promise<void>
): Promise<void> {
if (!('startViewTransition' in document)) {
const result = callback();
return result instanceof Promise ? result : Promise.resolve();
}
return (document as any).startViewTransition(callback).finished;
}
Always wrap with a feature check. Users on unsupported browsers get the same functionality, just without the animation.
| Browser | SPA (JS API) | MPA (CSS/HTML) |
|---|---|---|
| Chrome 111+ | Supported | — |
| Chrome 126+ | Supported | Supported |
| Edge 111+ | Supported | — |
| Edge 126+ | Supported | Supported |
| Safari 18+ | Supported | Experimental |
| Firefox | Coming soon | Coming soon |
About 70-75% of global users are already on supported browsers. With progressive enhancement, this is production-ready today.
The View Transitions API is making the dream of "native-feeling transitions without JavaScript" real. Start with progressive enhancement today. As browser support matures, it'll become a cornerstone of every serious design system.