Transitions

Configure how values animate, with tweens, springs and inertia, plus delays, repeats and orchestration.

A transition defines the kind of animation used when a value animates from one state to another.

import type { Transition } from "motion-sv";

const transition: Transition = {
	duration: 0.8,
	delay: 0.5,
	ease: [0, 0.71, 0.2, 1.01],
};

Pass it to a motion component's transition prop:

<motion.div animate={{ x: 100 }} {transition} />

The same options work with the animate function:

animate(".box", { x: 100 }, transition);
<script lang="ts">
	import { motion } from "motion-sv";
</script>

<motion.div
	style={{ width: 200, height: 200, borderRadius: "50%", background: "#ff0088" }}
	initial={{ opacity: 0, scale: 0.5 }}
	animate={{ opacity: 1, scale: 1 }}
	transition={{
		duration: 0.8,
		delay: 0.5,
		ease: [0, 0.71, 0.2, 1.01],
	}}
/>

Setting a transition

Every animation target accepts a transition, which is used when that animation runs. That includes animate, exit, variants and the gesture props:

<motion.div
	whileHover={{
		scale: 1.1,
		transition: { duration: 0.2 },
	}}
/>

When the hover ends, the element animates back with the transition of the state it returns to.

Value-specific transitions

When several values animate, each one can have its own transition. default applies to every value without one:

<motion.li
	animate={{
		x: 0,
		opacity: 1,
		transition: {
			default: { type: "spring" },
			opacity: { ease: "linear" },
		},
	}}
/>

The same object works with the animate function:

animate(
	"li",
	{ x: 0, opacity: 1 },
	{
		default: { type: "spring" },
		opacity: { ease: "linear" },
	}
);

Default transitions

The transition prop sets the default transition for a component. Its animations use it whenever their target doesn't define a transition of its own:

<motion.div animate={{ x: 100 }} transition={{ type: "spring", stiffness: 100 }} />

To set a default for a whole group of components, use MotionConfig:

<script lang="ts">
	import { MotionConfig } from "motion-sv";
</script>

<MotionConfig transition={{ duration: 0.4, ease: "easeInOut" }}>
	<App />
</MotionConfig>

So Motion looks for a transition in this order: the animation target's transition, then the component's transition prop, then the transition of the closest MotionConfig. The first one it finds replaces the others entirely. When none is set, it picks a default that suits the value: a spring for physical values like x and scale, and a short tween for values like opacity and color.

Inheriting transitions

Set inherit: true to merge a transition with the less specific one it would otherwise replace, rather than replacing it. Its own options win.

A target's transition can inherit from the component's transition prop:

<motion.div
	transition={{ duration: 1, ease: "easeOut" }}
	whileHover={{
		scale: 1.1,
		// duration: 0.2, ease: "easeOut"
		transition: { inherit: true, duration: 0.2 },
	}}
/>

And a value-specific transition can inherit from the transition around it:

<motion.div
	animate={{ x: 100, opacity: 1 }}
	transition={{
		duration: 0.5,
		ease: "linear",
		// duration: 1, ease: "linear"
		x: { inherit: true, duration: 1 },
	}}
/>

Transition settings

type

Default: dynamic

type decides the kind of animation. It can be "tween", "spring" or "inertia".

Tween animations have a duration and an easing curve.

Spring animations are either physics-based or duration-based:

  • Physics-based springs are set with stiffness, damping and mass. They incorporate the velocity of any existing gesture or animation, so they feel natural to interact with.
  • Duration-based springs are set with visualDuration (or duration) and bounce. They don't incorporate velocity, but they're easier to reason about.
