Skip to content

paoloricciuti/sveltekit-view-transition

Repository files navigation

sveltekit-view-transition

Simplified view-transition-api for Sveltekit.

MIT License GitHub last commit npm npm

Before we begin: what is a view transition?

Svelte has kinda spoiled us: we have built in animations and transitions so that we can add a little bit of micro-interactions to our websites and apps. Do you need to animate a list reordering? flip comes to the rescue. Is a div moving and morphing from one list to another? crossfade is your friend. The web platform must have seen how much svelte developers love those kind of things and has provided us with a brand new, fully progressive enhance-able api: the view transition API. This api not only allows you to animate between two states of your application regardless of the fact that two elements are actually the same or not (something that before was only possible with third party libraries or very complex code) but allows you to animate elements that are on different pages!

SvelteKit provides a way to hook into the navigation to allow the developer to start a view transition before the navigation and they do so with a very low level primitive. Here's how you can enable view transitions in SvelteKit

onNavigate((navigation) => {
	if (!document.startViewTransition) return;
	return new Promise((resolve) => {
		document.startViewTransition(async () => {
			resolve();
			await navigation.complete;
		});
	});
});

While not very complex, writing this snippets everywhere you need it can get quite tedious and sometimes based on how the view-transition-api works you might need to do other things like add classes or change the style of an element.

sveltekit-view-transition aim to ease the experience of writing easy and complex transitions in SvelteKit!

Before going in the details of how this library works and how to make use of it i want to leave here a wonderful article that explains what they are and how to use them in Vanilla JS from Jake Archibald, the main mind behind them.

Warning While view transitions are cool please don't overuse them as having too much motion can worsten your users experience rather than enhance it. Also PLEASE respect your users preference for reduced motion with @media (prefers-reduced-motion)

Installation

npm i -D sveltekit-view-transition@latest  # or pnpm/yarn

Usage

The simplest setup:

src/routes/+layout.svelte

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	setupViewTransition();
</script>

<slot />

This will automatically enable the default transition to every navigation.

Modifying the defaults:

Optionally, the default animation can be modified via ::view-transition-old(root) and ::view-transition-new(root) like so:

src/routes/+layout.svelte

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	setupViewTransition();
</script>

<slot />

<style>
	/* Disable default crossfade. */
	:root {
		view-transition-name: none;
	}

	/* Or, just modify the duration. */
	:global(::view-transition-old(root)),
	:global(::view-transition-new(root)) {
		animation-duration: 2s;
	}
</style>

transition

It's often useful to give specific parts of the page their own unique view transitions. We can do this by setting an element's view-transition-name.

One way to do this is with transition, a svelte action returned by setupViewTransition.

transition accepts a string representing the view-transition-name that should be assigned to the element using it:

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	const { transition } = setupViewTransition();
</script>

<header use:transition={'header'}>
	<!-- links -->
</header>

<slot />

<style>
	:global(::view-transition-old(header)),
	:global(::view-transition-new(header)) {
		/* ... */
	}
</style>

As you can see, the header's view transition can now be modified via ::view-transition-old(header) and ::view-transition-new(header).

Additional Options

If you want to be a bit more creative with the transitions, you can pass an object to the transition action instead of a string. This object accepts the following options:

  • name: the view-transition-name (required)
  • classes: classnames to apply to the target element during the transition. An array of strings or a function that returns one.
  • applyImmediately: Whether the transition should be applied immediately, or only during the actual navigation. A boolean or a function that returns one.
  • shouldApply: Whether the transition should be applied or not. Can be a boolean or a function that returns one.

Let's take a look at each of these options in more detail:

name

The view-transition-name -- the only required parameter. It can be a string or a function that takes a navigation object and returns a string, and will be applied to the target element during the transition. This is equivalent to setting the style property view-transition-name on an element.

Apart from the navigation object this function also receive the HTMLElement of the action (it's the property node inside the prop object) and a boolean that express if this element is actually in the viewport at the time of the transition (it's the property isInViewport inside the prop object). This allow you to easily skip a transition if the element is not in the viewport to avoid having elements that fly off the viewport (inspired by this tweet by Ryan Florence)

classes

Either an array of strings, or a function that returns an array of strings. These classes will be applied as classnames to the root element during the transition.

To demonstrate this, let's assume we want to apply a unique transition to our header anytime our "back" button is clicked.

This can be achieved by returning an array, i.e. ["back"], to our classes callback.

