The Best Svelte SVG Animation Library
Published Nov 3, 2023
Table of Contents
- Motivation
- Animation Library Foundations
- The Animation Library
- Playing Animations At The Same Time
- Creating Stories With Sound
- Tidying Things Up
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.
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.
<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.
<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.
<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.
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.
<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.
<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.
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.
<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.
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
.
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.
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.
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.
<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.
<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.
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? 😄
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.