The Best Svelte SVG Animation Library

Published Nov 3, 2023

Table of Contents

Motivation

Inspired by Motion Canvas, I made the presentational framework Animotion for making animated videos using code, instead of having to learn animation software.

Animotion is only responsible for animating layout transitions, and code blocks between slides. Instead of recording and matching the audio to a timeline, you record the voicover over the slides.

You can use any JavaScript animation library in your slides, but I always wanted a bespoke animation library that complements Animotion.

You can find the code on GitHub.

I thought about it for months, and explored everything I could think of such as a declarative timeline using the Web Animations API, but nothing felt right — only to realize that Motion Canvas was right all along.

Animation Library Foundations

In Motion Canvas every animatable value is a signal, which represents a value that changes over time.

It uses generators to describe and play animations.

example
export default makeScene2D(function* (view) {
  const circle = createRef<Circle>()

  view.add(
    <Circle
      ref={circle}
      x={-300}
      width={240}
      height={240}
      fill="#e13238"
    />
  )

  yield* tween(2, value => {
    circle().position.x(map(-300, 300, value))
  })
})

You don’t have to understand generators, but the example above animates the x coordinate of the circle from -300 to 300 over 2 seconds by creating a tween that yields the value over time.

Motion Canvas uses the Canvas API to draw graphics, but I’m going to use SVGs because it already has everything you need to draw shapes, and animate their values.

Svelte already has a tweened and spring Svelte store to define values that change over time.

example
<script>
	import { tweened } from 'svelte/motion'

	// value that changes over time
	const circle = tweened({ cx: -300 })

	// update value over 2 seconds
	circle.set({ cx: 300 }, { duration: 2000 })
</script>

<svg width="100%" height="100%" viewBox="-300 -300 600 600">
	<circle cx={$circle.cx} cy={0} r={40} fill="#e13238" />
</svg>

I quickly learned that animating CSS keyframes using the Web Animations API isn’t enough, because you can’t tween every value like the SVG viewBox to create interesting camera effects.

You can’t interpolate strings, but Svelte gives you the option to pass in your own interpolation function.

This means you can use the d3-interpolate package to interpolate between numbers, colors, strings, arrays, and objects.

example
<script>
	import { tweened } from 'svelte/motion'
	import { cubicInOut } from 'svelte/easing'
	import { interpolate } from 'd3-interpolate'

	const circle = tweened({ cx: -300, fill: '#fff' }, {
		duration: 2000,
	  easing: cubicInOut,
	  interpolate,
	})

	circle.set({ cx: 300, fill: '#e13238' })
</script>

<svg width="100%" height="100%" viewBox="-300 -300 600 600">
	<circle cx={$circle.cx} cy={0} r={40} fill={$circle.fill} />
</svg>

The tweened store returns a Promise, which means you can use the await keyword to wait until the animation is done.

example
<script>
	import { onMount } from 'svelte'
	import { tweened } from 'svelte/motion'
	import { cubicInOut } from 'svelte/easing'
	import { interpolate } from 'd3-interpolate'

	const circle = tweened({ cx: -300, fill: '#fff' }, {
		duration: 2000,
	  easing: cubicInOut,
	  interpolate,
	})

	onMount(async () => {
		await circle.set({ cx: 300, fill: '#e13238' })
		await circle.set({ cx: -300, fill: '#e13238' })
	})
</script>

<svg width="100%" height="100%" viewBox="-300 -300 600 600">
	<circle cx={$circle.cx} cy={0} r={40} fill={$circle.fill} />
</svg>

To avoid repeating the values for every animation, I’m going to use the store’s update method, and merge the old and new values.

example
onMount(async () => {
	await circle.update(prev => ({ ...prev, cx: 300, fill: '#e13238' }))
	await circle.update(prev => ({ ...prev, cx: -300 }))
})

Being able to tween values, and run animations in sequence is the foundation of every animation library.

The Animation Library

This is great, but doing any slightly more complex animation is going to be tedious.

I want to be able to define values that change over time, and chain animations together using a .to method inspired by the GSAP animation library.

+page.svelte
<script lang="ts">
	import { onMount } from 'svelte'

	function animate(fn) {
		onMount(fn)
	}

	animate(async () => {
		await circle
			.to({ cx: 300, fill: '#e13238' })
			.to({ cx: -300 })
	})
</script>

The animate function is just a wrapper around onMount to make things prettier, if you want to publish this as a library.

I’m going to wrap the tweened store inside of a signal function, creating a custom Svelte store you can subscribe to.

+page.svelte
<script lang="ts">
	import { tweened } from 'svelte/motion'
	import { cubicInOut } from 'svelte/easing'
	import { interpolate } from 'd3-interpolate'

	function signal(values, options = { duration: 1000, easing: cubicInOut, interpolate }) {
		const { subscribe, update } = tweened(values, options)

		function to(values, options) {
			update(prev => ({ ...prev, ...values }))
			return this
		}

  	return { subscribe, to }
	}

	const circle = signal({ cx: -300, fill: '#fff' })

	animate(async () => {
		await circle.to({ cx: 300, fill: '#e13238' })
	})
</script>

To chain animations we simply return this, which returns a reference to itself. This doesn’t work yet, because we update the store immediately, and it animates to the last position.

Making the .to method async and using await would not work, because it returns a Promise.

example
async function to(values, options) {
	await update(prev => ({ ...prev, ...values }))
	return this
}

To solve this problem I’m going to create a tasks queue, and push the animations inside of it to play them later. For promise chaining to work, you can return a then method which makes an object thenable.

