Last updated: First published:
View Transitions: What Could Possibly Go Wrong?
Most of the time, View Transitions just work. This page is for the exceptions: the quirks, the caveats, the things that quietly cancel your animations. Learn how to detect them and bring transitions back to life.
You might trip over some of these, and it could make you turn away from the API. That would be unfortunate because view transitions can be a lot of fun for developers and a real benefit for website users. My goal is to catch you before frustration sets in or you walk away, and to offer help with the most common problems that might occur.
These are things that have tripped me up, and I noticed them while helping others, which reassured me that I was not alone. I hope this list proves useful to others as well. I will keep adding items whenever I spot something that might catch you off guard. To be honest, I am probably a bit blind to some quirks by now. Please help me improve the list by sending tips on Bluesky🦋 or opening an issue on GitHub!↗. Anything that tripped you up or seems worth mentioning is welcome and might help others!
Missing opt-in
When enabling cross-document view transitions, both sides of the navigation must opt in to view transitions. Whether you use the@view-transition
at-rule or a framework component like Astro’s client router, you must include it on the page where you start the navigation and on the page you navigate to.@view-transition { navigation: auto;}
or even better:
@media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; }}
It is best to place this rule and your global view transition styles in a shared stylesheet and include it on every page.
For Astro, for example, you are best off including the client router in the global layout you use on every page. Other frameworks with view transition support work in a similar way.
---import { ClientRouter } from "astro:transitions";---
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, minimum-scale=1.0" /> <ClientRouter /> ...
Respect for reduced motion preference is already built in, so you do not need to add anything else for Astro.
Non-unique view transition names
When the View Transition API takes snapshots, it looks for elements with aview-transition-name
CSS property. No two elements selected for screenshots in the old state may have the same view transition name. Same is true when the API captures the new images.Duplicate names raise an error and cancel the view transition as if it was explicitly stopped by skipTransition()
. Of course, it is OK and often necessary to have the same names for old and new images, as this is the key to the API’s shared element transitions.
Duplicate view transition names during image capturing are likely when you define view transition names in reusable components or if you use the same existing slug in multiple places as a view transition name.
Often this can be fixed by adding some context to the view transition name, or assign names automatically and then use view transition classes for styling.
Cross-origin navigation
Cross-document view transitions are only supported if the two pages have the same origin.The origin of a URL is the part up to the first single slash. For cross-document view transitions, the protocol (http:
vs https:
), the domain, and the port must be identical. You should also check for cross-origin redirects.
https://vtbag.dev/x/
and http://unsafe.vtbag.dev:1234/y/
fail the same-origin test for all three reasons: different protocol, different domain and different port.
If the origins of the from-page and the to-page differ, or if there had been redirects to URLs of a different origin, the browser will do a normal navigation without view transitions.
If you have such a different origin navigation, there is nothing you can do to trick the browser into cross-document view transitions.
Errors while executing the update callback
Oh, that one is tricky because it can often go unnoticed for a while. If your code throws while executing the update callback, the API will silently ignore it and will not start the animations.It might be a good idea to catch and log failures during the update to detect this situation when it happens.
const transition = document.startViewTransition(...);transition.updateCallbackDone.catch((e) => { console.error("updateCallbackDone error", e);})
Call to startViewTransition()
while a view transition is active
Calling startViewTransition()
starts an asynchronous action. It takes some
time to update the DOM, capture the before and after images and to wait for
the end of the animation.The call to startViewTransition
does not wait for
all this to happen. It returns immediately even before the capturing phase.
This allows you to directly interact with the view transition object returned
by the call. Your code continues and runs in parallel to the view transition.
Thus you can directly followup with another call to startViewTransition()
.
If that happens while the former view transition is still active, it has the
same effect as calling skipTransition()
: The current animations of the
active view transition are stopped immediately or do not even start.
To avoid this situation you can await the promises of the view transition object before you continue. You can synchronize your calls by awaiting the finished promise:
const transition1 = document.startViewTransition(...);await transition1.finished;const transition2 = document.startViewTransition(...);
When document.activeViewTransition
will become baseline, you can even check for a view transition started by some other code before you start yours:
if (document.activeViewTransition) await document.activeViewTransition.finished;
Or you can use the mayStartViewTransition()
function from the Bag’s Utensil Drawer with the collisionBehavior: "chaining"
option to automatically combine and synchronize view transitions.
Update callback takes too long
The update callback forstartViewTransition()
should complete as fast as possible. After all, the renderer is blocked while this callback runs and the users can not interact with the site. On the other hand, technically you can do a lot of long running stuff and the View Transition API will wait for this asynchronous function to finish.But the patience of browsers isn’t endless. After a few seconds, about 4 for Chrome, the view transitions will be skipped with an error and animations will not even start.
Better do all preparations before calling startViewTransition()
and limit the work done in the update callback to a minimum.
Missing animations
The view transition ends as soon as the last animation of a view transition pseudo-element ends. If no such animations exist, the view transition ends immediately after taking the snapshots.How can this happen? These are the most likely triggers:
-
you explicitly opt-out of the default view transition name for the document root and define no alternatives:
:root {view-transition-name: none;} -
you have some elements with view transition names but cancel all animations:
// also gets automatically inherited by// view-transition-old and// view-transition-newview-transition-group(*) {animation-duration: 0s;}
There are also other ways to mess up, for example setting animation-name to none
.
Use the browser’s DevTools or the Bag’s Inspection Chamber and try to pause your view transition right at the start and examine the generated pseudo-elements and their animations.
Blocked To Reduce Motion
Of course, you respect the users’ preference for reduced motion for same-document and cross-document view transitions. Good!But you forgot that you lately tested that feature and set the preference for prefers-reduced-motion
to reduce
, either on the OS level or in the browser/DevTools.
Switch it back in your development environment to re-enable view transitions.
Call to skipTransition()
while a view transition is active
Calling skipTransition()
on the ViewTransition object returned by startViewTransition()
cancels the active view transition.If you call skipTransition()
before the ready promise fulfills, the view transition API will not insert the ::view-transition
pseudo-element tree and the animations won’t start at all.
The update callback will always run to completion, independent of calls to skipTransition()
.
If you call skipTransition()
after the animations started but before they end, the view transition API will remove the ::view-transition
pseudo-element tree. This interrupts all animations and reveals the target DOM. It looks like all animations suddenly jumped to their end position.
Unreliable Animations
When you use components of your UI framework to enable view transitions, this is normally not an issue. But with native cross-document view transitions, this one can really throw you off:View transitions work sometimes, sometimes they don’t. They might work when you open DevTools. And fail as soon as you close them. They work as a charm when you traverse back and forth through the browser history, but they refuse to show up when you click a link to a new page. Sounds familiar?
The reason could be that the browser starts view transitions too early during page load. If that is the case, the browser needs a bit of help to decide on the right moment. Tell it what part of the page to wait for before the transition should start.
View transition names are not correctly escaped
If you use UI framework support for view transitions, escaping names should be covered by the framework, but when using the View Transition API directly, you must be aware of this: Not every string is a valid custom ident that works as a view transition name. If your names include characters like e.g./
, &
, or ’+’, you must escape them. Otherwise, the browser will ignore your view transition name.You are safe if you stick to A
-Z
, a
-z
, 0
-9
, -
or _
, as long as the name does not start with a digit or with a dash followed by a digit. For anything else, use CSS.escape()
to properly escape the characters in your names. If CSS.escape()
is not available, use a polyfill or the escapeViewTransitionName()
function from the Bag’s Utensil Drawer.
Missing CSS rules
Styles to animate view transitions must be available in the updated DOM. That is typically not a problem for same-document view transitions, but for cross-document view transitions, the updated DOM is loaded from the target URL of the navigation.All styling for the pseudo-elements is taken from the page you navigate to. This is even true for the styling of exit animations on the page you left.
The only phase where the styles of the current page are important is when you assign view transition names for the old images, e.g.:
selector { view-transition-name: name;}
Tip: Move the view transition styling for cross-document view transitions to a global stylesheet.
Stacking order issues
The view transition pseudo-elements are created in the order in which their named elements are encountered in the old DOM. After that come the pseudo-elements for the named elements that are part of the new DOM, only. Within an image pair, the new image is created after the old image.Pseudo-elements are rendered in the order they were created. Thus elements from the new DOM might obscure elements from the old DOM. And within an image pair, the new image is painted in front of the old one.
You can not change this by setting z-index
on the named DOM elements. But you can set z-index
on the pseudo-elements to change the paint order.
The pseudo-elements created by the View Transition API float in their own stacking context high above everything else on the page. As a consequence it might look as if elements with a view transition name pop out of the page during the view transition, suddenly drawn in front of elements that overlapped them in the original DOM.
You have no chance to lift a normal DOM element high enough to obscure one of the view-transition pseudo-elements. But you can assign view transition names to such elements. That way the API will generate additional pseudo-elements, and those can than be rearranged using their z-index
property.
Build tools rewrite CSS and break selectors
You can spend hours staring at your CSS and questioning your own sanity. If you’ve been squinting at your IDE long enough, take a look at the page source as the browser actually sees it. Sometimes all the helpful UX tricks can get in your way. Here’s an example that often trips me up when working with Astro, even though I should really know better by now.Even though this example is about CSS rewriting in Astro, the same thing can happen with Vue, Svelte, or other frontend frameworks or even with some overzealous PostCSS plugin.
Look at this example of a style element in a Layout.astro
file. This shows the recommended way to consistently slow down all your view transition animations to 2 seconds.
<style>::view-transition-group(*) { animation-duration: 2s;}</style>
This is what the browser might see when tools automatically scope your styles:
<style> ::view-transition-group(*):where(.astro-h4nsz2sr) { animation-duration: 2s; }</style>
This selector will never match anything as the scoping class is set on your regular HTML elements, only. Surely it will not be set on the pseudo-elements created by the View Transition API.
Here, fixing is easy: use :global()
as in :global(::view-transition-group(*))
<style>:global(::view-transition-group(*)) { animation-duration: 2s;}</style>
Or move your pseudo-element styles into a global style element using <style is:global>
:
<style is:global> ::view-transition-group(*) { animation-duration: 2s; }</style>
Size change of the snapshot containing block
I wrote a whole hardboiled detective short story about this topic.TL;DR: watch your overflows and consider to use
<meta name="viewport" content="width=device-width, minimum-scale=1">
instead of the typical
<meta name="viewport" content="width=device-width, initial-scale=1">
Bad performance
When your transitions feel slaggy, this may have various reasons, but typically it is not the time required for waiting for and capturing snapshots, even though it might be a good practice to restrict the number of view transition groups the API has to generate.Check the behavior with different browsers. Sometimes this might also be something that has to fixed on the browser level.
Use the DevTools Performance panel to find out what takes so long.
The main causes to watch for are
- missing pre-rendering,
- excessive render blocking, especially when loading large pages during blocking
- expensive CSS rules and
- massive re-layouts.
It might also be helpful to restrict the number of pseudo elements
and animations by assigning view transition names only to elements inside the
viewport, explicitly setting user agent generated animations to “none” or
even exclude pseudo element from rendering by setting display: none
.
No reverse animation
When you replace the default cross-fade animation with a slide from the right or something similar, you might have the expectation that on back navigation, the animation should play the animation in reverse.The View Transition API can give you a bit of navigation context in its pagereveal
event. A concept of navigation direction is not part of it. If your browser supports the Navigation API, you can get the information from there. Otherwise, you need to fiddle with the History API.
When you detect a backward traversal, set a view transition type, or a class or attribute on :root
and use this in you stylesheet to control the direction of the animations.
Or you use third-party scripts like the Turn Signal for direction detection and view transition type support.
No interactivity during view transitions
By default, view transitions block all pointer events while the animations run.Currently, it is impossible to make one of the view transition pseudo-elements clickable. But you can make sure that pointer events can reach elements of the DOM behind the view transition layer.
For same-document view transitions using several scoped view transitions, instead of one global one, can further increase the interactivity of your page.
Unknown CSS selectors
This is generally true for CSS, but it can happen more often in the context of view transitions: If the browser does not recognize a selector, it ignores the entire rule. This if true even when known and unknown selectors are combined in a single rule, like this:.bar,:active-view-transition .foo, { property: value;}
If the browser does not recognize the :active-view-transition
pseudo-class, it will also skip styling .bar
. The safer approach is to separate them:
.bar { property: value;}:active-view-transition .foo, { property: value;}
Typo in the animation-name
This is a special case of “No animations” above: if theanimation-name
doesn’t match a @keyframes
definition, the animation fails silently without
an error as if animation-name
were set to none
.Clashes with browser navigation gestures
Especially on mobile, browsers might support their own gestures and animations for traversing the browser history, like swiping the current page to the right. In combination with view transitions this might look awkward.Typically, browsers suppress view transitions on navigation if they already have started their own animation.
The popstate
and navigation
events have a hasUAVisualTransition
property which you can use to skip your own view transitions when the browser has already started a native one.
Sadly, popstate
does not fire for cross-document navigation, and not all browsers that support the view transition API also support the Navigation API.
As a last resort, you could completely skip view transitions when the activation.navigationType
of the pageswap
event equals 'traverse'
.
Paused in DevTools
This is a classic that has tricked me several times and others before.While debugging view transitions, you pressed the pause button in the DevTools Animation panel, then switched the view to the console and completely forgot you had paused it.
Simple to fix, but sometimes nerve-racking to detect.
Endless animation
You can also pause animations using JavaScript, or setanimation-iteration-count
to infinite
. This or similar actions will ensure
that the view transition hangs or runs forever.