Svelte and Spring Animations

Avatar of Adam Rackis
Adam Rackis on

Spring animations are a wonderful way to make UI interactions come to life. Rather than merely changing a property at a constant rate over a period of time, springs allow us to move things using spring physics, which gives the impression of a real thing moving, and can appear more natural to users.

I’ve written about spring animations previously. That post was based on React, using react-spring for the animations. This post will explore similar ideas in Svelte.

CSS devs! It’s common to think of easing when it comes to controling the feel of animations. You could think of “spring” animations as a subcategory of easing that are based on real-world physics.

Svelte actually has springs built into the framework, without needing any external libraries. We’ll rehash what was covered in the first half my previous post on react-spring. But after that, we’ll take a deep-dive into all the ways these springs can be used with Svelte, and leave the real world implementation for a future post. While that may seem disappointing, Svelte has a number of wonderful, unique features with no counterpart in React, which can be effectively integrated with these animation primitives. We’re going to spend some time talking about them.

One other note: Some of the demos sprinkled throughout may look odd because I configured the springs to be extra “bouncy” to create more obvious effect. If you the code for any of them, be sure to find a spring configuration that works for you.

Here’s a wonderful REPL Rich Harris made to show all the various spring configurations, and how they behave.

A quick primer on Svelte Stores

Before we start, let’s take a very, very quick tour of Svelte stores. While Svelte’s components are more than capable of storing and updating state, Svelte also has the concept of a store, which allows you to store state outside of a component. Since Svelte’s Spring API uses Stores, we’ll quickly introduce the salient parts here.

To create an instance of a store, we can import the writable type, and create it like so:

import { writable } from "svelte/store";
const clicks = writable(0);

The clicks variable is a store that has a value of 0. There’s two ways to set a new value of a store: the set and update methods. The former receives the value to which you’re setting the store, while the latter receives a callback, accepting the current value, and returning the new value.

function increment() {
  clicks.update(val => val + 1);
}
function setTo5() {
  clicks.set(5);
}

State is useless if you can’t actually consume it. For this, stores offer a subscribe method, which allows you to be notified of new values — but when using it inside of a component, you can prefix the store’s name with the $ character, which tells Svelte to not only display the current value of the store, but to update when it changes. For example:

<h1>Value {$clicks}</h1>
<button on:click={increment}>Increment</button>
<button on:click={setTo5}>Set to 5</button>

Here’s a full, working example of this code. Stores offer a number of other features, such as derived stores, which allow you to chain stores together, readable stores, and even the ability to be notified when a store is first observed, and when it no longer has observers. But for the purposes of this post, the code shown above is all we need to worry about. Consult the Svelte docs or interactive tutorial for more info.

A crash course on springs

Let’s walk through a quick introduction of springs, and what they accomplish. We’ll take a look at a simple UI that changes a presentational aspect of some elements — opacity and transform — and then look at animating that change.

This is a minimal Svelte component that toggles the opacity of one <div>, and toggles the x-axis transform of another (without any animation).

<script>
  let shown = true;
  let moved = 0;

  const toggleShow = () => (shown = !shown);
  const toggleMove = () => (moved = moved ? 0 : 500);
</script>

<div style="opacity: {shown ? 1 : 0}">Content to toggle</div>
<br />
<button on:click={toggleShow}>Toggle</button>
<hr />
<div class="box" style="transform: translateX({moved}px)">I'm a box.</div>
<br />
<button on:click={toggleMove}>Move it!</button>

These changes are applied instantly, so let’s look at animating them. This is where springs come in. In Svelte, a spring is a store that we set the desired value on, but instead of instantly changing, the store internally uses spring physics to gradually change the value. We can then bind our UI to this changing value, to get a nice animation. Let’s see it in action.

<script>
  import { spring } from "svelte/motion";

  const fadeSpring = spring(1, { stiffness: 0.1, damping: 0.5 });
  const transformSpring = spring(0, { stiffness: 0.2, damping: 0.1 });

  const toggleFade = () => fadeSpring.update(val => (val ? 0 : 1));
  const toggleTransform = () => transformSpring.update(val => (val ? 0 : 500));
  const snapTransform = () => transformSpring.update(val => val, { hard: true });
