Skip to content
This page is part of a work in progress series. Come back later for more content!

The browser API

15 min read

The browser View Transitions API is intended for a much more generic use case than Astro’s <ViewTransitions /> component. Its purpose is to provide an easier transition between two DOM states without worrying about intermediary states.

Astro’s <ViewTransitions /> component is one application of this API. It provides the wiring to turn all navigations between static Astro pages into a transition between two DOM states on the same page. Providing an SPA experience without requiring all the complexity and piles of JavaScript that would be necessary for doing that more generally.

But while Astro’s component is for navigation, the browser API can be used for many more use cases, even simpler ones. It is essential to understand the browser API first to understand how Astro’s component works and the reasoning behind some of its design decisions.

The world before: madness

To understand the problem this API is trying to solve, let’s pretend we are tasked with building a component that shows one image and switches to a different image when you click on it. We will gradually take care of more details in that component, so we can experience the problems that arise without using the View Transitions API before we switch to using it.

Laying the groundwork, let’s start with a simple component that includes the two images, and when you click on one, it hides it and shows the other. We’ll load the images using the <Image /> component from Astro’s astro:assets package.

Simplest image swap

---
import coast from '@/assets/jungle-coast.jpg';
import bird from '@/assets/tree-bird.jpg';
import {Image} from 'astro:assets';
---
<div id="images">
<Image style={{margin: '0'}} alt="Jungle coast" src={coast} />
<Image style={{display: 'none', margin: '0'}} alt="Tree bird" src={bird} />
</div>
<script>
function swapImages(parent: HTMLElement) {
const imgs = parent.querySelectorAll('img');
imgs.forEach(img => {
img.style.display = img.style.display === 'none'
? 'block' : 'none';
});
}
const images = document.getElementById('images');
images.addEventListener('click', () => {
swapImages(images);
});
</script>

Let’s break down what is happening in that code:

  • We have two images, one of jungle coasts and another of a bird on a tree.
  • The bird image is initially hidden.
  • We have an event listener that listens for clicks on the #images element that contains both images. When a click happens, it calls the swapImages function with the #images element as its argument.
  • The swapImages function swaps the visibility of all the img elements within the given container, hiding the ones that are visible and showing the ones that are hidden.

It’s a pretty straightforward component, right? Let’s see it in action:

Simple swap

Jungle coast Tree bird

That is good enough for a first version, but it has some problems:

  1. The first image is immediately hidden when you click on it, but the second image only starts loading afterward. This is because the <Image/> component has a handy default of lazy loading images. This is great for performance, but there will be a time gap with no image.

    Try setting a meager network speed in your browser’s dev tools and clicking on the image again to see this effect.

  2. The swap is immediate. There is no transition between the two images; they just suddenly disappear and appear. Once both images are loaded, the image appears to abruptly become the other.

Don’t wait for the next image

We can fix the first problem by setting the loading="eager" attribute on the second image. This will make it load immediately instead of lazily. This fixes the symptom but not the problem. If we had more images, we would not want to load all of them eagerly, only the one that will be shown next.

<div id="images">
<Image style={{margin: '0'}} alt="Jungle coast" src={coast} />
<Image
style={{display: 'none', margin: '0'}}
alt="Tree bird"
src={bird}
loading="eager"
/>
</div>

Simple swap with eager loading

The second image from here on is different, so the browser doesn’t share the cache with the demo above.

Jungle coast View from the sky

Animating the swap

As for the second problem, we can add an animation during the swap using img.animate. It would need two animations, one to fade out the current image and another to fade in the new one.

To do that, we can call animate on each image, passing two keyframes, one describing the opacity of the image before the swap and another describing the opacity after the swap. We can also pass an options object with the duration and easing of the animation. Let’s try this:

function swapImages(parent: HTMLElement) {
parent.querySelectorAll('img').forEach(img => {
if (img.style.display === 'none') {
img.style.display = 'inline';
img.animate(
// Transition from opacity 0 to 1 over 1 second.
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1000, easing: 'ease-in' },
);
} else {
img.style.display = 'none';
img.animate(
// Transition from opacity 1 to 0 over 1 second.
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 1000, easing: 'ease-out' },
);
}
});
}

Swap with animation

Jungle coast View from the sky

Wait, what happened there? The image flickered! It was supposed to fade out, but it disappeared immediately and then faded in. What happened?

The problem with animation and style changes

The problem is that animations are asynchronous, but style changes are synchronous. When we call img.animate, it schedules the animation to run and immediately returns a handle for it; the animation doesn’t run immediately (in fact, it doesn’t start until JS is idle). But when we set img.style.display = 'none', it hides the image immediately before even the assignment is completed in JavaScript.

So we are hiding the image, and animating its fade out while it is no longer visible. Definitely not what we wanted. You might even say it is worse than without the animation now since it always looks janky, not just on the first swap.

OK… We can fix that! img.animate returns an animation object with an onfinish property that is a callback for after the animation ends. We can use that to hide the image only after the animation has already faded-out the image completely.

