Scroll animations

Trigger animations when elements enter the viewport, or link values directly to scroll progress.

There are two kinds of scroll animations:

  • Scroll-triggered: a normal animation starts when an element enters or leaves the viewport.
  • Scroll-linked: values are linked directly to scroll progress.

motion-sv supports both.

Scroll-triggered animations

Scroll-triggered animations are normal animations that run when an element enters or leaves the viewport.

The whileInView prop sets a target, or variant, to animate to while the element is in view:

<motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} />

When the element leaves the viewport, it animates back.

Here, each card container animates to its onscreen variant once 80% of it is in view, and the card inside it inherits the variant:

<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>

whileInView is configured with the inViewOptions prop. To set the same options for every motion component in a part of your app, pass inViewOptions to MotionConfig.

One-time animations

Set once: true and the element stays in its whileInView state once it has entered the viewport, instead of animating back out:

<motion.div
	initial="hidden"
	whileInView="visible"
	{variants}
	inViewOptions={{ once: true }}
/>

Changing scroll container

By default, the element is in view when it's within the window's viewport. To use another scrollable element as the viewport, get it with bind:this and pass it as root:

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

	let container = $state<HTMLElement>();
</script>

<div bind:this={container} style="overflow: scroll">
	<motion.div
		initial={{ opacity: 0 }}
		whileInView={{ opacity: 1 }}
		inViewOptions={{ root: container }}
	/>
</div>

The demo above works this way, with its own scrollable box as the root.

Amount and margin

amount sets how much of the element must be visible to count as in view. It can be "some" (the default), "all", or a number between 0 and 1, the fraction of the element that must be visible:

<motion.div whileInView={{ opacity: 1 }} inViewOptions={{ amount: 0.5 }} />

margin grows or shrinks the viewport, with the same syntax as the CSS margin property. Positive values detect the element before it enters, and negative values once it's further in:

<motion.div whileInView={{ opacity: 1 }} inViewOptions={{ margin: "0px 0px -100px 0px" }} />

Setting state

onViewportEnter and onViewportLeave fire as the element enters and leaves the viewport, with its IntersectionObserverEntry. Use them to set state:

<motion.div
	onViewportEnter={() => (isVisible = true)}
	onViewportLeave={() => (isVisible = false)}
/>

To track whether any element (not just a motion component) is in view, use useInView. Its current property is reactive:

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

	let element = $state<HTMLElement>();

	const isInView = useInView(() => element);
</script>

<section bind:this={element}>
	{isInView.current ? "In view" : "Out of view"}
</section>

Scroll-linked animations

Scroll-linked animations are made with motion values and useScroll.

useScroll returns four motion values: two for the scroll offset in pixels (scrollX and scrollY), and two for the scroll progress, between 0 and 1 (scrollXProgress and scrollYProgress).

Pass them straight to style. For instance, scrollYProgress on scaleX makes a reading progress bar:

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

	const { scrollYProgress } = useScroll();
</script>

<motion.div
	style={{
		scaleX: scrollYProgress,
		originX: 0,
		position: "fixed",
		top: 0,
		left: 0,
		right: 0,
		height: 10,
		background: "#ff0088",
	}}
/>

The demo's article scrolls inside a box rather than the page, so it passes that box to useScroll as its container:

<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>

Value smoothing

To smooth the value out, pass it through useSpring:

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

	const { scrollYProgress } = useScroll();
	const scaleX = useSpring(scrollYProgress, {
		stiffness: 100,
		damping: 30,
		restDelta: 0.001,
	});
</script>

<motion.div style={{ scaleX }} />

Transform other values

With useTransform, the progress values can drive any other value, like a color:

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

	const { scrollYProgress } = useScroll();
	const backgroundColor = useTransform(scrollYProgress, [0, 0.5, 1], ["#f00", "#0f0", "#00f"]);
</script>

<motion.div style={{ backgroundColor }} />

Scrollable containers

