Simple swap
The browser API
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
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 theswapImages
function with the#images
element as its argument. - The
swapImages
function swaps the visibility of all theimg
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:
That is good enough for a first version, but it has some problems:
-
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.
-
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.
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.
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:
Swap with animation
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.
Swap without flickering
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.
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.
Swap with smooth animation
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:
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.
Swap with deduplicated animation
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:
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
:
Animation with View Transition
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:
- 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)
. - 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. - 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)
. - 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 fetch
ed 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.