The View Transition API brings page transitions and state-based UI changes — previously only possible with JavaScript frameworks — to the wider web.
This includes animating between DOM states in a single-page app (SPA) and animating the navigation between pages in a multi-page app (MPA). In other words, it brings view transitions to any type of website without bulky JavaScript dependencies and heady complexity. This is a win for users and developers! It is a game changer potentially.
In this article, I will focus on view transitions in MPAs. This is defined in the CSS View Transitions Module Level 2 specification and is referred to as cross-document view transitions. The cool thing is that the basics can be achieved without JavaScript — just a bit of declarative CSS will get you up and running! JavaScript is required when you want to implement some conditional logic.
Cross-document view transitions are now supported in both Chrome 126 and Safari 18.2. We can dive in straight away and use view transitions as a progressive enhancement today! 🙌
View transitions improve the navigation experience. More specifically, they can:
All view transitions involve the following three steps:
opacity: 1
to opacity: 0
while the new view animates from opacity: 0
to opacity: 1
The major difference between a view transition in a SPA and an MPA is how the transition is triggered. In an MPA, a view transition is triggered by navigating to another page — this can happen by clicking on a link or submitting a form. Navigations that don’t trigger a view transition include navigating using the URL address bar, clicking a bookmark, and reloading a page.
If a navigation takes too long, then the view transition is skipped, resulting in an error. Chrome’s limit is four seconds. It’s unclear what defines the start of a navigation in this context — is it that initial bytes have to be downloaded?
Interestingly, a single page can have multiple view transitions. We can target specific subtrees of the DOM for transitions, and it’s even possible to nest them.
There are some conditions for enabling view transitions, which we’ll cover in the next section.
To enable view transitions for a website, two key conditions need to be met:
@view-transition
CSS at-rule, as shown below:@view-transition { navigation: auto; }
With that, the default crossfade view transition should be enabled for the pages. Let’s look at an example.
Here is a demo of a carousel featuring a set of photos. A carousel is a component that permits cycling through a set of content, such as photos in a photo gallery. In this example, I slowed down the animation to two seconds to highlight the effect (more on this later):
View transitions allow us to create a carousel where each page is an item. Links to the next page and the previous page are all that is necessary in the CSS snippet above. There is no need to juggle items with code or to stuff a ton of images into a single page:
<!-- index.html - first page--> <h1>Cape Town</h1> <img src="cape-town.webp" alt=".."/> <a href="page2.html" class="next"><img src="/1-carousel/shared/img/arrow-right.svg" /></a> <!-- page2.html - second page--> <h1>Hong Kong</h1> <img src="hong-kong.webp" alt=".."/> <a href="index.html" class="previous"><img src="/1-carousel/shared/img/arrow-left.svg" /></a> <a href="page3.html" class="next"><img src="/1-carousel/shared/img/arrow-right.svg" /></a>
Here is an overview figure of the pages involved so you can better understand what is happening:
It would be remiss of me not to mention that there are three other conditions for enabling view transitions. You are likely satisfying these conditions by default.
The fine print of the specification states that all of these conditions must be met:
We can customize the animation through pseudo-elements:
::view-transition-group()
is used to reference a particular view transition::view-transition-old()
is used to reference the source view (outbound transition)::view-transition-new()
is used to reference the target view (inbound transition)For each of these pseudo-elements, we provide the view transition name as an argument to reference the view transition we are interested in. The default name for the view transition of a page is root
as it applies to the :root
element.
You can change the duration of the animation by putting the following on both pages:
::view-transition-group(root) { animation-duration: 3s; }
To create a different transition effect, we can set animations for the source (old) and target (new) views separately.
For example, let’s make a demo with a shrink animation. We will shrink the source page out of view, and have the target page expand into view:
We can do that with the following CSS:
@keyframes shrink { to { scale: 0; } } ::view-transition-old(root) { animation: shrink 1s; } ::view-transition-new(root) { animation: shrink 1s; animation-direction: reverse; }
Notice that we set animation-direction: reverse
on the target view transition; this makes the “shrink” animation expand!
Having opposite actions creates a symmetric effect, which can be pleasing. You don’t have to do this — you can treat each animation as a separate entity. You are free to come up with whatever tickles your fancy!
For cross-document view transitions, these pseudo-elements are only available on the target page. Don’t forget about this if you are making a view transition that goes in only one direction.
Let’s see what else we can do with our view transitions — this time, introducing JavaScript!
So far, we have demonstrated that we can enable cross-document view transitions and customize the animations with CSS. This is powerful, but when we want to do more, we need JavaScript.
The View Transition API does not cover all our needs. There are some complementary web features designed to be used in conjunction with it. They fall into the following categories:
pageswap
and pagereveal
events enable specifying conditional actions for view transitions. The pageswap
event is fired before the source page unloads, and the pagereveal
is fired before rendering the target pageNavigationActivation
objects that hold information about same-origin navigations. This saves developers the hassle of keeping track of this information themselves when they want to perform different animations/actions based on different URLsWe will discuss these features further with some examples.
pageswap
and pagereveal
eventsThe pageswap
and pagereveal
events give us the opportunity to perform some conditional logic for a view transition.
The pageswap
event is fired at the last moment before the source page is about to be unloaded and swapped by the target page. It can be used to find out whether a view transition is about to take place, customize it using types, make last-minute changes to the captured elements, or skip the view transition entirely.
The pagereveal
event is fired right before presenting the first frame of the target page. It can be used to act on different navigation types, make last-minute changes to the captured elements, wait for the transition to be ready in order to animate it, or skip it altogether.
In both of these events, you can access a ViewTransition
object using the ViewTransition
property. The ViewTransition
object represents an active view transition and provides functionality to react to the transition reaching different states e.g., when the animation is about to run, and when the animation has just finished.
Let’s look at an example to tie the concepts together.
Let’s create a demo to allow the user to disable/enable view transitions. I will add a checkbox to our carousel in the top right corner. If it is checked, we will disable (skip) view transitions:
We need to modify our HTML to add our checkbox input
, and we need to add a script
tag to point to the script we are about to write. We must add the script tag as a parser-blocking script in the <head>
. This is because the pagereveal
event must execute before the first rendering opportunity. This means the script can’t be a module, can’t have the async
attribute, and can’t have the defer
attribute:
<!DOCTYPE html> <html lang="en"> <head> <!-- other elements as before--> <!-- our script must be here exactly like this--> <script src="script.js"></script> </head> <body> <label>Skip?<input type="checkbox" id="skip" /></label> <!-- other elements as before--> </body> </html>
In our script, we will add event handlers for the pageswap
and pagereveal
events. In the pageswap
event handler, we write the value of the checkbox (true or false) to session storage, saving it as the skip
variable.
Notice that I consult the ViewTransition
object to decide if we want to store the value or not. The ViewTransition
object is null if there is no view transition taking place. Therefore, this check will return true
when a view transition is taking place.
In the pagereveal
event handler, we read the value of the skip
variable from session storage. If skip
has a value of “true” (session storage saves all values as strings), then we skip the view transition by calling the ViewTransition.skipTransition()
function:
/* script.js */ // Write to storage on old page window.addEventListener("pageswap", (event) => { if (event.viewTransition) { let skipCheckbox = document.querySelector("#skip"); sessionStorage.setItem("skip", skipCheckbox.checked); } }); // Read from storage on new page window.addEventListener("pagereveal", (event) => { if (event.viewTransition) { let skip = sessionStorage.getItem("skip"); let skipCheckbox = document.querySelector("#skip"); if (skip === "true") { event.viewTransition.skipTransition(); skipCheckbox.checked = true; } else { skipCheckbox.checked = false; } } });
We use the value from session storage in pagereveal
to persist the checkbox state in the target page. This maintains the checkbox state between page navigations. Remember that HTTP is a stateless protocol; it will forget everything about the previous page unless you tell it!
If you are not familiar with session storage, you can inspect session storage in Chrome’s DevTools. You will find it in the Application tab (as seen in the image below). On the sidebar under the Storage category, you will see a Session storage item. Click on it and you should see the origin of your website e.g., http://localhost:3000. Click on it and it will reveal all of the stored values:
The Application tab in Chrome DevTools with the Session storage item open that is contained under the Storage category in the sidebar
In the pageswap
and pagereveal
events, you can take actions based on the navigation that is taking place. This information is available through the NavigationActivation
object. This object exposes the used navigation type, the source page navigation history entry, and the target page navigation history entry. It is through these navigation history entries that we can get the URL of each page. At the time of writing, only Chrome supports the NavigationActivation
object.
Let’s make a demo to add a slide animation to our carousel. We want the following to happen:
For this scenario, you can use view transition types. You can assign one or more types to an active view transition through a Set
object available in the ViewTransition.types
property. For our example, when transitioning to a higher page in the sequence, we will assign the next
type, and when going to a lower page we assign the previous
type.
Each of the types can be referenced in CSS to assign different animations:
Overview of the assigning of types for page navigations. The link pointing to a page lower in the sequence is assigned a previous type, and a link pointing to a page higher in the sequence is assigned a next type.
Sounds good, right? But how do we determine the type?
It’s up to you to determine the type!
In this case, I will inspect the URL of the source page and target page to identify their order. We can get the URL of the source page and target page from the NavigationActivation
object. It contains a from
attribute that represents the source page as a history entry, and an entry
attribute that represents the target page as a history entry.
Because we follow a naming convention for our files that indicates their order, we can use this to identify an index for each page. The order is as follows:
index.html
page2.html
page3.html
In our code, our determineTransitionType
function will compare the indexes of the source page and target page to determine if it is a previous
type or next
type:
window.addEventListener("pageswap", async (e) => { if (e.viewTransition) { let transitionType = determineTransitionType( e.activation.from.url, e.activation.entry.url ); e.viewTransition.types.add(transitionType); } }); window.addEventListener("pagereveal", async (e) => { if (e.viewTransition) { // pagereveal does not expose the NavigationActivation object, we must get it from the global object let transitionType = determineTransitionType( navigation.activation.from.url, navigation.activation.entry.url ); e.viewTransition.types.add(transitionType); } }); function determineTransitionType(sourceURL, targetURL) { const sourcePageIndex = getIndex(sourceURL); const targetPageIndex = getIndex(targetURL); if (sourcePageIndex > targetPageIndex) { return "previous"; } else if (sourcePageIndex < targetPageIndex) { return "next"; } return "unknown"; } function getIndex(url) { let index = -1; let filename = new URL(url).pathname.split("/").pop(); if (filename === "index.html") { index = 1; } // extract a number from the filename let numberMatches = /\d+/g.exec(filename); if (numberMatches && numberMatches.length === 1) { index = numberMatches[0]; } return index; }
In our stylesheet, we specify the four animations required. I was quite literal with their names:
@keyframes slide-in-from-left { from { translate: -100vw 0; } } @keyframes slide-in-from-right { from { translate: 100vw 0; } } @keyframes slide-out-to-left { to { translate: -100vw 0; } } @keyframes slide-out-to-right { to { translate: 100vw 0; } }
We associate the animations with the view transition type with the :active-view-transition-type()
pseudo-class, and we provide the type as an argument.
For each type, we specify the animation for the source page with ::view-transition-old()
and the target page with ::view-transition-new()
:
::view-transition-group(root) { animation-duration: 400ms; } html:active-view-transition-type(next) { &::view-transition-old(root) { animation-name: slide-out-to-left; } &::view-transition-new(root) { animation-name: slide-in-from-right; } } html:active-view-transition-type(previous) { &::view-transition-old(root) { animation-name: slide-out-to-right; } &::view-transition-new(root) { animation-name: slide-in-from-left; } }
It takes a bit to get your head around all of this! But when you get used to it, you’ll be able to pull off a diverse range of animations for cross-document view transitions. That’s an exciting prospect!
In some cases, you may want to hold off the rendering of the target page until a certain element is present. This ensures the state you’re animating is stable:
<link rel="expect" blocking="render" href="#sidebar">
This ensures that the element is present in the DOM, however, it doesn’t wait until the content is fully loaded. If you are using this feature with images or videos that may take longer to load, you should factor this in.
Use this feature wisely. Generally, we want to avoid blocking rendering! In my exploration of cross-document view transitions, I did not find a use case for this but it is good to be aware of its existence!
The browser support is strong for view transitions with both Chrome and Safari covering the majority of the APIs involved:
Feature | Chrome | Safari |
---|---|---|
Cross-Document View Transitions | v126+ | v18.2+ |
View transition types | v125+ | v18.2+ |
PageRevealEvent |
v123+ | v18.2+ |
PageSwapEvent |
v124+ | v18.2+ |
NavigationActivation interface |
v123+ | – |
Render blocking | v124+ | – |
Nested View Transition Groups | Enable with #enable-experimental-web-platform-features flag | v18.2+ |
Auto View Transition Naming | Behind #enable-experimental-web-platform-features flag | v18.2+ |
No matter how cool an animation looks, it can cause issues for people with vestibular disorders. For those users, you can choose to slow the animation down, pick a more subtle animation, or stop the animation altogether. We can use the prefers-reduced-motion
media query to achieve this.
The easiest way is to enable view transitions only for people who have no preference for reduced motion. For people with the preference, it is disabled by default:
/* Enable view transitions for everyone except those who prefer reduced motion */ @media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; } }
When working with cross-document view transitions, be careful if you are working with a hot reload development server. If pages are getting cached or the page is not being fully reloaded, then you may not see your changes reflected.
I found the easiest way to ensure caching is not taking place is to have the dev tools open and select the Disable cache checkbox on the Network tab:
Also, if you are used to debugging using console.log()
or similar, this is not effective when you are working across two pages. With every navigation, the console log will be cleared. It is better to use sessionStorage
for logging if this is your preferred debugging method.
All of the demos I covered in this article can be found in this GitHub repo. I also included some of the demos prepared by the Chrome DevRel team.
Here are links to the live pages of the demos mentioned:
Navigation between pages must be within four seconds to see the view transitions.
The ability to add page transitions and state-based UI changes to any website is a significant step forward. Being able to apply view transitions without bulky JavaScript dependencies and the heady complexity of frameworks is good for users and developers! You can get up and running with some straightforward CSS. If you need conditional logic for a view transition, then you need to write some JavaScript code.
Generally, I’ve been impressed by the capability. However, I must admit that I struggled to understand aspects of using cross-document view transitions. The relationship between the View Transition API and companion APIs such as NavigationActivation
was not apparent from the explanations I read. Once you get over those comprehension hurdles, then you can write effective view transitions with JavaScript code of a moderate length.
The browser support is strong for the APIs related to view transitions with both Chrome and Safari covering the majority of them. In any case, you can use view transitions as a progressive enhancement. Be mindful that it is a new web feature, so you may stumble upon some issues.
It is also important to understand that cross-page view transitions require fast-loading pages. If a navigation takes over four seconds, Chrome will bail on the view transition. A complementary web feature that you can use to speed up navigation is prerendering. The Speculation Rules API is designed to improve performance for future navigations. These features point towards a faster and more capable web, but it will take people to build websites in a new fashion to realize the benefits.
The capabilities of view transitions are also expanding. Nested view transitions have been added recently and some experimental additions are being explored. The Chrome DevRel team has stated that they want to add more options for the conditions of navigation, maybe even permit cross-origin view transitions!
Give view cross-document view transitions a try!
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowDevelopers can take advantage of the latest release of .NET MAUI 9 to build better user experiences that more accurately react and respond to changes in their applications.
React Islands integrates React into legacy codebases, enabling modernization without requiring a complete rewrite.
Onlook bridges design and development, integrating design tools into IDEs for seamless collaboration and faster workflows.
JavaScript generators offer a powerful and often overlooked way to handle asynchronous operations, manage state, and process data streams.