Using .to we push animations into a queue, and then we can run them in sequence using await because the object is thenable.

+page.svelte
<script lang="ts">
	function signal(values, options = { duration: 1000, interpolate: interpolate, easing: cubicInOut }) {
		const { subscribe, update } = tweened(values, options)

		let tasks = []

		function to(values, options) {
			tasks.push(() => update(prev => ({ ...prev, ...values }), options))
			return this
		}

		async function then(resolve) {
			for (const task of tasks) {
				await task()
			}
			resolve()
		}

  	return { subscribe, to, then }
	}

	const circle = signal({ cx: -300, fill: '#fff' })

	animate(async () => {
		await circle
			.to({ cx: 300, fill: '#e13238' })
			.to({ cx: -300 })
	})
</script>

If you log tasks, you can see then runs after the last task has been pushed.

example
async function then(resolve) {
	console.log(tasks) // [() => update(...), () => update(...)]
}

There is a bug in the code. If you repeat the animation, it’s going to play three times, because we forgot to clear the tasks.

example
animate(async () => {
	await circle
		.to({ cx: 300, fill: '#e13238' })
		.to({ cx: -300 })

	await circle
		.to({ cx: 300, fill: '#e13238' })
		.to({ cx: -300 })
})

In these sort of scenarios, it’s easier to use a debugger statement, to understand how your code runs.

example
async function then(resolve) {
	debugger
	for (const task of tasks) {
		await task()
	}
	resolve()
}

You can see the entire application state, and find the problem quicker.

+page.svelte
async function then(resolve) {
	for (const task of tasks) {
		await task()
	}
	tasks = []
	resolve()
}

That’s it! 😄

Playing Animations At The Same Time

To play animations at the same time, we can use the Promise.all() method, which takes an array of promises, and returns a single Promise.

+page.svelte
<script lang="ts">
	const circle = signal({ cx: -300, r: 40, fill: '#fff' })
	const text = signal({ opacity: 0 })

	animate(async () => {
		Promise.all([
			circle.to({ cx: 300, r: 80, fill: '#e13238' }),
			text.to({ opacity: 1 }, { duration: 2000 })
		])
	})
</script>

<svg width="100%" height="100%" viewBox="-300 -300 600 600">
	<circle cx={$circle.cx} cy={0} r={$circle.r} fill={$circle.fill} />
	<text
		x={$circle.cx}
		y={$circle.cy}
		opacity={$text.opacity}
		font-size={$circle.r * 0.4}
		text-anchor="middle"
		dominant-baseline="middle"
	>
		Motion
	</text>
</svg>

This is tedious to write, so we can create a helper function.

+page.svelte
<script lang="ts">
	function all(...animations) {
  	return Promise.all(animations)
	}

	animate(async () => {
		all(
			circle.to({ cx: 300, r: 80, fill: '#e13238' }),
			text.to({ opacity: 1 }, { duration: 2000 })
		)
	})
</script>

That’s it! 😄

Creating Stories With Sound

The last feature I want is to be able to play sounds alongside animations, to create engaging stories like Vox does with their productions.

Inside signal, I’m going to create a sfx method that is also chainable.

+page.svelte
function signal(values, options) {
	function sfx(sound, { volume = 0.5 } = {}) {
		const audio = new Audio(sound)
		audio.volume = volume

		tasks.push(async () => {
			audio.play().catch(() => console.error('To play sounds interact with the page.'))
		})

		return this
	}

	return { subscribe, to, sfx, then }
}

Tidying Things Up

I’m going to move the typed library code to lib/motion.ts and export the functions.

How beautiful is this? 😄

src/lib/motion.ts
import { onMount } from 'svelte'
import { tweened, type TweenedOptions } from 'svelte/motion'
import { cubicInOut } from 'svelte/easing'
import { interpolate } from 'd3-interpolate'

type AnimationFn = () => Promise<void>
type Resolve = (value?: any) => Promise<void>

export function animate(fn: AnimationFn) {
	onMount(fn)
}

export function signal<TweenValues>(
	values: TweenValues,
	options: TweenedOptions<TweenValues> = {
		duration: 1000,
		easing: cubicInOut,
		interpolate,
	}
) {
	const { subscribe, update, set } = tweened<TweenValues>(values, options)

	let tasks: AnimationFn[] = []

	function to(
		this: any,
		values: Partial<TweenValues>,
		options: TweenedOptions<TweenValues> | undefined = undefined
	) {
		if (typeof values === 'object') {
			tasks.push(() => update((prev) => ({ ...prev, ...values }), options))
		} else {
			tasks.push(() => set(values, options))
		}
		return this
	}

	function sfx(this: any, sound: string, { volume = 0.5 } = {}) {
		const audio = new Audio(sound)
		audio.volume = volume

		tasks.push(async () => {
			audio.play().catch(() => console.error('To play sounds interact with the page first.'))
		})

		return this
	}

	async function then(resolve: Resolve) {
		for (const task of tasks) {
			await task()
		}
		resolve()
		tasks = []
	}

	return { subscribe, to, sfx, then }
}

export function all(...animations: AnimationFn[]) {
	return Promise.all(animations)
}

One last thing I changed is checking the type of values passed to the to method, in case you want to pass a single value, such as signal(0), or signal('#fff'), instead of having to pass an object.

I hope you learned something, but more importantly feel inspired, and have fun coding.

Support

You can subscribe on YouTube, or consider becoming a patron if you want to support my work.

Patreon
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub