Introduction
Motion for Svelte. Production-ready animations, gestures, layout and scroll effects for Svelte 5.
motion-sv brings Motion (formerly Framer Motion) to Svelte 5. It runs on the same animation engine as Motion for React and Vue, with an API shaped for Svelte: motion components, runes-friendly hooks and snippets.
In this guide, we'll install motion-sv and take a whirlwind tour of its main features.
Why motion-sv?
Svelte makes it easy to build dynamic interfaces, and its built-in transitions cover simple enter and exit effects. motion-sv is for everything beyond that, from micro-interactions to gesture-driven, physics-based and layout animations, all with one declarative API.
<motion.button animate={{ opacity: 1 }} /> Key advantages
- Declarative. Animations are props on the element. Change the values in
animateand the element animates to them, so animations follow your state. - Hardware accelerated. Motion runs animations through the same high-performance browser APIs as CSS where it can, and falls back to JavaScript for everything CSS can't do, like springs, interruptible keyframes and independent transforms.
- Animate anything. Transforms, colors, CSS variables, SVG paths, scroll position, layout. One consistent API handles simple transitions and advanced scroll, layout and gesture effects.
- App-like gestures. Hover, press, focus, pan and drag recognisers that work across mouse, touch and keyboard. Hover doesn't fire on touch taps, and press works with the keyboard.
When is CSS a better choice?
For simple, self-contained effects like a color change on hover, a CSS transition is the lightest solution. Svelte's own transition: directives are great for simple enter and exit effects too. Reach for motion-sv when you need springs, gestures, layout animations, exit animations that respect interruption, or scroll-linked effects.
Install
motion-sv is available on npm:
npm install motion-sv It requires Svelte 5.40 or later, and works with SvelteKit and plain Vite apps.
Usage
The core of motion-sv is the motion component. It's a normal DOM element, supercharged with animation capabilities.
<script lang="ts">
import { motion } from "motion-sv";
</script>
<motion.div /> Animating a motion component is as straightforward as setting values via the animate prop:
<motion.div animate={{ rotate: 360 }} /> <script lang="ts">
import { motion } from "motion-sv";
</script>
<motion.div
style={{ width: 100, height: 100, backgroundColor: "#0cdcf7", borderRadius: 5 }}
animate={{ rotate: 360 }}
transition={{ duration: 1 }}
/> When values in animate change, the component animates to them. Motion has great-feeling defaults, but animations can be configured via the transition prop.
<motion.div animate={{ scale: 2, transition: { duration: 2 } }} /> Enter animation
When a component enters the page, it animates from the rendered value to the values in animate. You can provide different starting values via the initial prop.
<motion.button initial={{ scale: 0 }} animate={{ scale: 1 }} /> <script lang="ts">
import { motion } from "motion-sv";
</script>
<motion.div
style={{ width: 100, height: 100, backgroundColor: "#8df0cc", borderRadius: "50%" }}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.4,
scale: { type: "spring", visualDuration: 0.4, bounce: 0.5 },
}}
/> Or disable the enter animation entirely by setting initial to false. The element renders straight away with the values in animate.
<motion.button initial={false} animate={{ scale: 1 }} /> Gestures
motion components extend Svelte's events with gesture recognisers for hover, press, focus, pan and drag.
<motion.button
whileHover={{ scale: 1.1 }}
whilePress={{ scale: 0.95 }}
onHoverStart={() => console.log("hover started!")}
/> <script lang="ts">
import { motion } from "motion-sv";
</script>
<div class="example">
<motion.div whileHover={{ scale: 1.2 }} whilePress={{ scale: 0.8 }} class="box" />
</div>
<style>
.example :global(.box) {
width: 100px;
height: 100px;
border-radius: 10px;
background-color: #1e75f7;
}
</style> Motion's gestures are designed to feel better than CSS alone. For instance, hover isn't triggered by touch screen taps. Learn more about gestures.
Scroll animations
motion-sv supports both kinds of scroll animations: scroll-triggered and scroll-linked.
Scroll-triggered animations run when an element enters or leaves the viewport, with the whileInView prop:
<motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} /> <script lang="ts">
import { motion, type Variants } from "motion-sv";
const cardVariants: Variants = {
offscreen: {
y: 300,
},
onscreen: {
y: 50,
rotate: -10,
transition: {
type: "spring",
bounce: 0.4,
duration: 0.8,
},
},
};
const hue = (h: number) => `hsl(${h}, 100%, 50%)`;
const food: [string, number, number][] = [
["๐
", 340, 10],
["๐", 20, 40],
["๐", 60, 90],
["๐", 80, 120],
["๐", 100, 140],
["๐ซ", 205, 245],
["๐", 260, 290],
["๐", 290, 320],
];
// The cards scroll inside this box, so it's the viewport they come into view in
let scroller = $state<HTMLElement>();
</script>
<div class="scroller" bind:this={scroller}>
<div class="container">
{#each food as [emoji, hueA, hueB] (emoji)}
<motion.div
class="card-container"
initial="offscreen"
whileInView="onscreen"
inViewOptions={{ root: scroller, amount: 0.8 }}
>
<div class="splash" style:background="linear-gradient(306deg, {hue(hueA)}, {hue(hueB)})"></div>
<motion.div class="card" variants={cardVariants}>
{emoji}
</motion.div>
</motion.div>
{/each}
</div>
</div>
<style>
.scroller {
box-sizing: border-box;
width: 100%;
height: 500px;
overflow-y: auto;
border: 1px solid rgb(127 127 127 / 0.25);
border-radius: 12px;
}
.container {
width: 100%;
max-width: 500px;
margin: 100px auto;
padding-bottom: 100px;
}
.scroller :global(.card-container) {
position: relative;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
padding-top: 20px;
margin-bottom: -120px;
}
.splash {
position: absolute;
inset: 0;
clip-path: path(
"M 0 303.5 C 0 292.454 8.995 285.101 20 283.5 L 460 219.5 C 470.085 218.033 480 228.454 480 239.5 L 500 430 C 500 441.046 491.046 450 480 450 L 20 450 C 8.954 450 0 441.046 0 430 Z"
);
}
.scroller :global(.card) {
display: flex;
justify-content: center;
align-items: center;
width: 300px;
height: 430px;
border-radius: 20px;
background: #f5f5f5;
font-size: 164px;
box-shadow:
0 0 1px hsl(0deg 0% 0% / 0.075),
0 0 2px hsl(0deg 0% 0% / 0.075),
0 0 4px hsl(0deg 0% 0% / 0.075),
0 0 8px hsl(0deg 0% 0% / 0.075),
0 0 16px hsl(0deg 0% 0% / 0.075);
transform-origin: 10% 60%;
}
</style> Scroll-linked animations tie values directly to scroll progress. useScroll returns motion values, which you can pass straight to style:
<script lang="ts">
import { motion, useScroll } from "motion-sv";
const { scrollYProgress } = useScroll();
</script>
<motion.div style={{ scaleX: scrollYProgress }} /> <script lang="ts">
import { motion, useScroll } from "motion-sv";
// The article scrolls inside this box, which stands in for the page
let scroller = $state<HTMLElement>();
const { scrollYProgress } = useScroll(() => ({ container: scroller }));
</script>
<div class="example">
<motion.div class="scroll-indicator" style={{ scaleX: scrollYProgress, originX: 0 }} />
<div class="scroller" bind:this={scroller}>
<article>
{@render text()}
{@render text()}
</article>
</div>
</div>
{#snippet text()}
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam ac rhoncus quam.</p>
<p>
Fringilla quam urna. Cras turpis elit, euismod eget ligula quis, imperdiet sagittis justo. In viverra fermentum ex
ac vestibulum. Aliquam eleifend nunc a luctus porta. Mauris laoreet augue ut felis blandit, at iaculis odio
ultrices. Nulla facilisi. Vestibulum cursus ipsum tellus, eu tincidunt neque tincidunt a.
</p>
<h2>Sub-header</h2>
<p>
In eget sodales arcu, consectetur efficitur metus. Duis efficitur tincidunt odio, sit amet laoreet massa fringilla
eu.
</p>
<p>
Pellentesque id lacus pulvinar elit pulvinar pretium ac non urna. Mauris id mauris vel arcu commodo venenatis.
Aliquam eu risus arcu. Proin sit amet lacus mollis, semper massa ut, rutrum mi.
</p>
<p>Sed sem nisi, luctus consequat ligula in, congue sodales nisl.</p>
<p>
Vestibulum bibendum at erat sit amet pulvinar. Pellentesque pharetra leo vitae tristique rutrum. Donec ut volutpat
ante, ut suscipit leo.
</p>
<h2>Sub-header</h2>
<p>
Maecenas quis elementum nulla, in lacinia nisl. Ut rutrum fringilla aliquet. Pellentesque auctor vehicula malesuada.
Aliquam id feugiat sem, sit amet tempor nulla. Quisque fermentum felis faucibus, vehicula metus ac, interdum nibh.
Curabitur vitae convallis ligula. Integer ac enim vel felis pharetra laoreet. Interdum et malesuada fames ac ante
ipsum primis in faucibus. Pellentesque hendrerit ac augue quis pretium.
</p>
<p>
Morbi ut scelerisque nibh. Integer auctor, massa non dictum tristique, elit metus efficitur elit, ac pretium sapien
nisl nec ante. In et ex ultricies, mollis mi in, euismod dolor.
</p>
<p>Quisque convallis ligula non magna efficitur tincidunt.</p>
<p>
Pellentesque id lacus pulvinar elit pulvinar pretium ac non urna. Mauris id mauris vel arcu commodo venenatis.
Aliquam eu risus arcu. Proin sit amet lacus mollis, semper massa ut, rutrum mi.
</p>
<p>Sed sem nisi, luctus consequat ligula in, congue sodales nisl.</p>
<p>
Vestibulum bibendum at erat sit amet pulvinar. Pellentesque pharetra leo vitae tristique rutrum. Donec ut volutpat
ante, ut suscipit leo.
</p>
<h2>Sub-header</h2>
<p>
Maecenas quis elementum nulla, in lacinia nisl. Ut rutrum fringilla aliquet. Pellentesque auctor vehicula malesuada.
Aliquam id feugiat sem, sit amet tempor nulla. Quisque fermentum felis faucibus, vehicula metus ac, interdum nibh.
Curabitur vitae convallis ligula. Integer ac enim vel felis pharetra laoreet. Interdum et malesuada fames ac ante
ipsum primis in faucibus. Pellentesque hendrerit ac augue quis pretium.
</p>
<p>
Morbi ut scelerisque nibh. Integer auctor, massa non dictum tristique, elit metus efficitur elit, ac pretium sapien
nisl nec ante. In et ex ultricies, mollis mi in, euismod dolor.
</p>
<p>Quisque convallis ligula non magna efficitur tincidunt.</p>
{/snippet}
<style>
.example {
position: relative;
box-sizing: border-box;
width: 100%;
height: 400px;
overflow: hidden;
border: 1px solid rgb(127 127 127 / 0.25);
border-radius: 12px;
}
.example :global(.scroll-indicator) {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
height: 10px;
background-color: #ff0088;
}
.scroller {
height: 100%;
overflow-y: auto;
}
article {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 500px;
margin: 0 auto;
padding: 150px 20px;
}
p {
margin: 0;
color: rgb(127 127 127);
line-height: 1.3;
}
h2 {
margin: 0;
font-size: 30px;
line-height: 1;
letter-spacing: -0.04em;
}
</style> Learn more about scroll animations.
Layout animations
Motion can animate between any two layouts using transforms, for example switching justify-content or resizing a card.
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
let isOn = $state(false);
const layout = createLayoutMotion(() => isOn);
const toggleSwitch = () => (isOn = !isOn);
</script>
<button class={["toggle-container", isOn ? "start" : "end"]} onclick={toggleSwitch}>
<layout.div
data-state={isOn}
class="toggle-handle"
transition={{ type: "spring", visualDuration: 0.2, bounce: 0.2 }}
/>
</button>
<style>
.toggle-container {
box-sizing: content-box;
display: flex;
width: 100px;
height: 50px;
padding: 10px;
border: none;
border-radius: 50px;
background-color: #9911ff44;
cursor: pointer;
}
.toggle-container.start {
justify-content: flex-start;
}
.toggle-container.end {
justify-content: flex-end;
}
.toggle-container :global(.toggle-handle) {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #9911ff;
}
</style> Svelte has no hook that runs right before the DOM updates, which is where React and Vue measure the old layout. So in motion-sv you create a layout namespace with createLayoutMotion(), pass it a getter for the state the layout depends on, and render the elements through it. Whenever that state changes, they animate to their new layout:
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
let isOn = $state(false);
const layout = createLayoutMotion(() => isOn);
</script>
<button class={["switch", { on: isOn }]} onclick={() => (isOn = !isOn)}>
<layout.div class="handle" />
</button> To animate between different elements, give them the same layoutId. When one replaces the other, it animates from the old one's place, without update():
<motion.div layoutId="underline" /> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
const tabs = [
{ icon: "๐
", label: "Tomato" },
{ icon: "๐ฅฌ", label: "Lettuce" },
{ icon: "๐ง", label: "Cheese" },
];
let selectedTab = $state(tabs[0]);
</script>
<div class="container">
<nav class="nav">
<ul class="tabs-container">
{#each tabs as item (item.label)}
<motion.li
class="tab"
initial={false}
animate={{ backgroundColor: item.label === selectedTab.label ? "#eee" : "#eee0" }}
onclick={() => (selectedTab = item)}
>
{`${item.icon} ${item.label}`}
{#if item.label === selectedTab.label}
<motion.div class="underline" layoutId="underline" />
{/if}
</motion.li>
{/each}
</ul>
</nav>
<div class="icon-container">
<AnimatePresence mode="wait">
{#key selectedTab.label}
<motion.div
class="icon"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{selectedTab.icon}
</motion.div>
{/key}
</AnimatePresence>
</div>
</div>
<style>
.container {
display: flex;
flex-direction: column;
width: 480px;
height: 360px;
max-width: calc(100% - 40px);
max-height: calc(100% - 40px);
border-radius: 10px;
background: white;
overflow: hidden;
box-shadow:
0 1px 1px hsl(0deg 0% 0% / 0.075),
0 2px 2px hsl(0deg 0% 0% / 0.075),
0 4px 4px hsl(0deg 0% 0% / 0.075),
0 8px 8px hsl(0deg 0% 0% / 0.075),
0 16px 16px hsl(0deg 0% 0% / 0.075),
0 2px 2px hsl(0deg 0% 0% / 0.075),
0 4px 4px hsl(0deg 0% 0% / 0.075),
0 8px 8px hsl(0deg 0% 0% / 0.075),
0 16px 16px hsl(0deg 0% 0% / 0.075);
}
.nav {
height: 44px;
padding: 5px 5px 0;
border-bottom: 1px solid #eeeeee;
border-radius: 10px 10px 0 0;
background: #fdfdfd;
}
.tabs-container {
display: flex;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
font-weight: 500;
}
.tabs-container :global(.tab) {
position: relative;
display: flex;
flex: 1;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 0;
height: 24px;
margin: 0;
padding: 10px 15px;
border-radius: 5px 5px 0 0;
background: white;
color: #0f1115;
font-size: 14px;
font-weight: 500;
list-style: none;
cursor: pointer;
user-select: none;
}
.tabs-container :global(.underline) {
position: absolute;
right: 0;
bottom: -2px;
left: 0;
height: 2px;
background: #ff0088;
}
.icon-container {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
}
.icon-container :global(.icon) {
font-size: 128px;
}
</style> Layout animations are the one part of motion-sv that works differently from Motion for React and Vue, so they have their own guide.
Exit animations
Svelte removes elements from the DOM straight away. Wrap them in AnimatePresence and they stay in the DOM while their exit animation plays.
<AnimatePresence>
{#if isVisible}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />
{/if}
</AnimatePresence> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
let isVisible = $state(true);
</script>
<div class="container">
<AnimatePresence initial={false}>
{#if isVisible}
<motion.div
class="box"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
/>
{/if}
</AnimatePresence>
<motion.button class="button" onclick={() => (isVisible = !isVisible)} whilePress={{ y: 1 }}>
{isVisible ? "Hide" : "Show"}
</motion.button>
</div>
<style>
.container {
display: flex;
flex-direction: column;
width: 100px;
height: 160px;
position: relative;
}
.container :global(.box) {
width: 100px;
height: 100px;
background-color: #0cdcf7;
border-radius: 10px;
}
.container :global(.button) {
background-color: #0cdcf7;
border: none;
border-radius: 10px;
padding: 10px 20px;
color: #0b1012;
font: inherit;
font-size: 15px;
font-weight: 540;
line-height: 1;
cursor: pointer;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
</style> Learn next
That's a quick overview of what motion-sv can do. Next, read about the motion component and animation in depth, or if you know Motion from React or Vue, see what's different in Svelte.
Looking for ready-made components? Svelte Animations has 50+ free, open-source animated components and effects built with motion-sv.