<script lang="ts">
	import { AnimatePresence, motion } from "motion-sv";

	interface Card {
		id: string;
		category: string;
		title: string;
		content: string[];
		theme: "light" | "dark";
		background: string;
		top?: number;
		bottom?: number;
		width?: string;
		left?: number;
	}

	// Gradients stand in for the original's photos. "light" cards have light titles on a dark image, "dark" ones the
	// reverse.
	const items: Card[] = [
		{
			id: "a",
			category: "Travel",
			title: "5 Inspiring Apps for Your Next Trip",
			content: [
				"Love to travel? So do the makers of these five subscription apps. For a small monthly fee, they'll help you find the best deals on flights, hotels, and some other stuff we turn a blind eye to.",
				"Plan your perfect itinerary with intelligent recommendations based on your interests, time, and credit history.",
			],
			top: -300,
			theme: "light",
			background: "linear-gradient(160deg, #9911ff, #1e75f7)",
		},
		{
			id: "c",
			category: "How to",
			title: "Contemplate the Meaning of Life Twice a Day",
			content: [
				`What is life? You can't spell "life" without "i". You also can't spell "life" without "l", "f", and "e". Worth thinking about.`,
				"The only way to find out more about life is to think about it. And the only way to think about it is twice daily using an app.",
				"Apps? We got 'em. Therefore we got the meaning of life.",
			],
			bottom: -50,
			theme: "dark",
			background: "linear-gradient(160deg, #8df0cc, #0cdcf7)",
			width: "110%",
			left: -20,
		},
		{
			id: "d",
			category: "Steps",
			title: "Urban Exploration Apps for the Vertically-Inclined",
			content: [
				"Get off the beaten path. Find the best views, skywalks, and elevated gardens in your city.",
				"Locked door? No problem! This app crowdsources the access code to every door in your city.",
			],
			theme: "dark",
			width: "200%",
			background: "linear-gradient(160deg, #0cdcf7, #e0fbf2)",
			left: -100,
		},
		{
			id: "b",
			category: "Hats",
			title: "Take Control of Your Hat Life With This Stunning New App",
			content: [
				"Whether you're serious hat enthusiast, or just a filthy casual, this new app revolutionizes how you organize, care for, and expand your hat collection.",
				"Stay up to date with the latest hat trends, get personalized hat care reminders, and use predictive analytics to discover the last place you left your hat.",
				"Why follow the crowd when you can be the crowd?",
			],
			bottom: -100,
			theme: "light",
			background: "linear-gradient(160deg, #ff0088, #dd00ee)",
		},
	];

	let openId = $state<string | null>(null);

	function open(id: string) {
		openId = id;
	}

	function close() {
		openId = null;
	}
</script>

