Implementing Smooth View Transitions using SvelteKit
- Software Development
- SvelteKit
- transitions
- Technology Trends
Published on
A few days ago, SvelteKit version 1.24.0 dropped with the new onNavigate
lifecycle function, to support the Web View Transitions API, which is a modern CSS-friendly way to handle page transitions.
This blog post will cover the basics of setting up an example, which I published on this GitHub repo.
We will be creating a Pokemon dashboard app with a minimalist design, the app will have two pages. The first page shows the user info with a Pokemon image, and the second page allows editing the user info and the Pokemon ID, which is used for retrieving the image. Moving between pages will show scale-in-out
transition effect. Let’s get started!
Create a new SvelteKit app using pnpm
, or whatever package manager you prefer.
pnpm create svelte@latest page-transitions
This will create a new directory in your file system, let’s go there and install the packages we need:
cd page-transitions
pnpm install
pnpm install -D unocss @iconify-json/mdi
code . # Open the project using your favorite text editor
UnoCSS is an Atomic CSS library similar to TailwindCSS, but much easier to set up. It includes ready-to-go presets that we can directly use.
Let’s head into vite.config.ts
and add UnoCSS
plugin:
import { sveltekit } from '@sveltejs/kit/vite';
import UnoCSS from 'unocss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), UnoCSS()]
});
Create a uno.config.ts
file in the root directory:
import { defineConfig, presetUno, presetIcons, presetWebFonts } from 'unocss';
export default defineConfig({
presets: [
presetUno(),
presetIcons({
collections: {
mdi: () => import('@iconify-json/mdi/icons.json').then((i) => i.default)
}
}),
presetWebFonts({
provider: 'google',
fonts: {
sans: {
name: 'Roboto',
weights: [400, 500]
}
}
})
]
});
UnoCSS is powered using presets, we are using the following official presets which are included in the main package:
- presetUno: Allows us to use
TailwindCSS
classes in our project, this preset is also compatible withwindiCSS
classes. - presetIcons: This preset is an awesome simple way to add icons using CSS classes, we will be using
@iconify-json/mdi
package for material design icons, only the icons we use will be added to our output bundle, nothing more - presetWebFonts: For adding web fonts into our project. We will be using
Roboto
google font.
We have created our project and set it up, let’s move to the page transitions implementation.
Create a parent layout file in your route directory: routes/+layout.svelte
and add the following code:
<script lang="ts">
import 'virtual:uno.css';
import { onNavigate } from '$app/navigation';
import HeaderItem from '$lib/HeaderItem.svelte';
onNavigate(() => {
// check browser compatibility
if (!document.startViewTransition) return;
return new Promise((fulfil) => {
/* This will take a screenshot of the whole page, and freeze
until the animation promise resolves */
document.startViewTransition(() => new Promise(fulfil));
});
});
</script>
<div class="h-screen flex flex-col font-sans font-medium text-white">
<header>
<nav class="flex justify-center gap-4 bg-sky p-2.5">
<HeaderItem href="/">
<i class="i-mdi-view-dashboard w-6 h-6" />
<span>Dashboard</span>
</HeaderItem>
<HeaderItem href="/edit">
<i class="i-mdi-account w-6 h-6" />
<span>Edit Profile</span>
</HeaderItem>
</nav>
</header>
<div class="flex flex-col flex-grow min-h-0 content">
<slot />
</div>
</div>
<style>
/* For animating the inner content only, without the nav-bar,
we assign a unique transition name */
.content {
view-transition-name: content;
}
/* Set transition origin for old and new snapshots of the inner page */
:root::view-transition-old(content),
:root::view-transition-new(content) {
transform-origin: center;
}
/* Animate the old snapshot out */
:root::view-transition-old(content) {
animation: 1s ease-in-out both scale-out-slide-left;
}
/* Animate the new snapshot in */
:root::view-transition-new(content) {
animation: 1s ease-in-out both slide-left-scale-in 0.5s;
}
/* Define the animations */
@keyframes scale-out-slide-left {
0% {
transform: scale(1) translateX(0);
}
50% {
transform: scale(0.8) translateX(0);
}
100% {
transform: scale(0.8) translateX(-150vw);
}
}
@keyframes slide-left-scale-in {
0% {
transform: scale(0.8) translateX(150vw);
}
50% {
transform: scale(0.8) translateX(0);
}
100% {
transform: scale(1) translateX(0);
}
}
</style>
This parent layout is the place where page transitions happens:
- We begin by importing
virtual:uno.css
, this file is available after build time, which will serve as our Atomic CSS file for the styling to take effect. - The onNavigate lifecycle function is the core part, it’s the place where all the magic happens, we first check browser compatibility. The
document.startViewTransition
is an asynchronous function that fires a callback as soon as the animation is done playing. It animates the page by taking a screenshot of the current page, and a live snapshot of the next page, the following CSS selectors will be valid for customizing the animation:
::view-transition-old(root) /* old page snapshot */
::view-transition-new(root) /* next page live snapshot */
Those CSS selectors applies to the whole page, but it doesn’t stop here, you can assign unique view transition names for elements of your choice! We will explain that in just a moment.
The markup we have is straightforward, we have a header element that have links to the two pages we have, and each page is wrapped in a
div
element with a class name ofcontent
.Let’s explain what’s inside the
style
tag. In our example, the header element doesn’t change between pages, we want to animate the inner page only, we accomplish this by assigning a unique ID for thediv
element, using theview-transition-name
CSS property.Which, in turn, takes snapshots of that inner page, the following CSS selectors will be available for us:
::view-transition-old(content) /* old page snapshot */ ::view-transition-new(content) /* next page live snapshot */
We use those for specifying our own CSS animation, the old page will scale out and slide left, the new page will slide left and scale in.
Let’s show the code for the missing elements, I will only give the file name with the code snippet. No need to explain those as they are beyond the point of this blog post.
routes/+layout.server.ts
:import type { LayoutServerLoad } from './$types'; const defaultData = { name: 'Hash Ketchum', // Ash Ketchum's cousin, he's a developer email: 'hash@catchemcode.com', pokemonId: 7 }; export const load = (async ({ cookies }) => { const name = cookies.get('name') || defaultData.name; const email = cookies.get('email') || defaultData.email; const pokemonId = cookies.get('pokemonId') || defaultData.pokemonId.toString(); cookies.set('name', name); cookies.set('email', email); cookies.set('pokemonId', pokemonId); const paddedPokemonId = pokemonId.padStart(3, '0'); const pokemonAssetsUrl = 'https://assets.pokemon.com/assets/cms2/img/pokedex/detail'; const pokemonImageSrc = `${pokemonAssetsUrl}/${paddedPokemonId}.png`; return { pokemonImageSrc, name, email, pokemonId }; }) satisfies LayoutServerLoad;
routes/+page.svelte
:<script lang="ts"> import type { PageData } from './$types'; export let data: PageData; </script> <main class="flex-grow min-h-0 bg-indigo-400"> <div class="mx-auto py-12 text-center"> <h1 class="text-4x">Welcome, {data.name}!</h1> <div class="flex flex-col items-center gap-8"> <h2>Your Pokeomon</h2> <div class="flex items-center justify-center bg-gray-100 ring-5 ring-pink-400 p-3 rounded-full" > <img src={data.pokemonImageSrc} alt="Avatar" class="w-64 h-64 rounded-full" /> </div> <div> <h2>Your email</h2> <p class="text-normal underline">{data.email}</p> </div> </div> </div> </main>
routes/edit/+page.svelte
:<script lang="ts"> import { enhance } from '$app/forms'; import TextField from '$lib/TextField.svelte'; import type { PageData } from './$types.js'; export let form; export let data: PageData; </script> <main class="flex-grow min-h-0 bg-violet-500"> <div class="mx-auto py-12 text-center"> <h1 class="text-4x">Edit Profile Info</h1> <div class="flex flex-col items-center gap-8"> <form method="POST" use:enhance class="text-center w-[90vw] lg:w-[50vw] xl:w-[33vw]"> {#if form?.message} <p>{form.message}</p> {/if} <h2>User Info</h2> <div class="flex flex-col gap-6"> <TextField name="name" type="text" placeholder="Name" value={form?.name ?? data.name} /> <TextField name="email" type="email" placeholder="Email" value={form?.email ?? data.email} /> <TextField name="pokemonId" type="number" placeholder="Pokemon Id" value={form?.pokemonId ?? data.pokemonId} /> <button class="mt-2 cursor-pointer bg-pink-400 hover:brightness-110 transition-all p-3 border-0 rounded-xl text-lg text-white" > Save </button> </div> </form> </div> </div> </main>
routes/edit/+page.server.ts
:import { fail } from '@sveltejs/kit'; import type { Actions } from './$types'; export const actions = { default: async ({ request, cookies }) => { const data = await request.formData(); const name = data.get('name') as string | null; const email = data.get('email') as string | null; const pokemonId = data.get('pokemonId') as string | null; if (!name || !email || !pokemonId) { return fail(400, { message: 'Please fill out all fields', name, email, pokemonId }); } cookies.set('name', name); cookies.set('email', email); cookies.set('pokemonId', pokemonId); return { message: 'Your profile has been updated!', name, email, pokemonId }; } } satisfies Actions;
lib/HeaderItem.svelte
:<script lang="ts"> import { page } from '$app/stores'; export let href: string; $: isActive = $page.url.pathname === href; </script> <a class="flex items-center gap-2 text-xl color-white hover:bg-cyan-100/20 p-2.5 rounded-xl {isActive && 'bg-cyan-100/40'}" {href} > <slot /> </a>
lib/TextField.svelte
:<script lang="ts"> export let type: string; export let value: string; export let name: string; export let placeholder: string; </script> <input class="p-3 rounded-xl drop-shadow outline-none border-none ring-3 ring-transparent focus:ring-pink-400 focus:border-pink-400 transition-all text-lg" {name} {type} {placeholder} {value} />
Now we have implemented a page transition without the hassale of using a third-party library or complex state management! This is the power of the new web transition API, and it’s awesome to see frameworks like SvelteKit and Astro adapting to it so quickly. Can’t wait to see more browserss supporting it ( Firefox, I’m looking at you ).
I hope you enjoyed this blog post, and see you in the next one!