View Transitions API: Smooth DOM Animations in JavaScript
The View Transitions API lets you animate smoothly between two DOM states — or between pages in a single-page app — without manually orchestrating before-and-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. Always feature-detect before calling startViewTransition so unsupported browsers fall back gracefully to an instant DOM update.
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 and 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. This means you can use async work inside the callback — fetch content, then update the DOM, and the browser waits for the promise before animating.
The result is a ViewTransition object that tracks the lifecycle of the animation:
const transition = document.startViewTransition(() => {
updateTheDOM();
});
transition.finished.then(() => {
console.log("Transition finished");
});
You can use this promise to chain actions after the animation completes, like focusing an input or updating a URL.
How the animation works
By default, the browser uses a crossfade — the old state fades out while the new state fades in. You can customize this with CSS pseudo-elements that the browser generates during the transition.
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. The browser matches old and new elements with the same name and animates between their positions and sizes:
.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 is 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 improve the animation by naming the panels so the browser understands they represent the same conceptual element across DOM states:
[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. The result feels more like a connected interface and less like a page reload.
Customising the animation
Override the default crossfade by targeting the pseudo-elements directly. This gives you full control over timing, easing, and the visual character of each transition:
::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 for per-element animation control:
::view-transition-old(panel),
::view-transition-new(panel) {
animation: none;
mix-blend-mode: normal;
}
This removes the default animation for the panel element, which is useful when you want to handle motion in a different way — for example, with a slide or scale that matches the interaction. 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. This is cleaner than forcing unrelated elements to share a view-transition-name:
.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 would have to give both elements the same view-transition-name, which can cause conflicts when the browser tries to pair them during the snapshot phase. Using classes keeps the naming clean and the CSS manageable.
Transition types
Types let you filter which pseudo-elements participate in a transition. This is useful when different parts of the page need different animation behaviors — a slide for navigation, a crossfade for content:
document.startViewTransition({
updateCallback() {
showNewPage();
},
types: ["slide"]
});
In your CSS, filter by type to scope animation rules to specific transition scenarios:
::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, which is essential for coordinating UI updates, analytics, or focus management:
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. The ready promise resolves when the pseudo-elements are generated and the animation is about to begin, while finished resolves after the last frame.
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. The named elements on both pages need matching view-transition-name values so the browser can pair them:
/* 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, giving you a hook for temporary style changes:
: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 — for example, disabling pointer events to prevent accidental clicks mid-animation.
Fallback strategy
Not every browser supports view transitions yet. Always provide a sensible fallback that keeps the navigation working:
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 does not support the API — @view-transition { navigation: auto } has no effect on unsupported browsers, so pages degrade gracefully.
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; }
}
Designing motion that feels stable
The best transitions are noticeable but not distracting. Keep the movement short, use a consistent direction, and avoid animating every part of the page at once. If the user changes tabs, they should understand what moved and why without having to wait for a flourish. Stability matters more than spectacle, especially for interface elements they interact with often.
Naming shared state
view-transition-name works best when it maps to a real concept in the interface. Name the header, panel, or card that is actually changing rather than trying to decorate every element. This gives the browser a clear clue about what should match between old and new states, and it gives you fewer elements to reason about when tuning the animation.
Cross-document tradeoffs
Cross-document transitions are useful when page navigation should feel connected. They work well for simple content sites and apps with a light shell, but they add another moving part to deployment and CSS. If navigation is already fast and the content swap is tiny, a same-document transition may be enough. Match mode to the complexity of the page change.
Respecting motion preferences
Not every user wants animated page changes. Check reduced-motion preferences and shorten or skip the transition when users ask for less movement. This adjustment is small, but it makes the feature feel thoughtful instead of forced. When the browser cannot animate, the page should still remain fully usable and easy to follow.
Keep state changes small
A view transition works best when the DOM change is easy to describe. Swap one panel, update one title, or move one card instead of rebuilding the whole page. Smaller state changes give the browser a cleaner before-and-after pair and make the animation feel like a natural continuation of the user action.
Respect the interaction
The animation should explain where the user went, not distract from the task. Keep the movement short, use the same motion language across related screens, and skip extra flourishes when the content change is already clear. The feature is strongest when it supports navigation, not when it competes with it.
Match motion to structure
Animation shape depends on the layout change. A full-page route swap can use a broader transition, while a small card update needs something tighter and calmer. When motion matches the size of the change, the result feels intentional rather than decorative.
Keep the end state obvious
Users should always know where they landed after the animation finishes. Leave the final layout stable, avoid sudden jumps after the transition ends, and keep focus where the user expects it. That makes the feature useful for navigation, not just visual polish.
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