View Transition Experiments with Svelte

— 10 minute read

The View Transitions API (f.k.a. Shared Element Transitions) landed unflagged in Chrome 111 Beta, so I figured it was time to return to some of my demos using the API in Svelte and SvelteKit!

If you’re not familiar with the API, the explainer linked above is well worth the read. tl;dr, it’s a browser API that “makes it easy to change the DOM in a single step, while creating an animated transition between the two states.”

Replacing Svelte’s FLIP and crossfade transitions permalink

Last year I created this Star Wars demo to show off Svelte’s animation and transition capabilities. As an experiment, I ripped out all the Svelte animation code and replaced it with the View Transition API, and it worked quite well.

Here’s the experimental branch deployed to Vercel — it will only work if you use Chrome Canary or the latest Beta.

The implementation is slightly more complicated than the previous version using animate:flip and a crossfade transition. Essentially, every state update is wrapped in a helper function that starts a view transition and waits for the state update to complete using tick.

For example, the “select movie” button looks something like this (note the wrapping pageTransition call):

<IconButton
on:click={() => pageTransition(() => select(movie.id))}
label="select">
{@html plus}</IconButton
>

Because multiple state updates can happen when a single piece of state is updated thanks to the power of reactive statements, the helper pageTransition function will only start one transition, but run all the different queued state updates before completing the transition.

The helper function uses a bit of code from Jake Archibald to gracefully handle the API not being available (in which case, no animation will play).

The full pageTransition function looks like this:

import {tick} from 'svelte';

let cbs = [];
let inProgress = false;

function clearCallbacks() {
while (cbs.length > 0) {
const cb = cbs.pop();
cb();
}
}

export function pageTransition(fn, shouldTransition = true) {
// allows for easily toggling off the transition for certain state changes
if (!shouldTransition) {
fn();
return;
}
cbs.push(fn);
if (inProgress) {
return;
}
inProgress = true;
const t = transitionHelper({
updateDOM: async () => {
clearCallbacks();
await tick();
clearCallbacks(); // some callbacks may be queued in the middle of the transition, resolve those too
}
});

t.finished.finally(() => {
clearCallbacks();
inProgress = false;
});
}

// copied from Jake Archibald's explainer
function transitionHelper({skipTransition = false, classNames = [], updateDOM}) {
if (skipTransition || !document.startViewTransition) {
const updateCallbackDone = Promise.resolve(updateDOM()).then(() => {});

return {
ready: Promise.reject(Error('View transitions unsupported')),
updateCallbackDone,
finished: updateCallbackDone,
skipTransition: () => {}
};
}

document.documentElement.classList.add(...classNames);

const transition = document.startViewTransition(updateDOM);

transition.finished.finally(() =>
document.documentElement.classList.remove(...classNames)
);

return transition;
}

If you want to take a closer look at the implementation, see the experimental branch on the repo.

SvelteKit page transitions permalink

When I was experimenting with this API last year, I put together two SvelteKit page transition demos that I presented at the Svelte London meetup. I only blogged one of them, a simple fruit list demo (which I over-confidently titled “Part 1”). The other, a SvelteKit site displaying various Svelte Summit talks that I based on Jake Archibald’s demo, was mostly ready but never fully written up.

With the View Transitions API landing in Chrome Beta, I took the time to update these demos to the latest API, since there had been several breaking changes while the API was in an experimental state.

I’m pretty happy with how they both turned out — take a look (again, Chrome Canary or Beta only).

Fruit demo (source)

Svelte Summit demo (source)

For the full details of how the fruit list demo works, check out my updated post. The high level overview is that we set up some code in a beforeNavigate callback that starts the transition and resolves it once the navigation completes. We tell the browser which elements should transition together when changing pages by giving them the same view transition name.

The Svelte Summit demo used a similar approach, but had more complex transitions and was a bit trickier (which is why I put off doing a full write-up!) I ran into a few issues where the transitions weren’t happening due to race conditions — the new state was being shown before the transition had a chance to start, so it couldn’t capture the original state.

For instance, I tried to show a back arrow in the header based on which page we were on. This was originally a #if block in the component looking at the current value of $page.url. However, this caused a race condition where the $page store updated before the transition started, causing the back icon to disappear too soon.

Similarly, I had to change how I set view transition names on the video embed and thumbnail elements. Trying to do so via style directives was unpredictable, and sometimes the styles would be applied too late. Instead, I set the style on the transitioning DOM elements manually.

It’s unfortunate that we have to write our components in a less-idiomatic way, but the timing is tricky to get right.

But the worst race condition of the bunch was when I enabled SvelteKit’s preloading. When I did that, the transitions would consistently break — SvelteKit was just changing the page too fast! And there’s no way to tell it to wait to navigate, since beforeNavigate is synchronous.

The only way I could fix it was manually cancelling the navigation and restarting it with goto so the transition had a chance to start. This has some unintended side effects if another listener is checking the navigation type, and also doesn’t work for history traversals (e.g. hitting the back button). It doesn’t work all the time, but it’s much more consistent than before.

if (navigationType === 'link') {
cancel();
new Promise(res => setTimeout(res, 0)).then(() => goto(to?.url ?? ''));
}

Here’s the code snippet in context. I’m not happy with the workaround I found for this and want to find a better solution — maybe there’s a better API for SvelteKit to expose here?

Anyway, it was super fun to get back to these demos! I definitely spent a bit more time on them than expected. I love the effect and look forward to this API being implemented by other browsers (still TBD).

Want to find out when I post a new article? Follow me on Mastodon or Bluesky or subscribe to my RSS feed. I also have an email newsletter that I'll send out when I post something new, along with anything else I find interesting.

Previous Blog Post: How to git rebase on main without switching branches

Next Blog Post: State of Svelte Livestream