Spring physics in Svelte: what stores, actions and transitions add to the mix
Spring animations model motion with physical forces: stiffness, damping and mass produce movement that feels less mechanical than fixed easing curves. In Svelte, springs are built into the framework, so no external animation library is required. And because Svelte's spring API is built on its store system, you get a few advantages that React-based animation libraries don't offer—stores are plain JavaScript objects that can be consumed anywhere, and you can combine them with Svelte actions to create reusable animation directives.
Before digging in, one caveat: the demo configurations in this walkthrough are intentionally extra “bouncy” to make the physics visible. Tune the values for your own interfaces; what follows is about mechanisms, not a recommended tuning.
Stores: the foundation for springs
A Svelte store is a state container that lives outside the component tree. writable is the most basic one, created with an initial value:
import { writable } from "svelte/store";
const clicks = writable(0);
Values are updated with set, which replaces the value outright, or update, which takes a callback that receives the old value and returns the new one:
function increment() {
clicks.update(val => val + 1);
}
function setTo5() {
clicks.set(5);
}
Inside a component, the $ prefix wires a store into Svelte's reactivity system. That syntax subscribes you to updates and reflects the current value in the markup:
<h1>Value {$clicks}</h1>
<button on:click={increment}>Increment</button>
<button on:click={setTo5}>Set to 5</button>
That covers the store mechanics used throughout this article. Svelte stores also support derived values, custom readable stores, and lifecycle callbacks for observer tracking, but none of those are needed for spring work.
The basic spring pattern
Consider a component that toggles the opacity of one element and the x-axis transform of another. Without animation, the change is instantaneous:
<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>
The reactive declaration with $: is Svelte's way of re-running a statement when its dependencies change. Click handlers flip the boolean states, and the DOM reflects them immediately.
To animate those changes, swap the plain values for springs. A spring is a store that doesn't jump to its new value—it eases toward it, frame by frame, using spring physics. You tell the spring what value you want, and it smoothly catches up:
<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>
The spring state now sits in dedicated stores, each with its own spring configuration. The spring function accepts a starting value and an optional config object that controls stiffness and damping—lower damping makes the spring more oscillatory. In this example, the transform spring is deliberately underdamped so it visibly overshoots.
A second argument to spring.set allows for a hard config option that bypasses the physics simulation and applies the target value immediately, which matters once you want to skip the opening animation on first render.
Animating to dynamic heights
Height is awkward to animate with springs or with CSS, because you need an explicit numeric target—there's no way to spring to auto. The practical route is a ResizeObserver to measure the actual element height and feed that number to a spring.
Svelte does expose an offsetHeight binding directly, but it's implemented via hidden iframe tricks and only works on elements that can have children. ResizeObserver keeps the measurement code portable.
The helper below wires up the observer. It returns a writable store initialized to null, interpreted as “haven't measured yet.” Once the store is actually used in a component—has become active—the observer starts watching and the height value updates on every size change, with a teardown callback for unloading:
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();
});
}
To use it: bind the element with bind:this, create a spring to hold the animated height, and let the tracked height feed the spring whenever either the open flag or the measured height changes:
<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>
$: heightSpring.set(open ? $heightStore || 0 : 0);
The classic bugs in this flow are: the div springs open when the page first renders (usually undesirable for an accordion or modal), and closing feels flickery if the spring keeps oscillating past the content's clipped height. Both need a deliberate fix.
Suppressing the original render animation relies on the hard option. Because the height store starts as null, the first time a real number arrives, we know the component has initially rendered—and we should set the final height instantly instead of animating from zero:
$: heightSpring.set(open ? $heightStore || 0 : 0, getConfig($heightStore));
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 } : {};
};
The closing flicker comes from a springy overshoot when contracting. The physical properties on a Svelte spring aren't read-only—you can mutate stiffness and damping on the spring object any time. When collapsing, set damping high enough that the spring barely oscillates:
$: {
heightSpring.set(open ? $heightStore || 0 : 0, getConfig($heightStore));
Object.assign(
heightSpring,
open ? { stiffness: 0.1, damping: 0.3 } : { stiffness: 0.1, damping: 0.5 }
);
}
That's cleaner, but there's still ceremony: observer setup, spring creation, initial-render suppression, and CSS handling all stay on the component. A helper function can bundle all that behavior into a single object.
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 };
}
The resulting component logic shrinks to a few lines:
const { heightSpring, sync } = getHeightSpring();
$: heightStore = syncHeight(el);
$: sync(open, $heightStore);
Reusable spring actions
Svelte's answer to reusable DOM behavior is an action—a function invoked when an element is mounted, with the element as its first argument. It may accept additional params, expose a return object with an update method, and provide cleanup when the node leaves.
All of the height-spring machinery above can be wrapped into one action:
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();
}
};
}
Within it, a manual subscription updates the element's style.height directly. That breaks convention—declarative frameworks usually keep all DOM writes… declarative—but for a library-like helper it's a reasonable compromise. The returned update method runs whenever the open value changes, forwarding the fresh state into the animation handler.
const springCleanup = heightSpring.subscribe((height) => {
el.parentNode.style.height = `${height}px`;
});
The component's usage becomes a single line:
<div use:slideAnimate={open}>
Direction-aware spring tuning
Undesired oscillation also appears when content grows and shrinks via user input. In the pane demo, collapse is damped but shrinking with the “Toggle More” button remains springy. Direction-aware tuning fixes both.
Remove the size-change update from the shared sync function, then let the spring's initial config already anticipate changes as height or open state changes—so expansion is springy, while shrinking uses a stiffer response. Seeding the spring start values accordingly sets the direction-dependent behavior:
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();
});
update(isOpen) {
open = isOpen;
Object.assign(heightSpring, open ? OPEN_SPRING : CLOSE_SPRING);
doUpdate();
},
Both observers—the ResizeObserver callback and the action's update—check the current value and adjust the spring stiffness and damping before setting the new target.
Transitions with springs
So far, so element-bound. But there's another critical lifecycle in Svelte: transitions. Svelte calls “transition” what CSS would call an entrance/exit—the animated introduction and removal of elements, which have no CSS-native analogue for mounting toggles.
Svelte's built-in transition syntax uses time-based animation: a duration in milliseconds and a CSS callback that receives a normalized t value from 0 to 1:
<div in:animateIn out:animateOut class="box">
Hello World!
</div>
const animateIn = () => {
return {
duration: 2000,
css: t => `transform: translateY(${t * 50 - 50}px)`
};
};
Svelte pre-runs the CSS function over all time steps before mounting the element, turns those values into a keyframes animation, and hands it off to the browser as a CSS animation. The result runs off the main thread at no extra cost to the page.
Integrating springs requires bridging the timeline: a spring runs for an unknown duration until it settles; a transition timeline is fixed. Reconcile the two by extracting spring values into an array as the simulation proceeds, in 60fps ticks. The CSS function then maps each t to the nearest indexed sample.
That's a tricky exercise, and a ready-made solution exists in the svelte-helpers project. Its exported springIn and springOut utilities return an object of duration and a CSS callback with the right sample lookup.
Animate in with a spring
A modal is a textbook use case—it should pop onto the stage via spring physics:
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)`
};
};
The transform starts at -80px, springing up to 0.
Animate out, smarter
Closing the modal becomes directional: read the element's current transform, use it as the starting position for the reversal. Animating out from mid-flight feels connected rather than teleport-y:
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)`
};
};
One quirk hides in this setup: precision. The spring's precision value controls how close the simulation must get to the target before it marks itself done. At the default, 0.01, a closing transition ends by imperceptibly crawling several extra milliseconds before the element finally unmounts. Set precision near 3 for out-transitions so element removal isn't visibly delayed.
Combining springs and easing for fade-out
Springs suit motion, not opacity; keeping a coherent timeline often means pairing one physics-based motion property with conventionally eased ones. Using quintOut and quadIn, the modal now fades while it springs, and the fade-out shares a single duration with the spatial animation rather than need its own:
import { quintOut, quadIn } from "svelte/easing";
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 };`;
}
};
};
The out-transition multiplies your easing function's current value by the element's current opacity, avoiding an abrupt flash of full opacity when closing mid-fade:
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 }`;
}
};
};
Practical takeaways
These final tips are worth repeating when pinning spring code into real products:
- The same accessibility rules that apply to CSS transitions apply to spring-like motion: pair with a
prefers-reduced-motionguard for users who opt out of animation. - Remember that spring configurations in interactive demos are often exaggerated to convey “springiness.” Real interfaces generally need lower damping for usability, not spectacle.
Svelte gives you just enough primitive—stores, springs, actions, custom transitions—to assemble tailored motion behavior without the heft of an animation-only dependency.