By default, useScroll tracks the page. To track a scrollable element instead, pass it as container. Elements bound with bind:this, or bind:ref on a motion component, are set after the component is created, so pass the options as a getter, which useScroll reads again once the element exists:

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

	let list = $state<HTMLElement>();

	const { scrollXProgress } = useScroll(() => ({ container: list }));
</script>

<svg viewBox="0 0 100 100">
	<motion.circle cx="50" cy="50" r="30" style={{ pathLength: scrollXProgress }} />
</svg>
<ul bind:this={list} style="overflow-x: scroll">
	<!-- ...items -->
</ul>

Here, the list's horizontal scroll progress fills the ring. The list also animates its maskImage whenever it reaches or leaves either end, to fade out the edges that have more to scroll to:

<script lang="ts">
	import { animate, motion, useMotionValue, useMotionValueEvent, useScroll, type MotionValue } from "motion-sv";

	const left = `0%`;
	const right = `100%`;
	const leftInset = `20%`;
	const rightInset = `80%`;
	const transparent = `#0000`;
	const opaque = `#000`;

	const colors = ["#ff0088", "#dd00ee", "#9911ff", "#0d63f8", "#0cdcf7", "#4ff0b7"];

	let list = $state<HTMLElement | null>(null);

	const { scrollXProgress } = useScroll(() => ({ container: list }));
	const maskImage = useScrollOverflowMask(scrollXProgress);

	function useScrollOverflowMask(scrollXProgress: MotionValue<number>) {
		const maskImage = useMotionValue(
			`linear-gradient(90deg, ${opaque}, ${opaque} ${left}, ${opaque} ${rightInset}, ${transparent})`
		);

		useMotionValueEvent(scrollXProgress, "change", (value) => {
			if (value === 0) {
				animate(
					maskImage,
					`linear-gradient(90deg, ${opaque}, ${opaque} ${left}, ${opaque} ${rightInset}, ${transparent})`
				);
			} else if (value === 1) {
				animate(
					maskImage,
					`linear-gradient(90deg, ${transparent}, ${opaque} ${leftInset}, ${opaque} ${right}, ${opaque})`
				);
			} else if (scrollXProgress.getPrevious() === 0 || scrollXProgress.getPrevious() === 1) {
				animate(
					maskImage,
					`linear-gradient(90deg, ${transparent}, ${opaque} ${leftInset}, ${opaque} ${rightInset}, ${transparent})`
				);
			}
		});

		return maskImage;
	}
</script>

