CSS View Transitions API: Page Animations Without JavaScript
The View Transitions API lets you animate between pages and UI states with pure CSS — no JavaScript animation libraries, no layout thrash, no headaches.
What the View Transitions API Actually Is
The View Transitions API is a browser-native mechanism for animating between two DOM states — or two full pages in an MPA — without you having to manually orchestrate anything. It shipped in Chrome 111 in 2023, landed in Safari 18, and Firefox finally got it in version 130. You call one method, update your DOM, and the browser handles the cross-fade. Done.
Honestly, the conceptual model is the part developers miss at first. The browser takes a screenshot of the current state, applies your DOM update, takes another screenshot of the new state, and then composites an animated transition between the two. That's it. No FLIP calculations, no requestAnimationFrame loops, no dependencies.
The simplest possible usage is a single line in JavaScript — but the actual animation configuration lives entirely in CSS. That distinction matters. You're not writing animation logic; you're writing transition styles. The JS just says "now".
The Minimal Setup: document.startViewTransition
Here's the baseline. Call document.startViewTransition(), pass a callback that mutates the DOM, and you get a default cross-fade between states for free.
document.startViewTransition(() => {
document.querySelector('.card').classList.toggle('expanded');
});The default animation is a 250ms cross-fade on the ::view-transition-old and ::view-transition-new pseudo-elements. Totally usable out of the box. But the real power is when you start overriding those pseudo-elements with your own keyframes.
Worth noting: the callback can return a Promise. That means if your DOM update involves fetching data first, you just return the fetch and the browser holds the screenshot until the Promise resolves. No flicker, no janky half-loaded state.
Customising Transitions with CSS Pseudo-elements
The API exposes two pseudo-elements you can target: ::view-transition-old(root) and ::view-transition-new(root). The root part is the transition name — we'll get to named transitions in a second.
/* Slide in from the right instead of cross-fading */
::view-transition-old(root) {
animation: 300ms ease-out both slide-out-left;
}
::view-transition-new(root) {
animation: 300ms ease-out both slide-in-right;
}
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}That gets you a horizontal page slide — the kind that used to require a full animation library — in about 12 lines of CSS. No dependencies. No bundler plugins. Just CSS.
In practice, you'll want to be careful with animation-fill-mode: both. Without it the old element snaps back before it disappears, which looks terrible. The both keyword keeps the element frozen at its final keyframe state until the transition is fully done.
One more thing — you can also target the wrapping ::view-transition pseudo-element itself if you need to control the overlay layer, like adding a background color behind the transition.
Named View Transitions: The Shared-Element Magic
The default root transition animates the whole page. Named transitions let you animate specific elements independently — and this is where it gets genuinely impressive.
Assign a view-transition-name in CSS to any element you want to track across states. The browser will smoothly interpolate that element's position, size, and opacity between the old and new DOM snapshots automatically.
.hero-image {
view-transition-name: hero;
}
.product-card {
view-transition-name: product-card; /* must be unique per page */
}document.startViewTransition(() => {
// update the DOM — move .hero-image to a new position
container.classList.add('detail-view');
});The named element gets its own pair of pseudo-elements: ::view-transition-old(hero) and ::view-transition-new(hero). You can customise just those, leave the root as a cross-fade, and get that slick "shared element" transition you've seen in native mobile apps. In 2023 this would have taken 80+ lines of JavaScript. Now it's a CSS property value.
Look, this is the feature that makes the API worth adopting today. A thumbnail expanding into a detail view, a nav item morphing into a page header — effects like these are what make interfaces feel considered rather than functional. If you're building components, check out how Empire UI's component library handles visual state — the principles translate directly.
MPA Support: Transitions Across Full Page Navigations
Single-page app transitions are one thing, but the API also supports Multi-Page App (MPA) navigations — actual <a href> link clicks between separate HTML documents. This is the part that still feels borderline magic.
You opt in with a single meta tag (or HTTP header):
<meta name="view-transition" content="same-origin" />Once that's in place, same-origin navigations automatically run your CSS-defined view transitions. No router, no client-side JS, no framework required. A statically-generated site with zero JavaScript can have smooth page transitions. Let that sink in.
Named view-transition-name values that appear on both the outgoing and incoming page will automatically animate as shared elements between the two pages. Match the names in your CSS and the browser figures out the rest. Quick aside: this is still marked as experimental in some browsers for MPAs as of early 2026, so keep an eye on caniuse before shipping to production.
Respecting User Preferences and Avoiding Motion Sickness
Any time you add motion, you need to think about prefers-reduced-motion. Users who've enabled that system setting can get disoriented — or worse — from unexpected animations. The fix is straightforward.
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}That cuts the transition entirely for users who've opted out. A 4ms cross-fade is fine to leave in — it's the sliding, scaling, and rotating that causes problems. Know the difference.
If you're building a design system with lots of motion — and styles like glassmorphism components or aurora tend to lean into animation — wrapping your transition CSS in a prefers-reduced-motion media query should be standard practice, not an afterthought. The css-scroll-animations article covers this pattern in more detail too.
Worth noting: Chrome DevTools has a "Emulate CSS media feature prefers-reduced-motion" option in the Rendering tab. Use it. Testing this manually takes 30 seconds and saves you real accessibility issues.
When to Use It (and When Not To)
The View Transitions API is the right tool when you want animated state changes that feel native — page navigations, expanding cards, list reorders, tab switches. It's not the right tool for looping animations, hover effects, or anything that needs precise timing control tied to scroll position or user input.
For scroll-driven effects, CSS animation-timeline: scroll() is the better primitive — that's a whole separate API. For hover microinteractions at 2px–4px scale, plain CSS transitions on individual properties are still simpler and more predictable.
The real win of the View Transitions API is eliminating the category of bugs that come from coordinating JavaScript animation state with DOM state. When the browser owns the screenshot-and-interpolate cycle, you can't get into a state where your animation runs against a half-updated DOM. That class of bug just disappears.
If you're building UI in React or similar, note that as of React 19 there's no first-class startViewTransition integration — you wrap your setState call manually inside the callback. It works, it's just not idiomatic yet. Frameworks like Astro and Nuxt have built-in support. Check the best-react-ui-libraries-2026 rundown for framework-level animation support comparisons.
FAQ
For SPAs, yes — Chrome, Edge, and Safari 18+ cover the vast majority of users. For MPA cross-document transitions, check caniuse first; Firefox support arrived in v130 but the feature was still flagged in some builds.
No. The only JS you need is document.startViewTransition(() => { /* DOM update */ }). MPA mode doesn't even require that — it's opt-in via a meta tag.
Each value must be unique per snapshot — two elements on the same page can't share the same view-transition-name or the transition breaks. Use distinct names per element.
For page transitions and shared-element effects, it's a strong native alternative. For complex timeline-based sequences, staggered lists, or scroll-scrubbed animations, GSAP still does things the API can't touch.