Apart from the navigation object this function also receive the HTMLElement of the action (it's the property node inside the prop object) and a boolean that express if this element is actually in the viewport at the time of the transition (it's the property isInViewport inside the prop object). This allow you to easily skip a transition if the element is not in the viewport to avoid having elements that fly off the viewport (inspired by this tweet by Ryan Florence)

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	const { transition } = setupViewTransition();
</script>

<header
	use:transition={{
		name: 'header',
		classes({ navigation }) {
			if (navigation.to?.route?.id === '/') {
				return ['back'];
			}
		},
	}}
>
	<a href="/">Back</a>
</header>

<!-- etc -->

Now, we can target .back::view-transition-old(back) and .back::view-transition-new(back) in our CSS and those transitions will only be applied when navigating to the home page /.

In the example above, you can see we're destructuring navigation from the provided OnNavigate object (the same object that sveltekit will pass to the onNavigate function). This object contains a lot of useful information, including the page you are navigating to, allowing us to apply classes conditionally based on the navigation.

Click here to see the full Navigation interface.
interface Navigation {
	/**
	 * Where navigation was triggered from
	 */
	from: {
		/**
		 * Parameters of the target page - e.g. for a route like `/blog/[slug]`, a `{ slug: string }` object.
		 * Is `null` if the target is not part of the SvelteKit app (could not be resolved to a route).
		 */
		params: Record<string, string> | null;
		/**
		 * Info about the target route
		 */
		route: { id: string | null };
		/**
		 * The URL that is navigated to
		 */
		url: URL;
	} | null;
	/**
	 * Where navigation is going to/has gone to
	 */
	to: {
		/**
		 * Parameters of the target page - e.g. for a route like `/blog/[slug]`, a `{ slug: string }` object.
		 * Is `null` if the target is not part of the SvelteKit app (could not be resolved to a route).
		 */
		params: Record<string, string> | null;
		/**
		 * Info about the target route
		 */
		route: { id: string | null };
		/**
		 * The URL that is navigated to
		 */
		url: URL;
	} | null;
	/**
	 * The type of navigation:
	 * - `form`: The user submitted a `<form>`
	 * - `leave`: The user is leaving the app by closing the tab or using the back/forward buttons to go to a different document
	 * - `link`: Navigation was triggered by a link click
	 * - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
	 * - `popstate`: Navigation was triggered by back/forward navigation
	 */
	type: 'form' | 'leave' | 'link' | 'goto' | 'popstate';
	/**
	 * Whether or not the navigation will result in the page being unloaded (i.e. not a client-side navigation)
	 */
	willUnload: false;
	/**
	 * In case of a history back/forward navigation, the number of steps to go back/forward
	 */
	delta?: number;
	/**
	 * A promise that resolves once the navigation is complete, and rejects if the navigation
	 * fails or is aborted. In the case of a `willUnload` navigation, the promise will never resolve
	 */
	complete: Promise<void>;
}

applyImmediately

By default, the transition name you provide will only be applied during the actual navigation, following the suggestion from Jake Archibald himself (the creator of the view transition api), which states that you shouldn't add transition names to everything -- instead, only to the elements involved in the transition. However, sometimes you want to add a transition name immediately (for example, when you're navigating back from a "detail" page and you want to animate back an image in the list).

applyImmediately is either a boolean or a function that take the navigation object (please note that this is the navigation object from the previous page, so the from will be the page that is navigating to the current page, and the to will be the current page) and returns a boolean.

Apart from the navigation object this function also receive the HTMLElement of the action (it's the property node inside the prop object) and a boolean that express if this element is actually in the viewport at the time of the transition (it's the property isInViewport inside the prop object). This allow you to easily skip a transition if the element is not in the viewport to avoid having elements that fly off the viewport (inspired by this tweet by Ryan Florence)

Here's a simple example of this in action:

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	const { transition } = setupViewTransition();

	export let data;
</script>

