The Case for Browser-Native UI Transitions
Animations do more than decorate a page — they direct attention, confirm interactions, and give an interface personality. But building polished, choreographed animations has traditionally meant pulling in a JavaScript animation library or a framework's transition system. While tools like GSAP and Framer Motion work well, JavaScript remains the most expensive resource on the web. Users must download, parse, and execute an entire animation engine before it can do anything.
The Shared Element Transitions API offers an alternative: let the browser handle the heavy lifting with optimized native code, while you keep full control over how UI states transition using vanilla JavaScript and CSS. The result is cinematic, multi-step animations with far less overhead.
What the API Can Do
Consider a common e-commerce interaction: adding an item to a cart, animating the product image into the cart icon, updating the cart counter, then triggering a confirmation panel — all sequenced seamlessly. With traditional methods, coordinating that chain of events requires substantial script and careful timing. With the Shared Element Transitions API, the browser manages the transition between the old and new visual states automatically.
The API works by capturing a snapshot of the current page state, applying your DOM and style changes, and then cross-fading between the "before" and "after" states. Elements you designate as "shared" get their own smooth, animated movement from their old position and size to their new ones rather than simply fading. This makes it straightforward to create the kind of fluid, spatial animations usually reserved for native mobile apps.
Browser Support and Status
As of this writing, the API is in early "Editor's Draft" status, meaning the specification is not finalized and may change. It is currently supported in Chrome version 104 and Canary, but only with the document-transition flag enabled. If you are not yet on a compatible browser, video examples accompanying this article will let you follow along with each demonstration.
Building Realistic Examples
To see the API's potential in practice, we'll construct four real-life interface scenarios from scratch. Each one exercises a different aspect of the API while remaining practical enough to adapt to production code:
- A shopping cart flow with animated product imagery and counter updates
- View transitions between different states without page reloads
- Customized transition timing with CSS animation control
- Shared elements that move between distinct positions on a page
Each example will show how the same underlying mechanism — snapshotting the old state, mutating the DOM, and letting the browser animate the difference — can be tailored to solve distinct UI problems.
The Shared Element Transitions Toolkit
Animating between UI states typically requires both the current and next state to exist simultaneously. Without library support, you’d otherwise need to orchestrate fade-outs and fade-ins while handling edge cases and accessibility concerns. The Shared Element Transitions API removes much of that groundwork by making both states visually present at once — you just handle the DOM update and the animation styles, with full control via standard CSS animation properties.
To see this in action, we’ll build an image gallery where a click expands an image from a grid card into a fixed overlay, and another click returns it. The image element is literally moved between the card container and the overlay — hence the “shared element” — while the empty card container keeps a CSS background-image to mask the move.
Markup, Styles, And Initial Script
Start with a grid of cards and an overlay, plus the JavaScript that relocates the clicked image and toggles the overlay’s visibility class:
See the Pen [Image gallery - vanilla (1) [forked]](https://codepen.io/smashingmag/pen/MWGJNaw) by Adrian Bece.
// Select static & shared page elements.
const overlayWrapper = document.getElementById("js-overlay");
const overlayContent = document.getElementById("js-overlay-target");
function toggleImageView(index) {
const image = document.getElementById(`js-gallery-image-${index}`);
// Store image parent element.
const imageParentElement = image.parentElement;
// Move image node from grid to modal.
moveImageToModal(image);
// Create a click listener on the overlay for the active image element.
overlayWrapper.onclick = function () {
// Return the image to its parent element.
moveImageToGrid(imageParentElement);
};
}
// Helper functions for moving the image around and toggling the overlay.
function moveImageToModal(image) {
// Show the overlay.
overlayWrapper.classList.add("overlay--active");
overlayContent.append(image);
}
function moveImageToGrid(imageParentElement) {
imageParentElement.append(overlayContent.querySelector("img"));
// Hide the overlay.
overlayWrapper.classList.remove("overlay--active");
}
Because the same image DOM node lives in both the card and the overlay at different times, it’s genuinely a shared element.
Enabling The Default Crossfade
To get the API running, we invoke the global createDocumentTransition function and pass our DOM-updating callback to its start method:
// This function is now asynchronous.
async function toggleImageView(index) {
const image = document.getElementById(`js-gallery-image-${index}`);
const imageParentElement = image.parentElement;
// Initialize transition from the API.
const moveTransition = document.createDocumentTransition();
// moveImageToTarget function is now called by the API "start" function.
await moveTransition.start(() => moveImageToModal(image));
// Create a click listener on the overlay for the active image element.
overlayWrapper.onclick = async function () {
// Initialize transition from the API.
const moveTransition = document.createDocumentTransition();
// moveImageToContainer function is now called by the API "start" function.
await moveTransition.start(() => moveImageToGrid(imageParentElement));
};
}
See the Pen [Image gallery - crossfade (2) [forked]](https://codepen.io/smashingmag/pen/yLjgmJN) by Adrian Bece.
That minimal change yields a clean crossfade. The API captures a screenshot of the outgoing state, applies your DOM update, then captures a screenshot of the incoming state. What you see animating are those snapshots, never the live DOM, which sidesteps the usual accessibility and usability concerns. This default crossfade is useful but doesn’t yet communicate that the image in the overlay is the same element that was in the card.
Animating A True Shared Element
The crossfade becomes a shared-element animation once you tag the moving element with the page-transition-tag CSS property. The browser will then track the element’s size and position between the two states and animate the change. Set it on the image:
.gallery__image--active {
page-transition-tag: active-image;
}
.gallery__image {
contain: paint;
}
The tag must be unique and can only be applied to one element during the animation. Consequently, we add it to the image right before the move and remove it when the overlay closes:
async function toggleImageView(index) {
const image = document.getElementById(`js-gallery-image-${index}`);
// Apply a CSS class that contains the page-transition-tag before animation starts.
image.classList.add("gallery__image--active");
const imageParentElement = image.parentElement;
const moveTransition = document.createDocumentTransition();
await moveTransition.start(() => moveImageToModal(image));
overlayWrapper.onclick = async function () {
const moveTransition = document.createDocumentTransition();
await moveTransition.start(() => moveImageToGrid(imageParentElement));
// Remove the class which contains the page-transition-tag after the animation ends.
image.classList.remove("gallery__image--active");
};
}
The tag also requires a containment context — contain: paint or contain: layout — which was not needed for a simple crossfade. Using a utility class is a good way to manage the tag conditionally, especially when you want to apply it via media queries:
// Applies page-transition-tag to the image.
image.style.pageTransitionTag = "active-image";
// Removes page-transition-tag from the image.
image.style.pageTransitionTag = "none";
The result feels immediately more substantial:
See the Pen [Image gallery - crossfade + shared element (3) [forked]](https://codepen.io/smashingmag/pen/OJZWKaK) by Adrian Bece.
In a few lines we’ve built a transition that would otherwise demand substantial custom JavaScript, and we can now move on to animating other properties — duration, easing, delay — on both the root element and the tagged active image.
Controlling Duration And Easing
During a transition, the API adds a small tree of pseudo-elements to the document. WICG’s explainer describes the purpose of each node:
::page-transitionsits in a top-layer, over everything else on the page.::page-transition-outgoing-image(root)is a screenshot of the old state, and::page-transition-incoming-image(root)is a live representation of the new state. Both render as CSS replaced content.::page-transition-containeranimates size and position between the two states.::page-transition-image-wrapperprovides blending isolation, so the two images can correctly cross-fade.::page-transition-outgoing-imageand::page-transition-incoming-imageare the visual states to cross-fade.
To inspect or target these pseudo-elements conceptually:
::page-transition
└─ ::page-transition-container(root)
└─ ::page-transition-image-wrapper(root)
├─ ::page-transition-outgoing-image(root)
└─ ::page-transition-incoming-image(root)
::page-transition
├─ ::page-transition-container(root)
│ └─ ::page-transition-image-wrapper(root)
│ ├─ ::page-transition-outgoing-image(root)
│ └─ ::page-transition-incoming-image(root)
└─ ::page-transition-container(active-image)
└─ ::page-transition-image-wrapper(active-image)
├─ ::page-transition-outgoing-image(active-image)
└─ ::page-transition-incoming-image(active-image)
Since the whole tree relies on CSS animation properties, you can use a universal selector on the transition pseudo-elements for a global duration and easing, then override the specific tagged element with its own parameters:
::page-transition-container(*) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}
::page-transition-container(active-image) {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
See the Pen [Image gallery - custom animation (4) [forked]](https://codepen.io/smashingmag/pen/gOzgVJV) by Adrian Bece.
Feature Detection And Fallbacks
Without support checks, the last example would be unusable in browsers lacking the API. Feature detection in JavaScript is just a property check:
const isSupported = "createDocumentTransition" in document;
if(isSupported) {
/* Shared element transitions API is supported */
} else {
/* Shared element transitions API is not supported */
}
CSS-detection can be done via @supports:
@supports (page-transition-tag: none) {
/* Shared element transitions API is supported */
/* Use the Shared Element Transisitons API styles */
}
@supports not (page-transition-tag: none) {
/* Shared element transitions API is not supported */
/* Use a simple CSS animation if possible */
}
For broader support, you can also add a class conditionally from JavaScript:
if("createDocumentTransition" in document) {
document.documentElement.classList.add("shared-elements-api");
}
html.shared-elements-api {
/* Shared element transitions API is supported */
}
html:not(.shared-elements-api) {
/* Shared element transitions API is not supported */
}
The fallback experience ensures our callback still runs even without animation:
See the Pen [Image gallery - fallback (5) [forked]](https://codepen.io/smashingmag/pen/yLjMBLb) by Adrian Bece.
@supports query to toggle the banner visibility for unsupported browsers. (Large preview)Respecting Reduced Motion
Some users prefer minimized movement. The widely-supported prefers-reduced-motion media query can switch off the API’s animations in one pass — just be certain the underlying DOM update remains smooth and usable:
@media (prefers-reduced-motion) {
/* Turn off all animations */
::page-transition-container(*),
::page-transition-outgoing-image(*),
::page-transition-incoming-image(*) {
animation: none !important;
}
/* Or, better yet, create accessible alternatives for these animations */
}
See the Pen [Image gallery -completed (6) [forked]](https://codepen.io/smashingmag/pen/RwypbPz) by Adrian Bece.
Custom Motion in a To-Do Board
The basic crossfade and position animations are a solid starting point, but the real value of the Shared Element Transitions API shows when you compose custom CSS animation properties and @keyframe rules. A three-column to-do list — tasks in progress, completed tasks, and won’t-do tasks — is a good test case for these techniques.
The JavaScript setup is nearly identical to the earlier examples, except that an item can now be moved to one of two target columns. While a transition runs, the API freezes rendering, which means no other page elements can be interacted with. Keep animations short enough to avoid harming usability.
For the moving card, we apply the tag card-active right before the animation starts and remove it when the transition ends. The other cards in the origin column get unique tags based on their index, such as card-${index + 1}, which lets them animate into the space left behind.
// Assign unique page-transition-tag values to all task cards.
// We could have also done this manually in CSS by targeting :nth-child().
const allCards = document.querySelectorAll(".col:not(.col-complete) li");
allCards.forEach(
(c, index) => (c.style.pageTransitionTag = `card-${index + 1}`)
);
async function moveCard(isDone) {
const card = this.window.event.target.closest("li");
const destination = document.getElementById(
`js-list-${isDone ? "done" : "not-done"}`
);
//We'll use this class to hide the item controls while the animation is running.
card.classList.add("card-moving");
if (document.createDocumentTransition) {
// Replace the item tag with an active (moving) element tag.
card.style.pageTransitionTag = "card-active";
const moveTransition = document.createDocumentTransition();
await moveTransition.start(() => destination.appendChild(card));
// Remove the tag after the animation ends.
card.style.pageTransitionTag = "none";
} else {
destination.appendChild(card);
}
}
Without a contain property on the list items, the animation falls back to a crossfade. Note that in the future this behavior might change, and the DOM update could occur without an animation at all.
li {
contain: paint;
}
Toggling tags and setting contain: paint alone gives a clean position animation. But in this case, the default motion looks stiff. Whether an animation looks good out of the box depends heavily on the type of motion, the animation’s purpose, and the surrounding UI.
Scaling and Bouncing
To get a bouncier feel, we can define custom keyframes for the animation pseudo-elements. Slightly increase the animation-duration for the position animation — but use the ::page-transition-image-wrapper child for scaling, so the default size and position animation of ::page-transition-container is left intact. A small delay on the scale animation helps the flow.
If you adjust the duration for some elements, update it for all of them to keep everything in sync. A custom cubic-bezier timing function supplies the bounce.
/* We are applying contain property on all browsers (regardless of property support) to avoid differences in rendering and introducing bugs */
li {
contain: paint;
}
@supports (page-transition-tag: none) {
::page-transition-container(card-active) {
animation-duration: 0.3s;
}
::page-transition-image-wrapper(card-active) {
animation: popIn 0.3s cubic-bezier(0.64, 0.57, 0.67, 2) 0.1s;
}
}
@keyframes popIn {
from {
transform: scale(1.3);
}
to {
transform: scale(1);
}
}
With those small tweaks and a custom keyframe, the simple card move becomes a lively, intentional motion.
See the Pen [To-do list - jumping & bouncing animation (3) [forked]](https://codepen.io/smashingmag/pen/YzLZKZm) by Adrian Bece.
Crossfading an Image Carousel
A simpler case: animate a tiny change — the src attribute of a single image element — with a custom crossfade. The carousel has two functions for stepping backward and forward through an array of images, and both run the same transition.
// Let's store all images in an array.
const images = [ "https://path.to/image-1.jpg", "https://path.to/image-2.jpg", "..."];
let index = 0;
function previousImage() {
index -= 1;
if (index < 0) {
index = images.length - 1;
}
updateIndex();
crossfadeElements();
}
function nextImage() {
index += 1;
if (index >= images.length) {
index = 0;
}
updateIndex();
crossfadeElements();
}
// Util functions for animation, index update and image src update.
async function crossfadeElements() {
if (document.createDocumentTransition) {
const imageTransition = document.createDocumentTransition();
await imageTransition.start(updateImage);
} else {
updateImage();
}
}
function updateIndex() {
const galleryIndex = document.getElementById("js-gallery-index");
galleryIndex.textContent = index + 1;
}
function updateImage() {
const gallery = document.getElementById("js-gallery");
gallery.src = images[index];
}
Two keyframe sets drive the effect:
fadeOut: outgoing image blurs and brightens while opacity goes from1to0.fadeIn: incoming image sharpens and darkens as opacity goes from0to1.
Apply the exit animation to the outgoing image’s pseudo-element and the entry animation to the incoming one. Since the image element is the only animated node, setting the page-transition-tag directly on it works.
/* We are applying contain property on all browsers (regardless of property support) to avoid differences in rendering and introducing bugs */
.gallery img {
contain: paint;
}
@supports (page-transition-tag: supports-tag) {
.gallery img {
page-transition-tag: gallery-image;
}
::page-transition-outgoing-image(gallery-image) {
animation: fadeOut 0.4s ease-in both;
}
::page-transition-incoming-image(gallery-image) {
animation: fadeIn 0.4s ease-out 0.15s both;
}
}
The lightning-flash-style brightness change suits a dark theme particularly well.
See the Pen [Crossfade image carousel - completed (2) [forked]](https://codepen.io/smashingmag/pen/gOzbZXB) by Adrian Bece.
Composed Add-to-Cart Animation
Our final build runs two animations back-to-back: a dot flies from the button to the cart icon, and once that finishes, the cart counter animates its change. Regardless of how layered the animation gets, the API hands you total control over the visual result.
For this, toggle a page-transition-tag on both the dynamic dot element and the cart element within their respective transitions.
async function addToCart() {
/* ... */
if (document.createDocumentTransition) {
const moveTransition = document.createDocumentTransition();
await moveTransition.start(() => moveDotToTarget(dot));
dot.style.pageTransitionTag = "none";
}
dot.remove();
if (document.createDocumentTransition) {
counterElement.style.pageTransitionTag = "cart-counter";
const counterTransition = document.createDocumentTransition();
await counterTransition.start(() => incrementCounter(counterElement));
counterElement.style.pageTransitionTag = "none";
} else {
incrementCounter();
}
}
/* ... */
function createCartDot() {
const dot = document.createElement("div");
dot.classList.add("product__dot");
dot.style.pageTransitionTag = "cart-dot";
return dot;
}
Customize each animation separately. The dot needs a different duration and timing function; the cart-counter can add a slight vertical movement to the standard crossfade. The dot has no explicit sizing CSS — its dimensions respond to its parent container. At the start of the animation it lives inside the button container, then it moves to the smaller cart icon container behind the counter. The Shared Elements API handles that position and dimension shift automatically.
/* We are applying contain property on all browsers (regardless of property support) to avoid differences in rendering and introducing bugs */
.product__dot {
contain: paint;
}
.shopping-bag__counter span {
contain: paint;
}
@supports (page-transition-tag: supports-tag) {
::page-transition-container(cart-dot) {
animation-duration: 0.7s;
animation-timing-function: ease-in;
}
::page-transition-outgoing-image(cart-counter) {
animation: toDown 0.3s cubic-bezier(0.4, 0, 1, 1) both;
}
::page-transition-incoming-image(cart-counter) {
animation: fromUp 0.3s cubic-bezier(0, 0, 0.2, 1) 0.3s both;
}
}
@keyframes toDown {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(4px);
opacity: 0;
}
}
@keyframes fromUp {
from {
transform: translateY(-3px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
The fixed header holding the cart icon doesn’t complicate the transition either — the standard animation setup works in that context without extra effort.
See the Pen [Add to cart animation - completed (2) [forked]](https://codepen.io/smashingmag/pen/vYjxEOR) by Adrian Bece.
Using the API Responsibly
Complex state transitions are far easier to build with the Shared Element Transitions API, but that simplicity can invite bad habits: slow or repetitive animations, unnecessary complexity, or motion that gets in the way. Keep the animation fundamentals in mind and aim for experiences that are both delightful and accessible. Consultation with a designer is a good idea when you’re unsure about an effect’s appropriateness.
In the next article, we’ll examine how the API handles state transitions between pages in Single Page Apps, plus the upcoming Cross-document same-origin transitions that are still on the horizon.



