By using two sequential View Transitions when intercepting links with the Navigation API – one in the precommitHandler and one in the regular handler – you can build a Two-Phase View Transition today!
~
Two-Phase View Transitions
Yesterday, Jake shared a short video on Two-Phase View Transitions, a concept we have been exploring at Google.
If you didn’t watch the video, or want a recap, the problem with Cross-Document View Transitions is that it waits for the navigation to commit upon which the View Transition runs. This is a bit of a problem when it takes a while to load the new page: there is a significant delay between you clicking the the link and the View Transition actually starting.
Two-Phase View Transitions aim to solve this, by allowing the transition to start immediately after the click and before the navigation commits. To do this, it would require you to transition into some sort of intermediate preview state, such as a loading spinner or skeleton screen.
Usage could be something like this:
// If the next document is not ready to render
if (!navigateEvent.nextDocumentReadyToRender) {
// Start a View Transition into the preview state
const transition = document.startViewTransition(show_preview);
// Defer committing the navigation until we have transitioned into that state
navigateEvent.deferCommit(transition.finished);
}
In his video, Jake is making the case that it needs to be intuitive to recover out of this intermediate state when, for example, pressing the back button.
~
The Navigation API’s precommitHandler
An exciting addition to the Navigation API is a precommitHandler, which gives you a way to defer the commit when intercepting navigations. This feature shipped in Chrome 141 and is something I needed for View Transitions.
You can see it in action in this demo. The code for my demo looks something like this:
let t;
navigation.addEventListener("navigate", (e) => {
// …
e.intercept({
precommitHandler: async () => {
// Get new content
const response = await fetch(e.destination.url, {
signal: e.signal,
});
// Update the DOM (with a View Transition)
t = document.startViewTransition(() => {
updateTheDOM(response);
});
},
handler: async () => {
// Cleanup
await t.finished;
// …
}
});
});
On a slow connection, the navigation won’t commit until the contents of the next view are actually loaded. This allows you to click away, thereby aborting that ongoing navigation, without any problems. For more details, refer to WICG/navigation-api#277.
😔 The precommitHandler is also, unfortunately, a feature that is NOT part of the Interop 2025 Navigation API Focus Area because it only got developed throughout the year.
~
View Transitions + precommitHandler = DIY Two-Phase View Transitions
While chatting with Noam about Two-Phase View Transitions back in September, it dawned on me that I could use the precommitHandler to build a Two-Phase View Transitions. When Martin also shared his excitement for Two-Phase View Transitions on Bsky later that month, I shared that it was totally hackable:
I hacked something together that approximates this using two View Transitions and the Navigation API’s `precommitHandler` option when intercepting navigations.
view-transitions.chrome.dev/tests/split-…
— Bramus (@bram.us) September 18, 2025 at 3:17 PM
The demo I threw together first transitions into a loading screen with spinner, before transitioning into the new contents … similar to the Two-Phase View Transition Concept. You should check it out using Chromium, as that’s the only family of browsers with support for the precommitHandler. If you can’t or won’t, check out the following recording:
The demo uses two View Transitions under the hood: one outgoing View Transitions that fades out the old page showing the loading screen, followed by an incoming View Transition that fades in the new content.
The code looks something like this, which is pretty much the same as before:
let outgoingViewTransition = null;
let incomingViewTransition = null;
navigation.addEventListener("navigate", (e) => {
// …
e.intercept({
precommitHandler: async () => {
// Start outgoing View Transition
outgoingViewTransition = document.startViewTransition({
update: () => {
// We retain the page having dipped to black by injecting an overlay onto the page
document.documentElement.dataset.pendingTransition = 'true';
},
types: ['outgoing', 'fade-out'],
});
// Get new content
const response = await fetch(e.destination.url, {
signal: e.signal,
});
// Update the DOM (with a View Transition)
t = document.startViewTransition(() => {
updateTheDOM(response);
});
},
handler: async () => {
// Wait for outgoing View Transition to be finished first
if (outgoingViewTransition) {
await outgoingViewTransition.finished;
}
// Start incoming View Transition
incomingViewTransition = document.startViewTransition({
update: () => {
// Remove overlay
delete document.documentElement.dataset.pendingTransition;
// Update page with fetched data
document.title = pageData.title;
document.body.replaceWith(pageData.$body);
},
types: ['incoming', 'fade-in'],
});
}
});
});
The loading screen is faked by injecting the spinner into the ::view-transition overlay:
::view-transition {
background: black url(loading.gif) no-repeat center center / 2em 2em;
}
🧐 I also noticed a small frame glitch from time to time when running with only the code above. This happens when the View Transition finishes before the data has actually been loaded.
My code predates the addition of ViewTransition.waitUntil() (which prevents a View Transition from finishing before a passed in promise has resolved), so I worked around the visual glitch by duplicating the loading spinner on the document using the data-pending-transition attribute on the document that gets set/unset.
html[data-pending-transition]::before {
content: '';
position: fixed;
inset: 0;
background: black url(loading.gif) no-repeat center center / 2em 2em;
z-index: Infinity;
}
With ViewTransition.waitUntil(), this would not be needed, and it’d be as simple as adding the following:
outgoingViewTransition.waitUntil(dataHasBeenFetched);
To only show the old state during the outgoing transition and the new state during the incoming transition, I’m using View Transition types
:active-view-transition-type(outgoing) {
&::view-transition-old(root) {
animation: none;
}
&::view-transition-new(root) {
display: none;
}
}
:active-view-transition-type(incoming) {
&::view-transition-old(root) {
display: none;
}
&::view-transition-new(root) {
animation: none;
}
}
The fade-in/fade-out then is set on the entire ::view-transition-group(), which is unconventional but possible:
:active-view-transition-type(fade-out) {
&::view-transition-group(root) {
animation: fade-out 0.5s forwards;
}
}
:active-view-transition-type(fade-in) {
&::view-transition-group(root) {
animation: fade-in 0.5s forwards;
}
}
By setting those animations on the group, one can easily swap them out to, for example, slide-down/slide-up animations
:active-view-transition-type(slide-down) {
&::view-transition-group(root) {
animation: slide-down 0.5s forwards;
}
}
:active-view-transition-type(slide-up) {
&::view-transition-group(root) {
animation: slide-up 0.5s forwards;
}
}
You could easily pass in any of the Page Transitions effects here 🙂
~
So we can use this?
Maybe.
Given that support for precommitHandler is very limited and does not have something like nextDocumentReadyToRender, the hack will either not work at all, or work too much. And I’m not sure if that is something you’d want.
However, you could move all the code into the handler itself in browsers with no support for precommitHandler, but then know that you’ll end up with the current navigation entry getting updated too soon (which might be OK for your use-case). As for the lack of nextDocumentReadyToRender, you could hack something together with an AbortSignal to prevent the VT from running if the page was loaded in timely manner, but I’ll leave that up to you to dig into 😉
~
# Spread the word
Feel free to reshare one of the following posts on social media to help spread the word:
~
🔥 Like what you see? Want to stay in the loop? Here's how:
I can also be found on 𝕏 Twitter and 🐘 Mastodon but only post there sporadically.