<ul>
	{#each data.posts as post (post.id)}
		<li
			use:transition={{
				/**
				 * in the post/[id] page we have a matching title transition name
				 */
				name: 'title',
				applyImmediately({ navigation }) {
					// this will apply the title view transition to this element
					// only if it's the one that matches the id we are coming from
					// so for example if we were visiting /post/1 and this is the
					// post with id 1. Notice that i'm checking the `from` because
					// this will actually be called when the navigation is still happening
					// from post/1 to /
					return navigation?.from?.params?.id === post.id.toString();
				},
			}}
		>
			<a href="/post/{post.id}">{post.title}</a>
		</li>
	{/each}
</ul>

In this example, when we navigate back from the /post/1 page, the title will slide into the its position in the list.

Important Note: the transition name will be added before the transition ends and removed immediately after to allow for a forward transition from another post to happen. If not removed the transition name would be duplicated.

shouldApply

As mentioned above, this can be either a boolean or a function that takes a navigation object (this time the from is this page and the to is the page you are navigating to) and returns a boolean. If the return value is true the transition name will be applied, otherwise it will not. This is useful when, for example, you want to navigate from a list to a detail.

NB: the default is true so if you don't pass shouldApply the transition name will be applied every time.

Apart from the navigation object this function also receive the HTMLElement of the action (it's the property node inside the prop object) and a boolean that express if this element is actually in the viewport at the time of the transition (it's the property isInViewport inside the prop object). This allow you to easily skip a transition if the element is not in the viewport to avoid having elements that fly off the viewport (inspired by this tweet by Ryan Florence)

So, completing the example above:

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	const { transition } = setupViewTransition();

	export let data;
</script>

<ul>
	{#each data.posts as post (post.id)}
		<li
			use:transition={{
				/**
				 * in the post/[id] page we have a matching title transition name. Note that
				 * the transition name will only be applied moments before the actual transition
				 * if and when the shouldApply function returns true
				 */
				name: 'title',
				applyImmediately({ navigation }) {
					return navigation?.from?.params?.id === post.id.toString();
				},
				shouldApply({ navigation }) {
					// if the params.id i'm navigating to is equal to the id of the post
					// we add the title transition name.
					return navigation?.to?.params?.id === post.id.toString();
				},
			}}
		>
			<a href="/post/{post.id}">{post.title}</a>
		</li>
	{/each}
</ul>

More advanced usage

on

This function is returned from setupViewTransition, and allows you to add a listener to run code during an arbitrary moment of the "lifecycle" of the view transition. It takes three arguments; the name of the event, the main callback function, and a series of options.

option name type meaning
registerDuringTransition boolean Wether the callback should be added immediately (even if there's a transition running) or not. This is because there are events that runs after the component has mounted, and if you add the listeners on mount specifying true as the third argument, this listeners will be called immediately. This might be useful if, for example, you want to modify the state after an incoming transition.
autoWrap boolean By default the function will be internally wrapped in afterNavigate (to reassign the listeners even if the navigation is towards the same page) but you can pass autoWrap as false to avoid wrapping the add listener in afterNavigate.
autoClean boolean wether the listener clean automatically after has been applied or it requires manual cleaning. It defaults to true

There are 7 types of events you can subscribe to in order of calling:

Event Name Description
before-start-view-transition event called before the transition even start if you modify the DOM here it will be included in the "screenshot" of the view-transition
before-navigation Event called before SvelteKit start to handle the navigation the view transition has
already been started
before-navigation-complete Event called after SvelteKit started to handle the navigation but before it completes.
The view-transition is still happening
after-navigation-complete Event called after the navigation from SvelteKit has completed.
The view-transition is still happening
transition-ready Event called when the view-transition is ready, the pseudo-elements are created.
update-callback-done Event called when the callback of the view-transition has finished running.
transition-finished Event called when the view-transition finish and the new view is in place

The on function also returns a function that unregister the callback when called.

off

A function to unsubscribe a specific handle from a specific event. This will rarely be necessary given that the on function already returns unsubscribe.

By default the function will be internally wrapped in afterNavigate (to reassign the listeners even if the navigation is towards the same page) but you can pass a third parameter as false to avoid wrapping the remove listener in afterNavigate.

classes

Much like the classes function on the action, this function can be called immediately in the script tag of a component to add a specific class to the :root element during a navigation. It can either be a array of strings or a function that returns an array of strings. The callback provides a navigation object (just like the one from the action).

By default the function will be internally wrapped in afterNavigate (to reassign the listeners even if the navigation is towards the same page) but you can pass a second parameter as false to avoid wrapping the add listener in afterNavigate.

<script>
	import { setupViewTransition } from 'sveltekit-view-transition';

	const { classes } = setupViewTransition();

	classes(({ navigation }) => {
		if (navigation.to?.route.id === '/') {
			return ['back'];
		}
	});
</script>

Examples

You can find some example of usage in the examples folder.

Example SvelteLab link Live demo Features
list-and-details link link entry/exit animation, dynamic name, page transition from list to detail
sveltegram link link multiple element transitions, conditional apply based on route

Contributing

Contributions are always welcome!

For the moment there's no code of conduct or contributing guideline, but if you've found a problem or have an idea, feel free to open an issue

For the fastest way to open a PR, try out Codeflow:

Open in Codeflow

Authors