function swapImages(parent: HTMLElement) {
parent.querySelectorAll('img').forEach(img => {
if (img.style.display === 'none') {
img.style.display = 'inline';
img.animate(
// Transition from opacity 0 to 1 over 1 second.
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1000, easing: 'ease-in' },
);
} else {
const animation = img.animate(
// Transition from opacity 1 to 0 over 1 second.
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 1000, easing: 'ease-out' },
);
animation.onfinish = () => {
img.style.display = 'none';
};
}
});
}

Swap without flickering

Jungle coast View from the sky

WHAAT? Now the images are tiled above each other! Well, we should expect that… We now show both images at the same time, so they appear as if both were always visible. Both are animating, one with fade out and the other with fade in, but both are visible separately, simultaneously.

Superimposing old state and new state

We need to fade out the old image while fading in the new one in the same space on the screen. We need to superimpose the two images on top of each other and then run the animations on both simultaneously.

Here, we invoke some style trickery. We set the position of the container to relative so we can position the images relative to it without considering the normal element flow of the page.

<div id="images" style={{position: 'relative'}}>
<Image style={{margin: '0'}} alt="Jungle coast" src={coast} />
<Image
style={{display: 'none', margin: '0'}}
alt="Tree bird"
src={bird}
loading="eager"
/>
</div>

Then, when an image is fading out, we set its position to absolute and set its top and left to 0 so it is positioned at the beginning of the container, which will be in the same position as the new image. This way, the old image will be superimposed on top of the new one, and we can fade it out while fading in the new one.

The new image will still be positioned using the normal flow of the page, this ensures that the container and anything that follows it will not shrink to zero height during the transition. After the animation, we have to clear these position styles so the next swap works as expected.

function swapImages(parent: HTMLElement) {
parent.querySelectorAll('img').forEach(img => {
if (img.style.display === 'none') {
img.style.display = 'inline';
img.animate(
// Transition from opacity 0 to 1 over 1 second.
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1000, easing: 'ease-in' },
);
} else {
// Restyle the image to it is positioned on top of the other one
// outside of the normal flow.
img.style.position = 'absolute';
img.style.top = '0';
img.style.left = '0';
const animation = img.animate(
// Transition from opacity 1 to 0 over 1 second.
[{ opacity: 1 }, { opacity: 0 }],
{
duration: 1000,
easing: 'ease-out',
},
);
animation.onfinish = () => {
// Once the animation is finished, hide the image and reset its style
// so the container never changes size.
img.style.display = 'none';
img.style.position = null;
img.style.top = null;
img.style.left = null;
};
}
});
}

Swap with smooth animation

Jungle coast View from the sky

BEAUTIFUL! Now, we have a nice fade-out and fade-in animation between the two images. Job done, right? Well, not quite… Click twice fast on the image. You’ll see that the entire thing breaks. Both images disappear and never come back. What happened?

The user is impatient

We have a people problem now. The user is the biggest force of chaos in the world. There will always be someone relying on what you didn’t think of testing:

xkcd about workflow

In this case, the user was impatient and clicked on the image before the animation was finished. This triggered another set of animations to start, but the first set was not canceled, so now we have four animations running at the same time:

  • Fading out the first image
  • Fading in the second image
  • Fading out the second image
  • Fading in the first image

Since both images started a fade-out animation, they are positioned using position: 'absolute', so there is nothing in the normal flow inside the container, making it collapse to zero-size. At the end of those fade-out animations, both images are hidden completely, so the container remains collapsed. With the container collapsed, we can’t click on it anymore (it has a size of zero), so the user can’t trigger another swap.

We must prevent any new animation from starting while one is already running. To do that, we set a flag when we create an animation and clear it when it is finished. Before starting a new animation, we check if the flag is set, and if it is, we don’t create the animation.

function swapImages(parent: HTMLElement) {
if (parent.dataset.state === 'animating') return;
parent.dataset.state = 'animating';
parent.querySelectorAll('img').forEach(img => {
if (img.style.display === 'none') {
img.style.display = 'inline';
img.animate(
// Transition from opacity 0 to 1 over 1 second.
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1000, easing: 'ease-in' },
);
} else {
// Restyle the image to it is positioned on top of the other one
// outside of the normal flow.
img.style.position = 'absolute';
img.style.top = '0';
img.style.left = '0';
const animation = img.animate(
// Transition from opacity 1 to 0 over 1 second.
[{ opacity: 1 }, { opacity: 0 }],
{
duration: 1000,
easing: 'ease-out',
},
);
animation.onfinish = () => {
// Once the animation is finished, hide the image and reset its style
// so the container never changes size.
img.style.display = 'none';
img.style.position = null;
img.style.top = null;
img.style.left = null;
parent.dataset.state = null;
};
}
});
}

Swap with deduplicated animation

Jungle coast View from the sky