<div class="example">
	<svg class="progress" width="80" height="80" viewBox="0 0 100 100">
		<circle cx="50" cy="50" r="30" pathLength="1" class="bg" />
		<motion.circle cx="50" cy="50" r="30" class="indicator" style={{ pathLength: scrollXProgress }} />
	</svg>
	<motion.ul bind:ref={list} class="list" style={{ maskImage }}>
		{#each colors as color (color)}
			<li style:background={color}></li>
		{/each}
	</motion.ul>
</div>

<style>
	.example {
		position: relative;
		width: 100%;
		max-width: 400px;
	}

	.progress {
		position: absolute;
		top: -65px;
		left: -15px;
		transform: rotate(-90deg);
	}

	.progress :global(circle) {
		stroke-dashoffset: 0;
		stroke-width: 10%;
		fill: none;
	}

	.bg {
		stroke: rgb(127 127 127 / 0.12);
	}

	.progress :global(.indicator) {
		stroke: #ff0088;
	}

	.example :global(.list) {
		display: flex;
		gap: 20px;
		height: 220px;
		margin: 0 auto;
		padding: 20px 0;
		overflow-x: scroll;
		list-style: none;
	}

	li {
		flex: 0 0 200px;
	}

	.example :global(.list::-webkit-scrollbar) {
		width: 5px;
		height: 5px;
		border-radius: 1ex;
		background: rgb(127 127 127 / 0.2);
	}

	.example :global(.list::-webkit-scrollbar-thumb) {
		border-radius: 1ex;
		background: #ff0088;
	}

	.example :global(.list::-webkit-scrollbar-corner) {
		background: rgb(127 127 127 / 0.2);
	}
</style>

Tracking an element

useScroll can also track an element's position as it moves through the viewport (or the container). Pass the element as target, and set offset to choose which part of its journey the progress values cover:

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

	let target = $state<HTMLElement>();

	const { scrollYProgress } = useScroll(() => ({
		target,
		offset: ["end end", "start start"],
	}));
</script>

<div bind:this={target}>
	<svg viewBox="0 0 100 100">
		<motion.circle cx="50" cy="50" r="30" style={{ pathLength: scrollYProgress }} />
	</svg>
</div>

offset sets where tracking starts and ends. Each entry describes a point where the target and the viewport meet: "end end" means the end of the target meets the end of the viewport. So ["end end", "start start"] tracks from when the target is fully in view at the bottom of the viewport until its start reaches the top. Edges can be "start", "center" and "end", numbers between 0 and 1, or pixel and percentage values. The default is ["start start", "end end"]. useScroll covers offsets in detail.

Here, each item fills its own progress ring as it moves through a scrollable box. The box is passed as container too, so the progress is measured within it:

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

	// The items scroll inside this box, which stands in for the viewport
	let scroller = $state<HTMLElement>();
	const items = $state<HTMLElement[]>([]);

	// Each item tracks its own progress, from when its end meets the end of the box, until its start meets the start
	const progress = Array.from(
		{ length: 12 },
		(_, i) =>
			useScroll(() => ({
				container: scroller,
				target: items[i],
				offset: ["end end", "start start"],
			})).scrollYProgress
	);
</script>

<div class="scroller" bind:this={scroller}>
	{#each progress as scrollYProgress, i (i)}
		<section>
			<div class="item" bind:this={items[i]}>
				<figure>
					<svg width="75" height="75" viewBox="0 0 100 100">
						<circle cx="50" cy="50" r="30" pathLength="1" class="bg" />
						<motion.circle cx="50" cy="50" r="30" pathLength="1" style={{ pathLength: scrollYProgress }} />
					</svg>
				</figure>
			</div>
		</section>
	{/each}
</div>

<style>
	.scroller {
		/* A positioned container lets Motion measure where the items sit inside it */
		position: relative;
		box-sizing: border-box;
		width: 100%;
		height: 440px;
		overflow-y: auto;
		border: 1px solid rgb(127 127 127 / 0.25);
		border-radius: 12px;
	}

	section {
		display: flex;
		justify-content: center;
		align-items: center;
		height: 100%;
		max-height: 400px;
	}

	.item {
		position: relative;
		width: 200px;
		height: 250px;
		border: 2px dotted #ff0088;
	}

	figure {
		position: sticky;
		top: 0;
		width: 80px;
		height: 80px;
		margin: 0;
		padding: 0;
	}

	svg {
		stroke: #ff0088;
		stroke-dashoffset: 0;
		stroke-width: 5;
		fill: none;
		transform: translateX(-100px) rotate(-90deg);
	}

	.bg {
		opacity: 0.2;
	}
</style>

Parallax

Map an element's scroll progress to y to move it at a different speed than the page:

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

	function useParallax(value: MotionValue<number>, distance: number) {
		return useTransform(value, [0, 1], [-distance, distance]);
	}

	let image = $state<HTMLElement>();

	const { scrollYProgress } = useScroll(() => ({ target: image }));
	const y = useParallax(scrollYProgress, 300);
</script>

<section>
	<img bind:this={image} src="/photo.jpg" alt="" />
	<motion.h2 style={{ y }}>#001</motion.h2>
</section>

Here, each heading follows its image's scroll progress, so it overtakes the image as it scrolls past. The images snap into place with CSS scroll snapping, and the scroll progress of the whole box drives a smoothed progress bar:

<script lang="ts">
	import { motion, useScroll, useSpring, useTransform, type MotionValue } from "motion-sv";

	function useParallax(value: MotionValue<number>, distance: number) {
		return useTransform(value, [0, 1], [-distance, distance]);
	}

	// Stand-ins for the photos
	const images = [
		{ id: 1, background: "linear-gradient(160deg, #ff0088, #dd00ee)" },
		{ id: 2, background: "linear-gradient(160deg, #dd00ee, #9911ff)" },
		{ id: 3, background: "linear-gradient(160deg, #9911ff, #1e75f7)" },
		{ id: 4, background: "linear-gradient(160deg, #1e75f7, #0cdcf7)" },
		{ id: 5, background: "linear-gradient(160deg, #0cdcf7, #8df0cc)" },
	];

	// The images scroll inside this box, which stands in for the page
	let scroller = $state<HTMLElement>();
	const targets = $state<HTMLElement[]>([]);

	// Each image moves its heading with its own scroll progress through the box
	const ys = images.map((_, i) => {
		const { scrollYProgress } = useScroll(() => ({
			container: scroller,
			target: targets[i],
		}));
		return useParallax(scrollYProgress, 300);
	});

	const { scrollYProgress } = useScroll(() => ({ container: scroller }));
	const progress = useSpring(scrollYProgress, {
		stiffness: 100,
		damping: 30,
		restDelta: 0.001,
	});
</script>

<div class="example">
	<div class="scroller" bind:this={scroller}>
		{#each images as { id, background }, i (id)}
			<section class="img-container">
				<div bind:this={targets[i]} style:background></div>
				<motion.h2 initial={{ visibility: "hidden" }} animate={{ visibility: "visible" }} style={{ y: ys[i] }}>
					{`#00${id}`}
				</motion.h2>
			</section>
		{/each}
	</div>
	<motion.div class="progress" style={{ scaleX: progress }} />
</div>

<style>
	.example {
		position: relative;
		width: 100%;
		height: 560px;
		overflow: hidden;
		border-radius: 12px;
		background: #0b1012;
	}

	.scroller {
		/* A positioned container lets Motion measure where the images sit inside it */
		position: relative;
		height: 100%;
		overflow-y: auto;
		scroll-snap-type: y mandatory;
	}

	.img-container {
		position: relative;
		display: flex;
		justify-content: center;
		align-items: center;
		height: 100%;
		scroll-snap-align: start;
	}

	.img-container > div {
		width: 300px;
		height: 400px;
		margin: 20px;
		overflow: hidden;
	}

	@media (max-width: 500px) {
		.img-container > div {
			width: 150px;
			height: 200px;
		}
	}

	.img-container :global(h2) {
		position: absolute;
		top: calc(50% - 25px);
		left: calc(50% + 120px);
		display: inline-block;
		margin: 0;
		color: #4ff0b7;
		font-family: ui-monospace, "SF Mono", Menlo, monospace;
		font-size: 50px;
		font-weight: 700;
		letter-spacing: -3px;
		line-height: 1.2;
	}

	.example :global(.progress) {
		position: absolute;
		left: 0;
		right: 0;
		bottom: 50px;
		height: 5px;
		background: #4ff0b7;
		transform: scaleX(0);
	}
</style>

Scroll velocity and direction

useVelocity turns scrollY into a scroll velocity motion value, and useMotionValueEvent can compare each new value with the previous one to find the direction:

<script lang="ts">
	import { motion, useMotionValueEvent, useScroll, useTransform, useVelocity } from "motion-sv";

	const { scrollY } = useScroll();
	const scrollVelocity = useVelocity(scrollY);
	const skewY = useTransform(scrollVelocity, [-2000, 0, 2000], [-5, 0, 5]);

	let direction = $state<"up" | "down">("down");

	useMotionValueEvent(scrollY, "change", (current) => {
		direction = current > (scrollY.getPrevious() ?? 0) ? "down" : "up";
	});
</script>

<motion.header style={{ skewY }} data-direction={direction} />