<!-- The list card and the open card share layoutIds, so opening and closing animates one into the other -->
{#snippet cardContent(card: Card, isOpen: boolean)}
	<motion.div class="card-content" layoutId="card-container-{card.id}">
		<motion.div class="card-image-container" layoutId="card-image-container-{card.id}">
			<motion.div
				class="card-image"
				style={{
					background: card.background,
					top: card.top ?? "auto",
					bottom: card.bottom ?? "auto",
					width: card.width ?? "100%",
					left: card.left ?? "auto",
				}}
				layoutId="card-image-{card.id}"
			/>
		</motion.div>
		<motion.div class="title-container" layoutId="title-container-{card.id}" layout="position">
			<span class="h6">{card.category}</span>
			<h2 class="h3">{card.title}</h2>
		</motion.div>
		{#if isOpen}
			<motion.div class="content-container small">
				{#each card.content as paragraph}
					<p class="big">{paragraph}</p>
				{/each}
			</motion.div>
		{/if}
	</motion.div>
{/snippet}

<div class="app-store">
	<header>
		<div>
			<h2 class="store-title">Today</h2>
		</div>
		<div class="avatar"></div>
	</header>
	<!-- Store front -->
	<div>
		<ul class="card-list">
			{#each items as card (card.id)}
				<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
				<li class={["card", card.theme]} onclick={() => open(card.id)}>
					{@render cardContent(card, false)}
				</li>
			{/each}
		</ul>
		<!-- Item detail, rendered per card so a closing card keeps its content while it animates back -->
		<AnimatePresence>
			{#each items as card (card.id)}
				{#if openId === card.id}
					<motion.div
						class="overlay"
						initial={{ opacity: 0 }}
						animate={{ opacity: 1 }}
						exit={{ opacity: 0 }}
						transition={{ duration: 0.2, delay: 0.1 }}
						onclick={close}
					/>
					<div class={["card-content-container", "open", card.theme]}>
						{@render cardContent(card, true)}
					</div>
				{/if}
			{/each}
		</AnimatePresence>
	</div>
</div>

<style>
	.app-store {
		display: flex;
		flex-direction: column;
		width: 100%;
		max-width: 990px;
		margin: 0 auto;
	}

	.app-store,
	.app-store :global(*) {
		box-sizing: border-box;
	}

	header {
		position: relative;
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-bottom: 20px;
	}

	.avatar {
		width: 40px;
		height: 40px;
		border: 1px solid rgb(127 127 127 / 0.25);
		border-radius: 50%;
		background: linear-gradient(135deg, #ff0088, #9911ff);
	}

	.store-title {
		margin: 5px 0;
		font-size: 34px;
		font-weight: 640;
		line-height: 1;
		letter-spacing: -0.05em;
	}

	ul,
	li {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.card-list {
		display: flex;
		flex-wrap: wrap;
		align-content: flex-start;
		gap: 10px;
	}

	.card {
		position: relative;
		flex: 0 1 calc(50% - 5px);
		height: 280px;
		padding: 0;
	}

	.card-content-container {
		position: relative;
		display: flex;
		flex-direction: column;
		justify-content: flex-end;
		width: 100%;
		height: 100%;
		pointer-events: none;
	}

	.card-content-container.open {
		position: fixed;
		top: 0;
		right: 0;
		left: 0;
		z-index: 1000001;
		justify-content: center;
		padding: 0;
		overflow: hidden;
	}

	.dark .h3,
	.dark span {
		color: #0f1115;
	}

	.app-store :global(.card-content) {
		position: relative;
		width: 100%;
		height: 100%;
		margin: 0 auto;
		border-radius: 20px;
		background: #ffffff;
		color: #18222e;
		overflow: hidden;
		pointer-events: auto;
	}

	.open :global(.card-content) {
		width: unset;
		height: unset;
		max-width: 700px;
		overflow: hidden;
		pointer-events: none;
	}

	.app-store :global(.card-image-container) {
		position: relative;
		display: flex;
		flex-direction: column;
		justify-content: stretch;
		height: 280px;
		overflow: hidden;
	}

	.app-store :global(.card-image) {
		position: absolute;
		width: 100%;
		aspect-ratio: 3 / 2;
	}

	li :global(.card-image) {
		top: unset !important;
		right: 0 !important;
		bottom: -10px !important;
		left: 0 !important;
		min-width: 250px;
		min-height: 300px;
	}

	.open :global(.card-image-container),
	.open :global(.title-container) {
		z-index: 1;
	}

	.app-store :global(.title-container) {
		position: absolute;
		top: 15px;
		left: 15px;
		max-width: 300px;
	}

	li :global(.title-container) {
		visibility: hidden;
	}

	.open :global(.title-container) {
		top: 30px;
		left: 30px;
	}

	.h6 {
		font-size: 12px;
		font-weight: 590;
		line-height: 1;
	}

	.h3 {
		margin: 8px 0;
		color: #fff;
		font-size: 22px;
		font-weight: 530;
		line-height: 1.2;
		letter-spacing: -0.02em;
	}

	.app-store :global(.overlay) {
		position: fixed;
		inset: 0;
		z-index: 1000000;
		background: rgba(0, 0, 0, 0.8);
		will-change: opacity;
	}

	.app-store :global(.content-container) {
		width: 90vw;
		max-width: 700px;
		padding: 35px;
	}

	.app-store :global(.small) {
		font-size: 15px;
		font-weight: 480;
		line-height: 1.4;
		letter-spacing: -0.01em;
	}

	.big {
		margin: 0;
		color: rgb(127 127 127);
		font-size: 16px;
		line-height: 1.3;
	}
</style>

Inertia animations decelerate a value based on its initial velocity. They're mostly used to implement inertial scrolling, and they power the momentum at the end of a drag.

<motion.path animate={{ pathLength: 1 }} transition={{ duration: 2, type: "tween" }} />

Set type: false to make the animation instant.

Tween

duration

Default: 0.3 (or 0.8 if there are more than two keyframes)

The duration of the animation, in seconds. Setting it on a "spring" makes the spring duration-based.

animate("ul > li", { opacity: 1 }, { duration: 1 });

ease

The easing function to use with tween animations. It accepts:

  • An easing function name, like "linear".
  • An array of four numbers that defines a cubic bezier curve, like [0.17, 0.67, 0.83, 0.67].
  • A JavaScript easing function, which takes and returns a value between 0 and 1.

The available easing function names are:

  • "linear"
  • "easeIn", "easeOut", "easeInOut"
  • "circIn", "circOut", "circInOut"
  • "backIn", "backOut", "backInOut"
  • "anticipate"
<script lang="ts">
	import { cubicBezier, motion } from "motion-sv";

	const ease = cubicBezier(0.35, 0.17, 0.3, 0.86);
</script>

<motion.div animate={{ x: 100 }} transition={{ ease }} />

When animating keyframes, ease can be an array, with a different easing between each pair of keyframes:

<motion.div
	animate={{
		x: [0, 100, 0],
		transition: { ease: ["easeIn", "easeOut"] },
	}}
/>

times

When animating keyframes, times positions each keyframe in the animation.

Each value is a progress value between 0 (the start of the animation) and 1 (the end):

<motion.div
	animate={{
		x: [0, 100, 0],
		transition: { times: [0, 0.3, 1] },
	}}
/>

There must be as many times as there are keyframes. By default, keyframes are spread evenly.

Spring

Drag the sliders to try visualDuration and bounce, then click the track:

<script lang="ts">
	import { motion } from "motion-sv";

	let visualDuration = $state(0.5);
	let bounce = $state(0.25);
	let isOn = $state(false);
</script>

<div class="spring-demo">
	<button class="track" onclick={() => (isOn = !isOn)} aria-label="Animate">
		<motion.div
			class="ball"
			animate={{ x: isOn ? 180 : 0 }}
			transition={{ type: "spring", visualDuration, bounce }}
		/>
	</button>

	<label>
		<code>visualDuration</code>
		<input type="range" min={0.1} max={1.5} step={0.05} bind:value={visualDuration} />
		<output>{visualDuration}</output>
	</label>
	<label>
		<code>bounce</code>
		<input type="range" min={-1} max={1} step={0.05} bind:value={bounce} />
		<output>{bounce}</output>
	</label>
	<p>Click the track to animate.</p>
</div>

<style>
	.spring-demo {
		display: flex;
		flex-direction: column;
		gap: 0.75rem;
		width: 300px;
	}

	.track {
		width: 100%;
		padding: 10px;
		border: none;
		border-radius: 999px;
		background: rgb(153 17 255 / 0.15);
		cursor: pointer;
	}

	.track :global(.ball) {
		width: 80px;
		height: 40px;
		border-radius: 999px;
		background: #9911ff;
	}

	label {
		display: grid;
		grid-template-columns: 7.5rem 1fr 2.5rem;
		align-items: center;
		gap: 0.5rem;
		font-size: 0.875rem;
	}

	input {
		accent-color: #9911ff;
	}

	output {
		font-variant-numeric: tabular-nums;
		text-align: right;
	}

	p {
		margin: 0;
		font-size: 0.8125rem;
		opacity: 0.7;
	}
</style>

bounce

Default: 0.3

The bounciness of a duration-based spring, from -1 to 1.

0 means no bounce (critically damped) and 1 is extremely bouncy. Negative values, down to -1, make the spring overdamped: it starts faster, then settles more slowly into its target, without overshooting. A negative bounce changes the shape of the motion, not its timing: the spring still arrives at the same time as one with a bounce of 0.

<motion.div animate={{ rotateX: 90 }} transition={{ type: "spring", bounce: 0.25 }} />

visualDuration

The time, in seconds, that the spring takes to visually reach its target. The bulk of the motion happens within this time, and the bouncy part mostly happens after it. When set, it overrides duration.

This makes springs easier to edit, and to coordinate with other, time-based animations.

<motion.div
	animate={{ rotateX: 90 }}
	transition={{ type: "spring", visualDuration: 0.5, bounce: 0.25 }}
/>

damping

Default: 10

The strength of the opposing force. At 0, the spring oscillates forever.

<motion.a animate={{ rotate: 180 }} transition={{ type: "spring", damping: 300 }} />

mass

Default: 1

The mass of the moving object. Higher values make the movement more lethargic.

<motion.feTurbulence animate={{ baseFrequency: 0.5 }} transition={{ type: "spring", mass: 0.5 }} />

stiffness

Default: 100

The stiffness of the spring. Higher values make the movement more sudden.

<motion.section animate={{ rotate: 180 }} transition={{ type: "spring", stiffness: 50 }} />

velocity

Default: the value's current velocity

The initial velocity of the spring.

<motion.div animate={{ rotate: 180 }} transition={{ type: "spring", velocity: 2 }} />

restSpeed

Default: 2, or 0.01 for values that move less than 5 units, like opacity and scale

The animation ends once its absolute speed, in units per second, drops below this value, and its distance from the target is below restDelta.

<motion.div animate={{ rotate: 180 }} transition={{ type: "spring", restSpeed: 0.5 }} />

restDelta

Default: 0.5, or 0.005 for values that move less than 5 units

The animation ends once its distance from the target drops below this value, and its speed is below restSpeed.

<motion.div animate={{ rotate: 180 }} transition={{ type: "spring", restDelta: 0.5 }} />

Inertia

An inertia animation decelerates a value based on its initial velocity. Optionally, set min and max boundaries, and the value springs back to them when it goes past.

Inertia calculates a target value up front from the velocity and power. You can change that target with modifyTarget, for instance to snap it to a grid.

Inertia is also the animation that runs at the end of a drag, and you configure it with the dragTransition prop. Here, modifyTarget makes the handle always come to rest on a tick:

<script lang="ts">
	import { motion } from "motion-sv";

	const step = 60;
</script>

<div class="inertia-demo">
	<div class="track">
		{#each [0, 1, 2, 3, 4, 5] as i (i)}
			<span class="tick" style:left="{i * step + 25}px"></span>
		{/each}
		<motion.div
			class="handle"
			drag="x"
			dragConstraints={{ left: 0, right: step * 5 }}
			dragTransition={{
				power: 0.2,
				timeConstant: 200,
				modifyTarget: (target) => Math.round(target / step) * step,
			}}
			whileDrag={{ scale: 1.1 }}
		/>
	</div>
	<p>Drag and release: the handle always comes to rest on a tick.</p>
</div>

<style>
	.inertia-demo {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.track {
		position: relative;
		width: 350px;
		padding: 5px 0;
		border-radius: 12px;
		background: rgb(12 220 247 / 0.12);
	}

	.tick {
		position: absolute;
		top: 50%;
		width: 2px;
		height: 12px;
		border-radius: 1px;
		background: rgb(12 220 247 / 0.5);
		translate: -1px -50%;
	}

	.track :global(.handle) {
		position: relative;
		width: 50px;
		height: 50px;
		border-radius: 10px;
		background: #0cdcf7;
		cursor: grab;
	}

	p {
		margin: 0;
		font-size: 0.8125rem;
		opacity: 0.7;
	}
</style>

power

Default: 0.8

A higher power calculates a target further away.

<motion.div drag dragTransition={{ power: 0.2 }} />

timeConstant

Default: 325

The duration of the deceleration, in milliseconds. Changing it changes how the motion feels.

<motion.div drag dragTransition={{ timeConstant: 200 }} />

modifyTarget

A function that receives the calculated target and returns a new one. Useful for snapping the target to a grid.

<motion.div
	drag
	dragTransition={{
		power: 0,
		// Snap the calculated target to the nearest 50 pixels
		modifyTarget: (target) => Math.round(target / 50) * 50,
	}}
/>

min

The minimum boundary. The value "bumps" against it, or springs to it straight away if the animation starts below it.

<motion.div drag dragTransition={{ min: 0, max: 100 }} />

max

The maximum boundary. The value "bumps" against it, or springs to it straight away if the animation starts above it.

<motion.div drag dragTransition={{ min: 0, max: 100 }} />

bounceStiffness

Default: 500

The stiffness of the spring that bounces the value off min or max. Higher values make the movement more sudden.

<motion.div drag dragTransition={{ min: 0, max: 100, bounceStiffness: 100 }} />

bounceDamping

Default: 10

The damping of the spring that bounces the value off min or max. At 0, the spring oscillates forever.

<motion.div drag dragTransition={{ min: 0, max: 100, bounceDamping: 8 }} />

Orchestration

delay

Default: 0

Delays the animation by this duration, in seconds.

animate(element, { filter: "blur(10px)" }, { delay: 0.3 });

A negative delay starts the animation that far in. For instance, delay: -1 starts it one second in.

repeat

Default: 0

The number of times to repeat the transition. Set it to Infinity to repeat forever.

<motion.div animate={{ rotate: 180 }} transition={{ repeat: Infinity, duration: 2 }} />

repeatType

Default: "loop"

How to repeat the animation:

  • "loop": repeats the animation from the start.
  • "reverse": alternates between playing forwards and backwards.
  • "mirror": swaps the origin and target on each repetition.
<motion.div
	animate={{ rotate: 180 }}
	transition={{ repeat: 1, repeatType: "reverse", duration: 2 }}
/>

With an asymmetric easing curve like "easeIn", the difference between "reverse" and "mirror" is easy to see: "reverse" plays the curve backwards, while "mirror" plays it forwards from the other end.

<script lang="ts">
	import { motion } from "motion-sv";

	const repeatTypes = ["loop", "reverse", "mirror"] as const;
</script>

<div class="repeat-demo">
	{#each repeatTypes as repeatType (repeatType)}
		<div class="row">
			<code>{repeatType}</code>
			<div class="track">
				<motion.div
					class="dot"
					animate={{ x: 200 }}
					transition={{ duration: 1, ease: "easeIn", repeat: Infinity, repeatType, repeatDelay: 0.3 }}
				/>
			</div>
		</div>
	{/each}
</div>

<style>
	.repeat-demo {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.row {
		display: grid;
		grid-template-columns: 4.5rem 240px;
		align-items: center;
		gap: 1rem;
		font-size: 0.875rem;
	}

	.track {
		padding: 4px;
		border-radius: 999px;
		background: rgb(255 0 136 / 0.12);
	}

	.track :global(.dot) {
		width: 32px;
		height: 32px;
		border-radius: 50%;
		background: #ff0088;
	}
</style>

repeatDelay

Default: 0

The time to wait between repetitions, in seconds.

<motion.div animate={{ rotate: 180 }} transition={{ repeat: Infinity, repeatDelay: 1 }} />

when

Default: false

With variants, when this animation runs relative to the animations of its children:

  • "beforeChildren": the children animate after this animation finishes.
  • "afterChildren": this animation runs after the children's animations finish.
<script lang="ts">
	import { motion, type Variants } from "motion-sv";

	const list: Variants = {
		hidden: {
			opacity: 0,
			transition: { when: "afterChildren" },
		},
	};

	const item: Variants = {
		hidden: {
			opacity: 0,
			transition: { duration: 2 },
		},
	};
</script>

<motion.ul variants={list} animate="hidden">
	<motion.li variants={item} />
	<motion.li variants={item} />
</motion.ul>

delayChildren

Default: 0

With variants, delays the children's animations by this duration, in seconds.

<script lang="ts">
	import { motion, type Variants } from "motion-sv";

	const container: Variants = {
		hidden: { opacity: 0 },
		show: {
			opacity: 1,
			transition: { delayChildren: 0.5 },
		},
	};

	const item: Variants = {
		hidden: { opacity: 0 },
		show: { opacity: 1 },
	};
</script>

<motion.ul variants={container} initial="hidden" animate="show">
	<motion.li variants={item} />
	<motion.li variants={item} />
</motion.ul>

With the stagger function, the delay is spread across the children:

const transition = {
	delayChildren: stagger(0.1),
};

By default, the stagger runs from the first child to the last. Its from option starts it from the "last" child, the "center", or a specific index instead:

const transition = {
	delayChildren: stagger(0.1, { from: "last" }),
};
<script lang="ts">
	import { motion, stagger, type Variants } from "motion-sv";

	let isOpen = $state(false);
	let nav = $state<HTMLElement | SVGElement | null>(null);
	let height = $state(0);

	$effect(() => {
		if (nav instanceof HTMLElement) height = nav.offsetHeight;
	});

	const toggle = () => {
		isOpen = !isOpen;
	};

	const navVariants: Variants = {
		open: {
			transition: { delayChildren: stagger(0.07, { startDelay: 0.2 }) },
		},
		closed: {
			transition: { delayChildren: stagger(0.05, { from: "last" }) },
		},
	};

	const itemVariants: Variants = {
		open: {
			y: 0,
			opacity: 1,
			transition: {
				y: { stiffness: 1000, velocity: -100 },
			},
		},
		closed: {
			y: 50,
			opacity: 0,
			transition: {
				y: { stiffness: 1000 },
			},
		},
	};

	const sidebarVariants: Variants = {
		open: (height = 1000) => ({
			clipPath: `circle(${height * 2 + 200}px at 40px 40px)`,
			transition: {
				type: "spring",
				stiffness: 20,
				restDelta: 2,
			},
		}),
		closed: {
			clipPath: "circle(30px at 40px 40px)",
			transition: {
				delay: 0.2,
				type: "spring",
				stiffness: 400,
				damping: 40,
			},
		},
	};

	const colors = ["#FF008C", "#D309E1", "#9C1AFF", "#7700FF", "#4400FF"];
</script>

<div class="container">
	<motion.nav
		initial={false}
		animate={isOpen ? "open" : "closed"}
		custom={height}
		bind:ref={nav}
		class="nav"
	>
		<motion.div class="background" variants={sidebarVariants} />

		<!-- Navigation -->
		<motion.ul class="list" variants={navVariants}>
			{#each colors as color (color)}
				<motion.li
					class="list-item"
					variants={itemVariants}
					whilePress={{ scale: 0.95 }}
					whileHover={{ scale: 1.1 }}
				>
					<div class="icon-placeholder" style:border="2px solid {color}"></div>
					<div class="text-placeholder" style:border="2px solid {color}"></div>
				</motion.li>
			{/each}
		</motion.ul>

		<!-- Menu Toggle -->
		<button class="toggle-container" onclick={toggle} aria-label="Toggle menu">
			<svg width="23" height="23" viewBox="0 0 23 23">
				<motion.path
					fill="transparent"
					stroke-width="3"
					stroke="hsl(0, 0%, 18%)"
					stroke-linecap="round"
					variants={{
						closed: { d: "M 2 2.5 L 20 2.5" },
						open: { d: "M 3 16.5 L 17 2.5" },
					}}
				/>
				<motion.path
					fill="transparent"
					stroke-width="3"
					stroke="hsl(0, 0%, 18%)"
					stroke-linecap="round"
					d="M 2 9.423 L 20 9.423"
					variants={{
						closed: { opacity: 1 },
						open: { opacity: 0 },
					}}
					transition={{ duration: 0.1 }}
				/>
				<motion.path
					fill="transparent"
					stroke-width="3"
					stroke="hsl(0, 0%, 18%)"
					stroke-linecap="round"
					variants={{
						closed: { d: "M 2 16.346 L 20 16.346" },
						open: { d: "M 3 2.5 L 17 16.346" },
					}}
				/>
			</svg>
		</button>
	</motion.nav>
</div>

<style>
	.container {
		position: relative;
		display: flex;
		justify-content: flex-start;
		align-items: stretch;
		width: 500px;
		max-width: 100%;
		height: 400px;
		background-color: #0cdcf7;
		border-radius: 20px;
		overflow: hidden;
	}

	.container :global(.nav) {
		width: 300px;
	}

	.container :global(.background) {
		background-color: #f5f5f5;
		position: absolute;
		top: 0;
		left: 0;
		bottom: 0;
		width: 300px;
	}

	.toggle-container {
		outline: none;
		border: none;
		user-select: none;
		cursor: pointer;
		position: absolute;
		top: 18px;
		left: 15px;
		width: 50px;
		height: 50px;
		border-radius: 50%;
		background: transparent;
	}

	.container :global(.list) {
		list-style: none;
		padding: 25px;
		margin: 0;
		position: absolute;
		top: 80px;
		width: 230px;
	}

	.container :global(.list-item) {
		display: flex;
		align-items: center;
		justify-content: flex-start;
		padding: 0;
		margin: 0;
		list-style: none;
		margin-bottom: 20px;
		cursor: pointer;
	}

	.icon-placeholder {
		width: 40px;
		height: 40px;
		border-radius: 50%;
		flex: 40px 0;
		margin-right: 20px;
	}

	.text-placeholder {
		border-radius: 5px;
		width: 200px;
		height: 20px;
		flex: 1;
	}
</style>

Path

path

By default, x and y animate in a straight line to their targets. path makes them travel along a curve instead. The arc() function creates a curved path:

<script lang="ts">
	import { arc, motion } from "motion-sv";

	const path = arc();

	let isMoved = $state(false);
</script>

<motion.div
	animate={{ x: isMoved ? 200 : 0, y: isMoved ? 100 : 0 }}
	transition={{ duration: 0.8, path }}
	onclick={() => (isMoved = !isMoved)}
/>

arc() accepts these options:

  • strength: how far the path bulges away from a straight line, relative to the distance travelled. Default 0.5.
  • peak: where along the path, from 0 to 1, the curve is furthest from the straight line. Default 0.5.
  • direction: the side the path bulges towards, "cw" or "ccw" relative to the direction of travel. By default, Motion picks a stable side.
  • rotate: rotates the element to follow the curve. true follows it fully, and a number between 0 and 1 follows it partially.

Layout animations follow path too. Create the path once, at the top level of the <script>, rather than inline in the markup: it remembers previous animations, which keeps interrupted animations continuous.