Great! We now have a nice animation working even if the user is impatient and clicks multiple times. Is this it? Is this the final version? Well, it is good enough for the point I’m making here. When transitioning between two states, we need to take care of many details that are not initially obvious. We need to think about the order of operations, the timing of the animations, the position of the elements, how to juggle both states simultaneously in the same space, how to do that without breaking everything else on the page, and so on. This is just a simple component with two images; imagine doing that for a whole page with many elements and states (that is one of the reasons the code of many SPA frameworks is so complicated).

I am, by no means, and I do mean no means, an expert in CSS and animations. In fact, I’m not even a frontend developer; I only do backend professionally. I’m sure many things could be done better in this component. But the point is that it is not easy to get right, and it is especially difficult to get right in a reusable and maintainable way.

The world after: View Transitions API

The browser View Transitions API is intended to help with those scenarios. It kinda follows the Unix philosophy of “do one thing and do it well”. It doesn’t try to do everything, it just does ONE thing: make transitions between two DOM states without worrying about intermediary states.

The API is exceedingly simple; on its JavaScript side, it is just a single method and an object with three promise handles:

interface Document {
startViewTransition(updateCallback: () => Promise<void> | void): ViewTransition;
}
interface ViewTransition {
finished: Promise<void>;
ready: Promise<void>;
updateCallbackDone: Promise<void>;
skipTransition(): void;
}

That’s it, that is the entire API. What is so magical about it? Well, let’s see it in action. Remember that very first example we had? The one that just swapped the images without any animation? Let’s see what happens when we wrap that in a call to document.startViewTransition:

---
import coast from '@/assets/jungle-coast.jpg';
import bird from '@/assets/tree-bird.jpg';
import {Image} from 'astro:assets';
---
<div id="images">
<Image style={{margin: '0'}} alt="Jungle coast" src={coast} />
<Image
style={{display: 'none', margin: '0'}}
alt="Tree bird"
src={bird}
loading="eager"
/>
</div>
<script>
function swapImages(parent: HTMLElement) {
const imgs = parent.querySelectorAll('img');
imgs.forEach(img => {
img.style.display = img.style.display === 'none'
? 'block' : 'none';
});
}
const images = document.getElementById('images');
images.addEventListener('click', () => {
document.startViewTransition(() => swapImages(images));
});
</script>

Animation with View Transition

Jungle coast Tree bird

Aaaand it works… Just that. It works. It swaps the images with a smooth transition between them. No flickering, no jank, no collapsing container, no more messing with styles to superimpose the images, nor worrying about the order of operations.

HOW?

Finally, the question proposed at the beginning of this article. How does it work? When we call document.startViewTransition, it goes through a few steps:

  1. The browser immediately takes a snapshot of the entire DOM state and shows that snapshot on top of the real DOM as the pseudo-element ::view-transition-old(scope).
  2. It then calls the updateCallback function we passed to it. This function can freely update the DOM, switching directly to the new state without worrying about intermediary states.
  3. Once that function returns (or the promise it returns resolves), the API takes another snapshot of the DOM and shows it on top of the page but below the old state. This is added with the pseudo-element ::view-transition-new(scope).
  4. It then starts an animation that fades out of the old state and fades in the new one. You can use those pseudo-elements to style the transition itself, be it by changing the animation, the duration, the easing, or even the position of the elements in those pseudo-states.

But what is that scope thing? It is an identifier, so you can style multiple portions of the DOM state transition differently. By default, any change to any element goes to the root scope, but you can style an element into a different scope. This allows you, for example, to have one element that takes slightly longer to fade in than everything else on the page. Or one that slides in while everything else fades.

With that, we can see how the problems we had before are solved:

  • This is an entirely asynchronous API, so we can delay until the new image is loaded. We can even show a loading indicator while the image is loading.
  • The snapshot of the DOM being shown on top of the document allows us to switch the style of the images without them flickering in front of the user.
  • There can only be one running transition at a time, so we don’t have to worry about the user clicking multiple times and breaking everything.
  • Since both pseudo-elements are independent snapshots of the entire DOM, we don’t need to handle the unique positioning of the elements. They’re already positioned precisely where they should be.

How does this tie in with Astro?

Astro’s <ViewTransitions /> component is a wrapper around this API but has a much bigger goal. That component is intended to make all navigations between static Astro pages behave like an SPA, transitioning between the pages and allowing state to persist between pages.

Then why tie them both together? What makes this API so special for Astro’s use case that it deserves to have the component named after it?

The browser API might be simple on the surface, but it enables a whole new world of possibilities. For example, what if we switch, one by one, each element in a page’s <head> section? This would change the style of the entire page and its metadata, but thanks to this API, this can be done without any flickering for the user since the browser will show the old state until everything is ready to be shown at once.

What if we change every element on the page? Like, with all the elements of another page that we fetched from the server? It’s the same experience as an SPA but without all the complexity of an SPA.

Those are some of the things that Astro builds on top of this API. To understand how all those features and others work, check back here for the following articles. In the next one, we’ll start building a simplified version of Astro’s <ViewTransitions /> component from scratch, step by step, so we can understand how it does all its magic.