Breaking down Svelte's demo counter animations

Animations, how do they work?

Svelte
12/2/2023

Introduction

If you’ve ever worked with Svelte, you’ve likely seen this:

Insert video of the counter

But how does it actually work? I recently wanted to create a similar effect and decided to dive into the code. While it looks simple on the surface, there are some unintuitive aspects to it, so let’s break it down together.

Browsing the code

Let’s start by taking a look at the Counter component. The first lines of code are this:

import { spring } from 'svelte/motion';

let count = 0;

const displayed_count = spring();
$: displayed_count.set(count);
$: offset = modulo($displayed_count, 1);

function modulo(n: number, m: number) {
  // handle negative numbers
  return ((n % m) + m) % m;
}

We see that there is a variable called count. This gets incremented or decremented in the on:click handler for the + and - buttons respectively. Makes sense so far. Next we see that displayed_count is a spring. A spring (like a tween) from the svelte/motion module is a writable store with a set() method, but here’s the trick: the value doesn’t update immediately, but gradually interpolates between its current value and the new value. That means that instead of jumping from 0 to 1, it moves through 0.001… to 0.999… before landing at 1. This is great for smoothly animating between two values, so its use here makes a lot of sense too. This behavior is triggered by the reactive call to displayed_count.set() when our count variable is updated.

I’m not sure yet what the offset is used for or why it needs a modulo for negative numbers, so let’s set that aside for a second.

The next thing that catches my eye is this:

<div class="counter-viewport">
  <div class="counter-digits" style="transform: translate(0, {100 * offset}%)">
    <strong class="hidden" aria-hidden="true">{Math.floor($displayed_count + 1)}</strong>
    <strong>{Math.floor($displayed_count)}</strong>
  </div>
</div>

Here we see our offset used in a transform: translate(), so we know it’s used to actually animate the position of the elements. This is also where it all starts making a little less sense to me. We can see there are two <strong> elements, one for the displayed_count and one for displayed_count + 1 that has a class of hidden. We know that our counter looks the same whether we increment or decrement, so I’m curious why we need an element for the counter + 1, but not for the counter - 1.

The only other code of note is that hidden class, which has a top: -100% definition in the CSS at the end of the file.

Let’s make it more visual

Now that we’ve seen the code behind it, let’s play with it a bit to see what everything does. With the offset variable being used in the transform statement, it seems like that is actually driving the movement of the numbers, so we’ll visualize its value and the value of displayed_count that it is based on. We also already know about the “hidden” element that has the counter + 1 value, that is being hidden above the current counter value. Let’s remove the overflow: hidden from their parent to better see what is happening. While we’re at it, I’ll also update the spring’s parameters so the animation is slowed down a little, and I’ll remove some of the existing text to make it less cluttered. Let’s start by looking at what happens when you increment the counter.

Insert video here

As expected, we can see that the value of displayed_count moves from the current count value towards the next and we can see that the offset is based on that. In fact, when incrementing from 0 to 1, the values for both variables are the same. When we reach higher numbers though, we can see that the offset always stays between 0 and 1. This is the work of our friend the modulo() function and it is needed for our transform to work, since our offset is multiplied by 100 and should give a transform percentage. If we just used the displayed_count value for this, we would get transforms of more than 100% times the value of the counter, which would look quite silly.

Another thing that becomes clear from this visualization is how the elements reset to their original position, ready for the next animation. As soon as displayed_count reaches its new value, the value of Math.floor($displayed_count) also becomes the new value of count. This is significant, because it makes the values in our two strong tags “reset”. If we increment from 0 to 1, the initial strong tag only flips from 0 to 1 as soon as the animation is done and the “hidden” strong tag flips from 1 to 2. Our elements now contain the correct values for the next increment. At the same time, our offset resets from 100% back to 0%, making the elements return to their original position.

What about decrementing?

This is what intrigued me so much about this animation. I know that the offset is used to slowly make the incremented value slowly appear, but what about the decremented value? There is no element with the value of displayed_count - 1. Well, this is also the work of the modulo() function. A regular modulo operator (%) would work for the increment example, but not for the decrement. The modulo outcome of a negative decimal value would also be negative and we know our offset only works with values between 0 and 1. The function ensures that even these types of inputs lead to an outcome between 0 and 1.

Now let’s look at the visualization again:

Insert video here

Now it all makes sense. When incrementing, the elements jump to their new position at the end of the animation, but when decrementing, this happens at the very beginning. The offset jumps to 1, making the elements jump down. It then decreases towards 0, making the elements move up. At the same time, our Math.floor($displayed_count) immediately jumps to the lower value, since it always rounds down. As soon as the spring’s value changes from 1 to 0.00009, it gets rounded to 0. This makes it so that the elements that just jumped to their new positions contain the correct value.

Let’s recreate it

To test our understanding, let’s see if we can recreate the effect ourselves.


In conclusion

While the effect seems quite simple at first, it’s actually a careful coordination of a few different values. The end result is a clean animation, with only the HTML elements that are needed. It shows some techniques, like using a spring and using it to dynamically set CSS transforms, that can be used to craft delightful animations throughout your app. And next time you generate a Svelte project with the demo, you’ll know exactly what’s going on behind that delightful counter.

© niels.codes 2024