</script>

<div style="opacity: {$fadeSpring}">Content to fade</div>
<br />
<button on:click={toggleFade}>Fade Toggle</button>

<hr />

<div class="box" style="transform: translateX({$transformSpring}px)">I'm a box.</div>
<br />
<button on:click={toggleTransform}>Move it!</button>
<button on:click={snapTransform}>Snap into place</button>

We get our spring function from Svelte, and set up different spring instances for our opacity, and transform animations. The transform spring config is purposefully set up to be extra springy, to help show later how we can temporarily turn off spring animations, and instantly apply desired changes (which will come in handy later). At the end of the script block are our click handlers for setting the desired properties. Then, in the HTML, we bind our changing values directly to our elements… and that’s it! That’s all there is to basic spring animations in Svelte.

The only remaining item is the snapTransform function, where we set our transform spring to its current value, but also pass an object as the second argument, with hard: true. This has the effect of immediately applying the desired value with no animation at all.

This demo, as well as the rest of the basic examples we’ll look at in this post, is here:

Animating height

Animating height is trickier than other CSS properties, since we have to know the actual height to which we’re animating. Sadly, we can’t animate to a value of auto. That wouldn’t make sense for a spring, since the spring needs a real number so it can interpolate the correct values via spring physics. And as it happens, you can’t even animate auto height with regular CSS transitions. Fortunately, the web platform gives us a handy tool for getting the height of an element: a ResizeObserver, which enjoys pretty good support among browsers.

Let’s start with a raw height animation of an element, producing a “slide down” effect that we gradually refine in other examples. We’ll be using ResizeObserver to bind to an element’s height. I should note that Svelte does have an offsetHeight binding that can be used to more directly bind an element’s height, but it’s implemented with some <iframe> hacks that cause it to only work on elements that can receive children. This would probably be good enough for most use cases, but I’ll use a ResizeObserver because it allows some nice abstractions in the end.

First, we’ll bind an element’s height. It’ll receive the element and return a writable store that initializes a ResizeObserver, which updates the height value on change. Here’s what that looks like:

