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.
The post Svelte and Spring Animations appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.
source https://css-tricks.com/svelte-and-spring-animations/
No comments:
Post a Comment