Unlocking view transitions in SvelteKit 1.24
Streamlined page transitions with onNavigate
The view transitions API has been sweeping the web development world lately, and for good reason. It streamlines the process of animating between two page states, which is especially useful for page transitions.
However, until now, you couldn’t easily use this API in a SvelteKit app, since it was difficult to slot into the right place in the navigation lifecycle. SvelteKit 1.24 brought a new onNavigate
lifecycle hook to make view transitions integration much easier – let’s dive in.
How view transitions workpermalink
You can trigger a view transition by calling document.startViewTransition
and passing a callback that updates the DOM somehow. For our purposes today, SvelteKit will update the DOM as the user navigates. Once the callback finishes, the browser will transition to the new page state — by default, it does a crossfade between the old and the new states.
ts
Property 'startViewTransition' does not exist on type 'Document'.2339Property 'startViewTransition' does not exist on type 'Document'.document .(async () => { startViewTransition awaitdomUpdate (); // mock function for demonstration purposes});
Behind the scenes, the browser does something really clever. When the transition starts, it captures the current state of the page and takes a screenshot. It then holds that screenshot in place while the DOM is updating. Once the DOM has finished updating, it captures the new state, and animates between the two states.
While it’s only implemented in Chrome (and other Chromium-based browsers) for now, WebKit is also in favor of it. Even if you’re on an unsupported browser, it’s a perfect candidate for progressive enhancement since we can always fall back to a non-animated navigation.
It’s important to note that view transitions is a browser API, not a SvelteKit one. onNavigate
is the only SvelteKit-specific API we’ll use today. Everything else can be used wherever you write for the web! For more on the view transitions API, I highly recommend the Chrome explainer by Jake Archibald.
How onNavigate workspermalink
Before learning how to write view transitions, let's highlight the function that makes it all possible: onNavigate
.
Until recently, SvelteKit had two navigation lifecycle functions: beforeNavigate
, which fires before a navigation starts, and afterNavigate
, which fires after the page has been updated following a navigation. SvelteKit 1.24 introduces a third: onNavigate
, which will fire on every navigation, immediately before the new page is rendered. Importantly, it will run after any data loading for the page has completed – since starting a view transition prevents any interaction with the page, we want to start it as late as possible.
You can also return a promise from onNavigate
, which will suspend the navigation until it resolves. This will let us wait to complete the navigation until the view transition has started.
ts
functiondelayNavigation () {return newPromise ((res ) =>setTimeout (res , 100));}Cannot find name 'onNavigate'.Parameter 'navigation' implicitly has an 'any' type.2304(async ( onNavigate ) => { navigation
7006Cannot find name 'onNavigate'.Parameter 'navigation' implicitly has an 'any' type.// do some work immediately before the navigation completes// optionally return a promise to delay navigation until it resolvesreturndelayNavigation ();});
With that out of the way, let's see how you can use view transitions in your SvelteKit app.
Getting started with view transitionspermalink
The best way to see view transitions in action is to try it yourself. You can spin up the SvelteKit demo app by running npm create svelte@latest
in your local terminal, or in your browser on StackBlitz. Make sure to use a browser that supports the view transitions API. Once you have the app running, add the following to the script block in src/routes/+layout.svelte
.
ts
import {Module '"$app/navigation"' has no exported member 'onNavigate'.2305Module '"$app/navigation"' has no exported member 'onNavigate'.} from '$app/navigation'; onNavigate Parameter 'navigation' implicitly has an 'any' type.7006Parameter 'navigation' implicitly has an 'any' type.onNavigate (() => { navigation if (!Property 'startViewTransition' does not exist on type 'Document'.2339Property 'startViewTransition' does not exist on type 'Document'.document .) return; startViewTransition return newPromise ((resolve ) => {Property 'startViewTransition' does not exist on type 'Document'.2339Property 'startViewTransition' does not exist on type 'Document'.document .(async () => { startViewTransition Expected 1 argument, but got 0. 'new Promise()' needs a JSDoc hint to produce a 'resolve' that can be called without arguments.2810Expected 1 argument, but got 0. 'new Promise()' needs a JSDoc hint to produce a 'resolve' that can be called without arguments.resolve ();awaitnavigation .complete ;});});});
With that, every navigation that occurs will trigger a view transition. You can already see this in action – by default, the browser will crossfade between the old and new pages.
How the code works
This code may look a bit intimidating – if you're curious, I can break it down line-by-line, but for now it’s enough to know that adding it will allow you to interact with the view transitions API during navigation.
As mentioned above, the onNavigate
callback will run immediately before the new page is rendered after a navigation. Inside the callback, we check if document.startViewTransition
exists. If it doesn’t (i.e. the browser doesn’t support it), we exit early.
We then return a promise to delay completing the navigation until the view transition has started. We use a promise constructor so that we can control when the promise resolves.
ts
A 'return' statement can only be used within a function body.1108A 'return' statement can only be used within a function body.return new Promise ((resolve ) => {document .startViewTransition (async () => {resolve ();awaitnavigation .complete ;});});
Inside the promise constructor, we start the view transition. Inside the view transition callback we resolve the promise we just returned, which indicates to SvelteKit that it should finish the navigation. It’s important that the navigation waits to finish until after we start the view transition – the browser needs to snapshot the old state so it can transition to the new state.
Finally, inside the view transition callback we wait for SvelteKit to finish the navigation by awaiting navigation.complete
. Once navigation.complete
resolves, the new page has been loaded into the DOM and the browser can animate between the two states.
It’s a bit of a mouthful, but by not abstracting it we allow you to interact with the view transition directly and make any customizations you require.
Customizing the transition with CSSpermalink
We can also customize this page transition using CSS animation. In the style block of your +layout.svelte
, add the following CSS rules.
@keyframes fade-in {
from {
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes slide-from-right {
from {
transform: translateX(30px);
}
}
@keyframes slide-to-left {
to {
transform: translateX(-30px);
}
}
:root::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
:root::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms cubic-bezier(0.4, 0, 0.2, 1) both
slide-from-right;
}
Now when you navigate between pages, the old page will fade out and slide to the left, and the new page will fade in and slide from the right. These particular animation styles come from Jake Archibald’s excellent Chrome Developers article on view transitions, which is well worth a read if you want to understand everything you can do with this API.
Note that we have to add :root
before the ::view-transition
pseudoelements – these elements are only on the root of the document, so we don’t want Svelte to scope them to the component.
You might have noticed that the entire page slides in and out, even though the header is the same on both the old and new page. To make for a smoother transition, we can give the header a unique view-transition-name
so that it is animated separately from the rest of the page. In src/routes/Header.svelte
, find the header
CSS selector in the style block and add a view transition name.
header {
display: flex;
justify-content: space-between;
view-transition-name: header;
}
Now, the header will not transition in and out on navigation, but the rest of the page will.
Fixing the types
Since startViewTransition
is not supported by all browsers, your IDE may not know that it exists. To make the errors go away and get the correct typings, add the following to your app.d.ts
:
ts
declareglobal {// preserve any customizations you have herenamespaceApp {// interface Error {}// interface Locals {}// interface PageData {}// interface Platform {}}// add these linesinterfaceViewTransition {updateCallbackDone :Promise <void>;ready :Promise <void>;finished :Promise <void>;skipTransition : () => void;}interfaceDocument {startViewTransition (updateCallback : () =>Promise <void>):ViewTransition ;}}export {};
Transitioning individual elementspermalink
We just saw how giving an element a view-transition-name
separates it out from the rest of the page's animation. Setting a view-transition-name
also instructs the browser to smoothly animate it to its new position after the transition completes. The view-transition-name
acts as a unique identifier so the browser can identify matching elements from the old and new states.
Let’s see what that looks like – our demo app’s navigation has a small triangle indicating the active page. Right now, it abruptly appears in the new position after we navigate. Let’s give it a view-transition-name
so the browser animates it to its new position instead.
Inside src/routes/Header.svelte
, find the CSS rule creating the active page indicator and give it a view-transition-name
:
li[aria-current='page']::before {
/* other existing rules */
view-transition-name: active-page;
}
By adding that single line, the indicator will now smoothly slide to its new position instead of jumping.
(It might be easy to miss the difference – look at the small moving triangle indicator at the top of the screen!)
Reduced motionpermalink
It’s important to respect our users’ motion preferences while implementing animation on the web. Just because you can implement an extreme page transition doesn’t mean you should. To disable all page transitions for users who prefer reduced motion, you can add the following to the global styles.css
:
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
While this may be the safest option, reduced motion does not necessarily mean no animation. Instead, you could consider your view transitions on a case-by-case basis. For instance, maybe we disable the sliding animation, but leave the default crossfade (which doesn’t involve motion). You can do so by wrapping the ::view-transition
rules you want to disable in a prefers-reduced-motion: no-preference
media-query:
@media (prefers-reduced-motion: no-preference) {
:root::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 300ms cubic-bezier(0.4, 0, 0.2, 1) both
slide-to-left;
}
:root::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms cubic-bezier(
0.4,
0,
0.2,
1
) both slide-from-right;
}
}
What’s next?permalink
As you can see, SvelteKit doesn’t abstract a whole lot about how view transitions work – you’re interacting directly with the browser’s built-in document.startViewTransition
and ::view-transition
APIs, rather than framework abstractions like those found in Nuxt and Astro. We’re eager to see how people end up using view transitions in SvelteKit apps, and whether it makes sense to add higher level abstractions of our own in future.