export default function syncHeight(el) {
  return writable(null, (set) => {
    if (!el) {
      return;
    }
    let ro = new ResizeObserver(() => el && set(el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  });
}

We’re starting the store with a value of null, which we’ll interpret as “haven’t measured yet.” The second argument to writable is called by Svelte when the store becomes active, which it will be as soon as it’s used in a component. This is when we fire up the ResizeObserver and start observing the element. Then, we return a cleanup function, which Svelte calls for us when the store is no longer being used anywhere.

Let’s see this in action:

<script>
  import syncHeight from "../syncHeight";
  import { spring } from "svelte/motion";

  let el;
  let shown = false;
  let open = false;
  let secondParagraph = false;

  const heightSpring = spring(0, { stiffness: 0.1, damping: 0.3 });
  $: heightStore = syncHeight(el);
  $: heightSpring.set(open ? $heightStore || 0 : 0);

  const toggleOpen = () => (open = !open);
  const toggleSecondParagraph = () => (secondParagraph = !secondParagraph);
</script>

<button on:click={ toggleOpen }>Toggle</button>
<button on:click={ toggleSecondParagraph }>Toggle More</button>
<div style="overflow: hidden; height: { $heightSpring }px">
  <div bind:this={el}>
    <div>...</div>
    <br />
    {#if secondParagraph}
    <div>...</div>
    {/if}
  </div>
</div>

Our el variable holds the element we’re animating. We tell Svelte to set it to the DOM element via bind:this={el}. heightSpring is our spring that holds the height value of the element when it’s open, and zero when it’s closed. Our heightStore is what keeps it up to date with the element’s current height. el is initially undefined, and syncHeight returns a junk writable store that basically does nothing. As soon as el is assigned to the <div> node, that line will re-fire — thanks to the $: syntax — and get our writable store with the ResizeObserver listening.

Then, this line:

$: heightSpring.set(open ? $heightStore || 0 : 0);

…listens for changes to the open value, and also changes to the height value. In either case, it updates our spring store. We bind the height in HTML, and we’re done!

Be sure to remember to set overflow to hidden on this outer element so the contents are properly clipped as the elements toggles between its opened and closed states. Also, changes to the element’s height also animate into place, which you can see with the “Toggle More” button. You can run this in the embedded demo in the previous section.

Note that this line above:

$: heightStore = syncHeight(el);

…currently causes an error when using server-side rendering (SSR), as explained in this bug. If you’re not using SSR you don’t need to worry about it, and of course by the time you read this that bug may have been fixed. But the workaround is to merely do this:

let heightStore;
$: heightStore = syncHeight(el);

…which works but is hardly ideal.

We probably don’t want the <div> to spring open on first render. Also, the opening spring effect is nice, but when closing, the effect is janky due to some content flickering. We can fix that. To prevent our initial render from animating, we can use the { hard: true } option we saw earlier. Let’s change our call to heightSpring.set to this:

$: heightSpring.set(open ? $heightStore || 0 : 0, getConfig($heightStore));

…and then see about writing a getConfig function that returns an object with the hard property that was set to true for the first render. Here’s what I came up with:

let shown = false;

const getConfig = val => {
  let active = typeof val === "number";
  let immediate = !shown && active;
  //once we've had a proper height registered, we can animate in the future
  shown = shown || active;
  return immediate ? { hard: true } : {};
};

Remember, our height store initially holds null and only gets a number when the ResizeObserver starts running. We capitalize on this by checking for an actual number. If we have a number, and we haven’t yet shown anything, then we know to show our content immediately, and we we do that by setting the immediate value. That value ultimately triggers the hard config value in the spring, which we saw before.

Now let’s tweak the animation to be a bit less, well, springy when we close our content. That way, things won’t flicker when they close. When we initially created our spring, we specified stiffness and damping, like so

const heightSpring = spring(0, { stiffness: 0.1, damping: 0.3 });

It turns out the spring object itself maintains those properties, which can be set anytime. Let’s update this line:

$: heightSpring.set(open ? $heightStore || 0 : 0, getConfig($heightStore));

That detects changes to the open value (and the heightStore itself) to update the spring. Let’s also update the spring’s settings based on whether we’re opening or closing. Here’s what it looks like:

$: {
  heightSpring.set(open ? $heightStore || 0 : 0, getConfig($heightStore));
  Object.assign(
    heightSpring,
    open ? { stiffness: 0.1, damping: 0.3 } : { stiffness: 0.1, damping: 0.5 }
  );
}

Now when we get a new open or height value, we call heightSpring.set just like before, but we also set stiffness and damping values on the spring that are applied based on whether the element is open. If it’s closed, we set damping up to 0.5, which reduces the springiness. Of course, you’re welcome to tweak all these values and configure them as you’d like! You can see this in the “Animate Height Different Springs” section of the demo.

You might notice our code is starting to grow pretty quickly. We’ve added a lot of boilerplate to cover some of these use cases, so let’s clean things up. Specifically, we’ll make a function that creates our spring and that also exports a sync function to handle our spring config, initial render, etc.

import { spring } from "svelte/motion";

const OPEN_SPRING = { stiffness: 0.1, damping: 0.3 };
const CLOSE_SPRING = { stiffness: 0.1, damping: 0.5 };

export default function getHeightSpring() {
  const heightSpring = spring(0);
  let shown = false;

  const getConfig = (open, val) => {
    let active = typeof val === "number";
    let immediate = open && !shown && active;
    // once we've had a proper height registered, we can animate in the future
    shown = shown || active;
    return immediate ? { hard: true } : {};
  };

  const sync = (open, height) => {
    heightSpring.set(open ? height || 0 : 0, getConfig(open, height));
    Object.assign(heightSpring, open ? OPEN_SPRING : CLOSE_SPRING);
  };

  return { sync, heightSpring };
}

There’s a lot of code here, but it’s all the code we’ve been writing so far, just packaged into a single function. Now our code to use this animation is simplified to just this

const { heightSpring, sync } = getHeightSpring();
$: heightStore = syncHeight(el);
$: sync(open, $heightStore);

You can see in the “Animate Height Cleanup” section of the demo.

Some Svelte-specific tricks

Let’s pause for a moment and consider some ways Svelte differs from React, and how we might leverage that to improve what we have even further.

First, the stores we’ve been using to hold springs and change height values are, unlike React’s hooks, not tied to component rendering. They’re plain JavaScript objects that can be consumed anywhere. And, as alluded to above, we can imperatively subscribe to them so that they manually observe changing values.

Svelte also something called actions. These are functions that can be added to a DOM element. When the element is created, Svelte calls the function and passes the element as the first argument. We can also specify additional arguments for Svelte to pass, and provide an update function for Svelte to re-run when those values change. Another thing we can do is provide a cleanup function for Svelte to call when it destroys the element.

Let’s put these tools together in a single action that we can simply drop onto an element to handle all the animation we’ve been writing so far:

export default function slideAnimate(el, open) {
  el.parentNode.style.overflow = "hidden";

  const { heightSpring, sync } = getHeightSpring();
  const doUpdate = () => sync(open, el.offsetHeight);
  const ro = new ResizeObserver(doUpdate);

  const springCleanup = heightSpring.subscribe((height) => {
    el.parentNode.style.height = `${ height }px`;
  });

  ro.observe(el);

  return {
    update(isOpen) {
      open = isOpen;
      doUpdate();
    },
    destroy() {
      ro.disconnect();
      springCleanup();
    }
  };
}

Our function is called with the element we want to animate, as well as the open value. We’ll set the element’s parent to have overflow: hidden. Then we use the same getHeightSpring function from before, set up our ResizeObserver, etc. The real magic is here.

const springCleanup = heightSpring.subscribe((height) => {
  el.parentNode.style.height = `${height}px`;
});

Instead of binding our heightSpring to the DOM, we manually subscribe to changes, then set the height ourselves, manually. We wouldn’t normally do manual DOM updates when using a JavaScript framework like Svelte but, in this case, it’s for a helper library, which is just fine in my opinion.

In the object we’re returning, we define an update function which Svelte will call when the open value changes. We update the original argument to this function, which the function closes over ( i.e. creates a closure around) and then calls our update function to sync everything. Svelte calls the destroy function when our DOM node is destroyed.

Best of all, using this action is a snap:

<div use:slideAnimate={open}>

That’s it. When open changes, Svelte calls our update function.

Before we move on, let’s make one other tweak. Notice how we remove the springiness by changing the spring config when we collapse the pane with the “Toggle” button; however, when we make the element smaller by clicking the “Toggle More” button, it shrinks with the usual springiness. I dislike that, and prefer shrinking sizes move with the same physics we’re using for collapsing.

Let’s start by removing this line in the getHeightSpring function:

Object.assign(heightSpring, open ? OPEN_SPRING : CLOSE_SPRING);

That line is inside the sync function that getHeightSpring created, which updates our spring settings on every change, based on the open value. With it gone, we can start our spring with the “open” spring config:

const heightSpring = spring(0, OPEN_SPRING);

Now let’s change our spring settings when either the height of our content changes, or when the open value changes. We already have the ability to observe both of those things changing — our ResizeObserver callback fires when the size of the content changes, and the update function of our action fires whenever open changes.

Our ResizeObserver callback can be changed, like this:

let currentHeight = null;
const ro = new ResizeObserver(() => {
  const newHeight = el.offsetHeight;
  const bigger = newHeight > currentHeight;

  if (typeof currentHeight === "number") {
    Object.assign(heightSpring, bigger ? OPEN_SPRING : CLOSE_SPRING);
  }
  currentHeight = newHeight;
  doUpdate();
});

currentHeight holds the current value, and we check it on size changes to see which direction we’re moving. Next up is the update function. Here’s what it looks like after our change:

update(isOpen) {
  open = isOpen;
  Object.assign(heightSpring, open ? OPEN_SPRING : CLOSE_SPRING);
  doUpdate();
},

Same idea, but now we’re only checking whether open is true or false. You can see these iterations in the “Slide Animate” and “Slide Animate 2” sections of the demo.

Transitions

We’ve talked about animating items already on the page so far, but what about animating an object when it first renders? And when it un-mounts? That’s called a transition, and it’s built into Svelte. The docs do a superb job covering the common use cases, but there’s one thing that’s not yet (directly) supported: spring-based transitions.

/explanation Note that what Svelte calls a “transition” and what CSS calls a “transition” are very different things. CSS means transitioning one value to another. Svelte is referring to elements as they “transition” into and out of the DOM entirely (something that CSS doesn’t help with much at all).

To be clear, the work we’re doing here is made for adding spring-based animations into Svelte’s transitions. This is not currently supported, so it requires some tricks and workarounds that we’ll get into. If you don’t care about using springs, then Svelte’s built-in transitions can be used, which are significantly simpler. Again, check the docs for more info.

The way transitions work in Svelte is that we provide a duration in milliseconds (ms) along with an optional easing function, then Svelte provides us a callback with a value running from 0 to 1, representing how far along the transition is, and we turn that into whatever CSS we want. For example:

const animateIn = () => {
  return {
    duration: 2000,
    css: t => `transform: translateY(${t * 50 - 50}px)`
  };
};

…is used like this:

<div in:animateIn out:animateOut class="box">
  Hello World!
</div>

When that <div> first mounts, Svelte:

  • calls our animateIn function,
  • rapidly calls the CSS function on our resulting object ahead of time with values from 0 to 1,
  • collects our changing CSS result, then
  • compiles those results into a CSS keyframes animation, which it then applies to the incoming <div>.

This means that our animation will run as a CSS animation — not as JavaScript on the main thread — offering a nice performance boost for free.

The variable t starts at 0, which results in a translation of -50px. As t gets closer to 1, the translation approaches 0, its final value. The out transition is about the same, but in reverse, with the added feature of detecting the box’s current translation value, starting from there. So, if we add it then quickly remove it, the box will start to leave from its current position rather than jumping ahead. However, if we then re-add it while it’s leaving, it will jump, something we’ll talk about in just moment.

You can run this in the “Basic Transition” section of the demo.

Transitions, but with springs

While there’s a number of easing functions that alter the flow of an animation, there’s no ability to directly use springs. But what we could do is find some way to run a spring ahead of time, collect the resulting values, and then, when our css function is called with the a t value running from 0 to 1, look up the right spring value. So, if t is 0, we obviously need the first value from thespring. When t is 0.5, we want the value right in the middle, and so on. We also need a duration, which is number_of_spring_values * 1000 / 60 since there’s 60 frames per second.

We won’t write that code here. Instead, we’ll use the solution that already exists in the svelte-helpers library, a project I started. I grabbed one small function from the Svelte codebase, spring_tick, then wrote a separate function to repeatedly call it until it’s finished, collecting the values along the way. That, along with a translation from t to the correct element in that array (or a weighted average if there’s not a direct match), is all we need. Rich Harris gave a helping hand on the latter, for which I’m grateful.

Animate in

Let’s pretend a big red <div> is a modal that we want to animate in, and out. Here’s what an animateIn function looks like:

import { springIn, springOut } from "svelte-helpers/animation";
const SPRING_IN = { stiffness: 0.1, damping: 0.1 };

const animateIn = node => {
  const { duration, tickToValue } = springIn(-80, 0, SPRING_IN);
  return {
    duration,
    css: t => `transform: translateY(${ tickToValue(t) }px)`
  };
};

We feed the values we want to spring to, as well as our spring config to the springIn function. That gives us a duration, and a function for translating the current tickToValue into the current value to apply in the CSS. That’s it!

Animate out

Closing the modal is the same thing, with one small tweak

const SPRING_OUT = { stiffness: 0.1, damping: 0.5, precision: 3 };

const animateOut = node => {
  const current = currentYTranslation(node);
  const { duration, tickToValue } = springOut(current ? current : 0, 80, SPRING_OUT);
  return {
    duration: duration,
    css: t => `transform: translateY(${ tickToValue(t) }px)`
  };
};

Here, we’re check the modal’s current translation position, then use that as a starting point for the animation. This way, if the user opens and then quickly closes the modal, it’ll exit from its current position, rather than teleporting to 0, and then leaving. This works because the animateOut function is called when the element un-mounts, at which point we generate the object with the duration property and css function so the animation can be computed.

Sadly, it seems re-mounting the object while it’s in the process of leaving does not work, at least well. The animateIn function is not called de novo, but rather the original animation is re-used, which means it’ll always start at -80. Fortunately this almost certainly would not matter for a typical modal component, since a modal is usually removed by clicking on something, like the background overlay, meaning we are unable to re-show it until that overlay has finished animating out. Besides, repeatedly adding and removing an element with bidirectional transitions might make for a fun demo, but they’re not really common in practice, at least in my experience.

One last quick note on the outgoing spring config: You may have noticed that I set the precision ridiculously high (3 when the default is 0.01). This tells Svelte how close to get to the target value before deciding it is “done.” If you leave the default at 0.01, the modal will (almost) hit its destination, then spend quite a few milliseconds imperceptibly getting closer and closer before deciding it’s done, then remove itself from the DOM. This gives the impression that the modal is stuck, or otherwise delayed. Moving the precision to a value of 3 fixes this. Now the modal animates to where it should go (or close enough), then quickly goes away.

More animation

Let’s add one final tweak to our modal example. Let’s have it fade in and out while animating. We can’t use springs for this, since, again, we need to have one canonical duration for the transition, and our motion spring is already providing that. But spring animations usually make sense for items actually moving, and not much else. So let’s use an easing function to create a fade animation.

If you need help picking the right easing function, be sure to check out this handy visualization from the Svelte docs. I’ll be using the quintOut and quadIn functions.

import { quintOut, quadIn } from "svelte/easing";

Our new animateIn function looks pretty similar. Our css function does what it did before, but also runs the tickToValue value through the quintOut easing function to get our opacity value. Since t runs from 0 to 1 during an in transition, and 1 to 0 during an out transition, we don’t have to do anything further to it before applying to opacity.

const SPRING_IN = { stiffness: 0.1, damping: 0.1 };
const animateIn = node =>; {
  const { duration, tickToValue } = springIn(-80, 0, SPRING_IN);
  return {
    duration,
    css: t => {
      const transform = tickToValue(t);
      const opacity = quintOut(t);
      return `transform: translateY(${ transform }px); opacity: ${ opacity };`;
    }
  };
};

Our animateOut function is similar, except we want to grab the element’s current opacity value, and force the animation to start there. So, if the element is in the process of fading in, with an opacity of, say, 0.3, we don’t want to reset it to 1, and then fade it out. Instead, we want to fade it out from 0.3.

Multiplying that starting opacity by whatever value the easing function returns accomplishes this. If our t value starts at 1, then 1 * 0.3 is 0.3. If t is 0.95, we do 0.95 * 0.3 to get a value, which is a little less than 0.3, and so on.

Here’s the function:

const animateOut = node => {
  const currentT = currentYTranslation(node);
  const startOpacity = +getComputedStyle(node).opacity;
  const { duration, tickToValue } = springOut(
    currentT ? currentT : 0,
    80,
    SPRING_OUT
  );
  return {
    duration,
    css: t => {
      const transform = tickToValue(t);
      const opacity = quadIn(t);
      return `transform: translateY(${ transform }px); opacity: ${ startOpacity * opacity }`;
    }
  };
};

You can run this example in the demo with the “Spring Transition With Fade component.

Parting thoughts

Svelte is a lot of fun! In my (admittedly limited) experience, it tends to provide extremely simple primitives, and then leaves you to code up whatever you need. I hope this post has helped explain how the spring animations can be put to good use in your web applications.

And, hey, just a quick reminder to consider accessibility when working with springs, just as you would do with any other animation. Pairing these techniques with something like prefers-reduced-motion can ensure that only folks who prefer animations are the ones who get them.