View Transitions API: Page Animations Without a Framework
The View Transitions API lets you animate page changes with pure browser APIs — no React Spring, no Framer Motion. Here's how it actually works in 2026.
What the View Transitions API Actually Does
Honestly, most developers over-engineer page animations. You don't need Framer Motion, a custom route wrapper, or 4kB of animation utility classes to get a polished cross-page transition. The View Transitions API ships in the browser, costs zero bytes of JavaScript, and works on any stack — plain HTML, React with a client-side router, or even a server-rendered Next.js app.
The core idea is simple: you call document.startViewTransition(() => yourDOMUpdate()), and the browser automatically captures a screenshot of the current state, runs your DOM update, then animates between the two states. By default you get a smooth cross-fade. With a few lines of CSS you can turn that into a slide, a shared-element morph, or anything else.
Browser support landed in Chrome 111, Edge 111, and Safari 18.0. Firefox is shipping behind a flag as of 133. For progressive enhancement that's more than good enough — you can wrap the call in a simple feature check and fall back to an instant update for Firefox users. Nobody notices the missing animation; they only notice when it's there.
The Minimal JavaScript You Need
Here's the thing: the JavaScript surface area is tiny. One method. One callback. That's it. Where things get interesting is what you do inside the callback and how you target elements with CSS afterward.
The example below wires up a client-side navigation handler in a vanilla JS app. The same pattern drops straight into a React useEffect or a Next.js route change callback — the browser API doesn't care about your framework.
// vanilla JS — works in any framework adapter
async function navigateTo(url) {
if (!document.startViewTransition) {
// Firefox fallback — just load the page
window.location.href = url;
return;
}
const response = await fetch(url);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.startViewTransition(() => {
// Swap only the main content region
document.querySelector('#content').innerHTML =
doc.querySelector('#content').innerHTML;
// Update the browser URL without a reload
history.pushState({}, '', url);
});
}The callback can be synchronous or return a Promise — the browser waits for the Promise to resolve before it starts the outgoing animation. That means you can fetch your data, build your new DOM nodes, and then hand control back to the browser for the visual part. Clean separation.
Customising the Cross-Fade with CSS
The browser exposes two pseudo-elements during a transition: ::view-transition-old(root) captures the outgoing screenshot, and ::view-transition-new(root) holds the incoming live DOM. You override their animations with standard @keyframes — nothing proprietary.
A 300ms slide-left feels snappy without being aggressive. The values below are what I landed on after testing on a 60Hz display and a 120Hz ProMotion screen. At animation-duration: 280ms on 120Hz you get exactly 33 or 34 rendered frames, which avoids that janky half-frame finish.
/* globals.css — drop this in once, applies to all transitions */
@keyframes slide-from-right {
from { transform: translateX(40px); opacity: 0; }
}
@keyframes slide-to-left {
to { transform: translateX(-40px); opacity: 0; }
}
::view-transition-old(root) {
animation: 280ms ease-in both slide-to-left;
}
::view-transition-new(root) {
animation: 280ms ease-out both slide-from-right;
}
/* Respect reduced-motion — this is not optional */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}Don't skip the prefers-reduced-motion block. Vestibular disorders are real, and some users get physically ill from sliding animations. The API makes opting out trivially easy, so there's no excuse.
Shared Element Transitions: The Actually Impressive Part
The default cross-fade is fine. But shared element transitions — where a specific element morphs from its position on page A to its position on page B — are what make the API genuinely interesting. Think of a product card expanding into a detail page, or a thumbnail growing into a hero image.
You wire this up with a single CSS property: view-transition-name. Give the same name to the element on both pages and the browser auto-interpolates position, size, and border-radius between the two states. No FLIP calculations. No getBoundingClientRect. The browser handles the geometry.
// ProductCard.tsx
export function ProductCard({ id, title, src }: Props) {
return (
<div
style={{ viewTransitionName: `product-card-${id}` }}
className="rounded-xl overflow-hidden cursor-pointer"
onClick={() => navigateTo(`/products/${id}`)}
>
<img src={src} alt={title} className="w-full aspect-video object-cover" />
<p className="p-4 font-medium">{title}</p>
</div>
);
}
// ProductDetail.tsx — same view-transition-name on the hero
export function ProductDetail({ id, title, src }: Props) {
return (
<div
style={{ viewTransitionName: `product-card-${id}` }}
className="w-full rounded-2xl overflow-hidden"
>
<img src={src} alt={title} className="w-full object-cover" />
</div>
);
}One gotcha: view-transition-name values must be unique on the page at any given moment. If you're rendering a list of 20 product cards, each one needs a distinct name — which is why the id suffix pattern above matters. Duplicate names cause the transition to silently skip that element and fall back to a cross-fade.
Wiring It Into React and Next.js
React doesn't know about the View Transitions API natively, but you can hook into it cleanly. In Next.js 15 with the App Router, the router.push() call inside document.startViewTransition() works — but you need to be careful about timing because React's concurrent rendering doesn't flush synchronously inside the callback.
The pattern that actually works is wrapping router.push() in startViewTransition and letting React re-render asynchronously. Next.js 15 introduced unstable_ViewTransition as an experimental wrapper component that handles the handshake between React 19's concurrent scheduler and the browser API — it's worth watching, but at the time of writing it's still behind a flag.
// hooks/useViewTransition.ts
import { useRouter } from 'next/navigation';
export function useViewTransition() {
const router = useRouter();
return (href: string) => {
if (!document.startViewTransition) {
router.push(href);
return;
}
document.startViewTransition(() => {
router.push(href);
});
};
}
// Usage in any component
const navigate = useViewTransition();
<button onClick={() => navigate('/about')}>About</button>For apps using React Router v7, the unstable_useViewTransitionState hook ships out of the box. Pass viewTransition to your <Link> component and the router handles the orchestration automatically. Worth using if you're on that stack — it covers edge cases around concurrent mode that are annoying to replicate by hand. You can pair this kind of routing animation with a well-chosen background — the aurora background and particles background from Empire UI both survive view transitions without flickering because they live outside the #content swap zone.
Performance: What You're Actually Paying For
A common concern: does capturing a screenshot on every navigation hurt performance? The short answer is no — on anything built in the last five years. The browser composites the screenshot on the GPU, the same way it composites CSS transforms. You're not doing a full paint. You're blitting a texture.
That said, there are real gotchas. If your page has position: fixed elements — headers, sticky navs, cookie banners — those get captured in the screenshot and can cause visual artifacts during the animation because they appear frozen while the rest of the page morphs. The fix: give fixed elements their own view-transition-name so the browser tracks them independently. view-transition-name: site-header on your nav, and it'll stay perfectly still while the content slides beneath it.
What about pages that are heavy on canvas or WebGL? Canvas content is rasterised at capture time, which means whatever frame was on-screen when you called startViewTransition gets frozen into the screenshot. If you're doing continuous animation — think a spotlight effect or a particle canvas — the screenshot will catch one frame and hold it. Usually that's fine at 280ms, but test it on a slow device where the DOM update takes longer.
Multi-Page App (MPA) Transitions: Same-Origin Navigation
Everything above assumes a single-page app where you're swapping DOM nodes. But the View Transitions API also covers true multi-page navigation — full page loads between same-origin URLs — through a mechanism called the Navigation API combined with @view-transition CSS at-rules. This landed in Chrome 126.
You opt in with two lines in your CSS: @view-transition { navigation: auto; }. That's it. Chrome will automatically apply the default cross-fade to every same-origin navigation on that page. No JavaScript at all. If you want custom animations, you use the same ::view-transition-old and ::view-transition-new pseudo-elements as before — they work identically in MPA mode.
This matters for server-rendered apps built with Rails, Django, Laravel, or plain HTML. Your marketing pages, documentation sites, and content blogs can now have polished page animations without a single line of JavaScript. Pair it with a thoughtful theme toggle implementation and your dark mode switch can also animate cleanly across page loads.
There's one limitation worth knowing: MPA transitions require both pages to opt in. If page A has @view-transition { navigation: auto; } but page B doesn't, you'll get an instant jump on arrival. Structure your CSS so every page in the app includes the opt-in — a shared layout stylesheet is the natural place to put it.
When NOT to Use View Transitions
Not every navigation needs an animation. Dashboard apps where users are power users clicking through tables 30 times a minute will find slide transitions annoying fast. Heavy data-dense UIs — think spreadsheets, code editors, admin panels — should probably keep transitions off or limit them to 150ms cross-fades that are barely perceptible.
Also consider your content strategy. If pages have very different visual structures — a wide grid page navigating to a single-column reading view — shared element transitions won't have many elements to match. The cross-fade fallback looks fine, but you might be adding complexity for no visible payoff. Sometimes the most honest animation is no animation at all.
And one practical note: if you're already using a heavy animation library like Framer Motion for in-page animations, the View Transitions API sits at a different layer — they don't conflict. Your in-page motion.div animations run after the view transition completes. You can absolutely use both. But if the only reason you're reaching for Framer Motion is page transitions, the browser API might be all you need, and it'll ship 150kB lighter.
FAQ
Yes, with a caveat. React 19's concurrent rendering doesn't flush synchronously, so the DOM update inside the callback may not complete before the browser captures the 'new' state. The practical fix is to call router.push() inside the callback and let React schedule the update — Next.js 15's experimental unstable_ViewTransition component handles the handshake more reliably if you're on that stack.
Yes. Read the navigationType from the Navigation API event: navigation.addEventListener('navigate', e => { const isBack = e.navigationType === 'traverse' && e.destination.index < navigation.currentEntry.index; document.documentElement.classList.toggle('back-nav', isBack); }). Then style ::view-transition-old(root) differently under .back-nav to reverse your slide direction.
Give the header its own view-transition-name in CSS: header { view-transition-name: site-header; }. The browser will now track the header independently and keep it composited on top while the page content animates beneath it. No JavaScript needed.
Not negatively in practice. The GPU-composited screenshot capture is fast — under 2ms on mid-range hardware. LCP is measured against the fully-painted new state, not the transition, so a 280ms animation doesn't add 280ms to your LCP. INP can improve because the browser gives immediate visual feedback even before React re-renders.
The root pseudo-element (::view-transition-old(root) / new(root)) captures the entire page. Named elements — like view-transition-name: hero-image — are captured and animated individually as 'shared elements'. Named elements are composited on top of the root animation, which is why they appear to fly across the screen while everything else cross-fades.
Yes. document.startViewTransition() returns a ViewTransition object with a .skipTransition() method and a .finished Promise. Call skipTransition() to immediately jump to the end state — useful for low-end devices you detect at runtime via the navigator.hardwareConcurrency or connection API.