View Transitions API
The View Transitions API lets you animate smoothly between two DOM states — or between pages in a single-page app — without manually orchestrating before/after screenshots. The browser handles the heavy lifting: it captures the old state, applies your CSS, and animates the change.
It started as a Chrome-only API for same-document transitions but has since expanded to cover cross-document navigations and gained broader browser support.
Checking Support
if (document.startViewTransition) {
console.log("View Transitions supported");
} else {
console.log("Not supported — use a fallback");
}
Chrome 111+, Edge 111+. Firefox 129+ supports same-document transitions. Safari has not shipped it yet.
Your First Same-Document Transition
The core method is document.startViewTransition(). Pass it a callback that updates the DOM:
document.startViewTransition(() => {
// Update the DOM — this becomes the "new" state
document.querySelector("main").innerHTML = "<p>New content</p>";
});
The callback runs synchronously first, then returns a promise. When the promise resolves, the browser starts the transition on the next frame. If you return nothing (or undefined), the transition starts immediately after the callback finishes.
The result is a ViewTransition object:
const transition = document.startViewTransition(() => {
updateTheDOM();
});
transition.finished.then(() => {
console.log("Transition finished");
});
How the Animation Works
By default, the browser uses a crossfade — the old state fades out while the new state fades in. You can customise this with CSS pseudo-elements.
Every transition creates pseudo-elements that you can target:
| Pseudo-element | What it represents |
|---|---|
::view-transition-group(name) | The container for one transition. Has its own animation timeline. |
::view-transition-old(name) | The snapshot of the old state |
::view-transition-new(name) | The snapshot of the new state |
Name each transition by setting view-transition-name on the elements involved:
.old-element {
view-transition-name: header;
}
.new-element {
view-transition-name: header;
}
With names set, the browser matches them up automatically. You can then style the animation:
::view-transition-group(header) {
animation-duration: 300ms;
animation-timing-function: ease-out;
}
Practical SPA Example
Here’s a tab switching example — clicking a tab swaps the visible panel with a smooth transition:
const tabs = document.querySelectorAll("[data-tab]");
const panels = document.querySelectorAll("[data-panel]");
for (const tab of tabs) {
tab.addEventListener("click", () => {
const target = tab.dataset.tab;
document.startViewTransition(() => {
// Hide all panels
for (const p of panels) {
p.hidden = true;
}
// Show the selected panel
document.querySelector(`[data-panel="${target}"]`).hidden = false;
// Update active tab styling
for (const t of tabs) {
t.setAttribute("aria-selected", t.dataset.tab === target);
}
});
});
}
With the default crossfade, this looks decent. But you can do better by naming the panels so the browser knows they’re the same conceptual element:
[data-panel] {
view-transition-name: panel;
}
Now the browser knows the old panel and new panel are related and can use a transform-based animation instead of a full crossfade.
Customising the Animation
Override the default crossfade by targeting the pseudo-elements directly:
::view-transition-old(root) {
animation: 200ms ease-out fade-out;
}
::view-transition-new(root) {
animation: 200ms ease-out fade-in;
}
The root name refers to the default transition — the entire viewport. You can also target named elements:
::view-transition-old(panel),
::view-transition-new(panel) {
animation: none;
mix-blend-mode: normal;
}
This removes the default animation for the panel element. Common patterns:
/* Fast slide for list items */
::view-transition-group(list-item) {
animation-duration: 150ms;
}
/* No animation — instant swap */
::view-transition-old(full-embed),
::view-transition-new(full-embed) {
animation: none;
}
Using view-transition-class
The view-transition-class CSS property lets you apply the same styles to multiple transition elements without naming each one individually:
.article-header {
view-transition-class: article-content;
}
.article-body {
view-transition-class: article-content;
}
::view-transition-group(article-content) {
animation-duration: 400ms;
}
Without view-transition-class, you’d have to give both elements the same view-transition-name. This is cleaner for cases where you have multiple related elements that should animate as a unit.
Transition Types
Types let you filter which pseudo-elements participate in a transition. Useful when different parts of the page transition differently:
document.startViewTransition({
updateCallback() {
showNewPage();
},
types: ["slide"]
});
In your CSS, filter by type:
::view-transition-group(sidebar) {
/* Only applies when the transition has type "sidebar" */
animation-duration: 200ms;
}
Query which types are active in JavaScript via the ViewTransition object:
const transition = document.startViewTransition({ updateCallback, types: ["slide"] });
console.log(transition.types); // Set {"slide"}
Waiting for the Transition
The ViewTransition object gives you promises to hook into lifecycle events:
const transition = document.startViewTransition(() => {
fetchNewContent(url).then(html => {
document.querySelector("main").innerHTML = html;
});
});
transition.ready.then(() => {
// Animation is about to start
console.log("Animation starting");
});
transition.finished.then(() => {
// Animation is done
console.log("Transition finished");
});
// Tell the browser to skip playing the animation
transition.skipCallback();
transition.skipCallback() tells the browser to skip playing the animation — useful if the user clicks something else before the transition completes.
Cross-Document Transitions
For navigations between separate HTML documents, the mechanism changes. The triggering document calls startViewTransition() as usual, but the CSS lives on the incoming page. A @view-transition rule at the top of the CSS opt-in:
/* On the incoming page */
@view-transition {
navigation: auto;
}
This tells the browser to automatically run a view transition for any navigation to this page. You can target specific transitions with names just like same-document:
/* On both pages */
h1 {
view-transition-name: page-title;
}
The ViewTransition object in cross-document mode is accessible from navigation.currentTransition on the new document:
// In the newly loaded document
if (navigation.currentTransition) {
navigation.currentTransition.finished.then(() => {
console.log("Arrived at new page");
});
}
Styling Active Transitions
The :active-view-transition pseudo-class targets the document while a transition is in flight:
:root:active-view-transition {
/* Apply this to the whole page during a transition */
}
:root:active-view-transition(root) {
/* Apply this only during transitions named "root" */
}
Useful for tweaking things like scroll behavior or cursor style during a transition.
Fallback Strategy
Not every browser supports view transitions yet. Always provide a sensible fallback:
function navigateTo(url) {
if (document.startViewTransition) {
document.startViewTransition(() => {
// Single-page navigation
history.pushState({}, "", url);
renderPage();
});
} else {
// Fallback: full page navigation
location.href = url;
}
}
For cross-document transitions, the browser simply does a normal navigation if it doesn’t support the API — @view-transition { navigation: auto } has no effect on unsupported browsers.
Full Example
// Minimal SPA router with view transitions
async function navigate(path) {
const target = document.querySelector(`[data-route="${path}"]`);
if (!target) return;
if (document.startViewTransition) {
document.startViewTransition(() => {
// Hide all routes
for (const route of document.querySelectorAll("[data-route]")) {
route.hidden = true;
}
// Show the target route
target.hidden = false;
history.pushState({}, "", path);
});
} else {
// Fallback for unsupported browsers
history.pushState({}, "", path);
for (const route of document.querySelectorAll("[data-route]")) {
route.hidden = route.dataset.route !== path;
}
}
}
// Wire up all navigation links
document.addEventListener("click", (e) => {
const link = e.target.closest("[data-navigate]");
if (link) {
e.preventDefault();
navigate(link.dataset.navigate);
}
});
/* Route containers — named so the browser can transition them */
[data-route] {
view-transition-name: route-panel;
}
/* Smooth crossfade with a slight slide */
::view-transition-old(route-panel) {
animation: 250ms ease-out fade-out slide-out;
}
::view-transition-new(route-panel) {
animation: 250ms ease-out fade-in slide-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
See Also
- /guides/javascript-navigation-api/ — the Navigation API works well alongside View Transitions for SPA routing
- /guides/javascript-web-animations-api/ — underlying animation system used by View Transitions
- /guides/javascript-streams-api/ — use with streams to load new page content progressively during a transition