useScroll
Creates scroll-linked motion values, for progress indicators, parallax and other scroll-linked animations.
useScroll creates scroll-linked animations, like progress indicators and parallax effects.
<script lang="ts">
import { motion, useScroll } from "motion-sv";
const { scrollYProgress } = useScroll();
</script>
<motion.div style={{ scaleX: scrollYProgress }} /> useScroll can run some animations with the browser's ScrollTimeline API, for hardware-accelerated performance. The browser then drives the animation itself, so it stays in sync with scrolling and smooth even when the main thread is busy.
Usage
Import useScroll from motion-sv:
import { useScroll } from "motion-sv"; useScroll returns four motion values:
scrollXandscrollY: the absolute scroll position, in pixels.scrollXProgressandscrollYProgress: the scroll position between the defined offsets, as a value between0and1.
Like other hooks, call useScroll during component setup, at the top level of the <script>.
Page scroll
By default, useScroll tracks the page's scroll.
const { scrollY } = useScroll();
useMotionValueEvent(scrollY, "change", (latest) => {
console.log("Page scroll: ", latest);
}); For example, you can make a page scroll indicator by passing scrollYProgress straight to the scaleX style of a progress bar:
<script lang="ts">
import { motion, useScroll } from "motion-sv";
const { scrollYProgress } = useScroll();
</script>
<motion.div class="progress-bar" style={{ scaleX: scrollYProgress }} />
<style>
:global(.progress-bar) {
position: fixed;
inset: 0 0 auto;
height: 10px;
background: #ff0088;
transform-origin: 0%;
}
</style> In this demo and the next one, the article scrolls inside a box instead of the page, so the box is passed 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> As useScroll returns motion values, you can compose them with other motion value hooks, like useTransform and useSpring. Here, the progress bar follows the scroll position with a spring, which smooths out its movement:
<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 class="progress-bar" style={{ scaleX }} /> <script lang="ts">
import { motion, useScroll, useSpring } from "motion-sv";
// The article scrolls inside this box, which stands in for the page
let scroller = $state<HTMLElement>();
const { scrollYProgress } = useScroll(() => ({ container: scroller }));
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
</script>
<div class="example">
<motion.div class="scroll-indicator" style={{ scaleX, 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 {
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> Since scrollY is a motion value, you can also tell which direction the user is scrolling in, by comparing its latest value with its previous one:
<script lang="ts">
import { useMotionValueEvent, useScroll } from "motion-sv";
const { scrollY } = useScroll();
let direction = $state<"up" | "down">("down");
useMotionValueEvent(scrollY, "change", (current) => {
const diff = current - (scrollY.getPrevious() ?? 0);
direction = diff > 0 ? "down" : "up";
});
</script> That's handy for hiding a sticky header while the user scrolls down, and showing it again when they scroll up.
Element scroll
To track the scroll position of a scrollable element, pass it as the container option.
bind:this only assigns the element once the component has mounted, after useScroll has run. So pass the options as a getter, which useScroll reads again once the element is bound:
<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 is a motion component, bound with bind:ref, so it can also animate its maskImage, fading 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> Element position
To track the progress of an element as it moves through the container, pass it as the target option.
<script lang="ts">
import { useScroll } from "motion-sv";
let target = $state<HTMLElement>();
const { scrollYProgress } = useScroll(() => ({
target,
offset: ["end end", "start start"],
}));
</script>
<div bind:this={target}></div> Here, each item has its own progress indicator, which fills as the item scrolls through the box, from when it's fully in view at the bottom until its top reaches the top:
<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> The demo calls useScroll once per item, so each item gets its own scroll progress. Calling a hook in a loop is fine in Svelte, because a component's setup only runs once. The scrollable box is passed as container.
Scroll offsets
With the offset option, you can define which parts of the target you want to track against which parts of the container. For example, you can track an element as it enters from the bottom, leaves at the top, or travels through the whole viewport.
Hardware acceleration
scrollXProgress and scrollYProgress can run on the browser's native scroll timelines, instead of being updated with JavaScript on every scroll event. This happens when:
- The browser supports it:
ScrollTimelineto track a container, orViewTimelineto track atarget. - Without a
target, nooffsetis set. - With a
target, theoffsetcan be expressed as aViewTimelinerange. That's the default and any offset whose container edges arestartorend, like["start end", "end start"], without pixel orvh/vwvalues. - The progress is passed to an HTML
motionelement'sopacity,clipPath,filter,backgroundColorortransformstyle, either directly or throughuseTransformwith an input and output range.
<script lang="ts">
const { scrollYProgress } = useScroll();
const backgroundColor = useTransform(scrollYProgress, [0, 1], ["#ff0088", "#0cdcf7"]);
</script>
<!-- Both of these run on a ScrollTimeline -->
<motion.div style={{ opacity: scrollYProgress, backgroundColor }} /> In every other case, including properties like scaleX, Motion updates the values with JavaScript. The motion values themselves update either way, so you can always read them, or pass them to other hooks.
Options
useScroll accepts the following options, as an object or a getter that returns them. Use a getter when an option reads an element bound with bind:this or bind:ref, or state that changes: useScroll(() => ({ target: element })).
container
Default: the page
The scrollable element to track the scroll position of. By default, this is the page, but it can be any scrollable element.
target
Default: the scrollable area of the container
An element to track the progress of within the container.
target is tracked by its layout position, so any CSS transform applied to it or its ancestors is ignored when measuring its progress.
axis
Default: "y"
The scroll axis to apply offset to.
offset
Default: ["start start", "end end"]
offset describes intersections: points where the target and container meet.
For example, the intersection "start end" means the point where the start of the target, on the tracked axis, meets the end of the container.
So if the target is an element, the container is the page, and you're tracking the vertical axis, then "start end" is where the top of the element meets the bottom of the viewport. scrollYProgress is 0 at the first intersection, and 1 at the last.
Accepted intersections
Both target and container points can be defined as:
- Numbers:
0is the start of the axis and1is the end. So to match the top of the target with the middle of the container, use"0 0.5". Values outside this range are allowed. - Names:
"start","center"and"end"are shortcuts for0,0.5and1. - Pixels: pixel values like
"100px"and"-50px"are that many pixels from the start of the target or container. - Percentages: like numbers, expressed as
"0%"to"100%". - Viewport units:
"vh"and"vw"values.
An intersection can also be an array of two numbers, [target, container], so "start end" is the same as [0, 1].
Presets
ScrollOffset has presets for common offsets:
import { ScrollOffset, useScroll } from "motion-sv";
const { scrollYProgress } = useScroll(() => ({
target,
offset: ScrollOffset.Enter,
})); | Preset | Equivalent to |
|---|---|
ScrollOffset.Enter |
["start end", "end end"]: as the target enters through the end of the container |
ScrollOffset.Exit |
["start start", "end start"]: as the target leaves through the start of the container |
ScrollOffset.Any |
["end start", "start end"] |
ScrollOffset.All |
["start start", "end end"], the default |
Progress always runs from the first intersection to the last. So with ScrollOffset.Any, it counts down from 1 when the target starts to enter to 0 once it has left. To count up instead, use ["start end", "end start"].
trackContentSize
Default: false
When the size of the page's or an element's content changes, its scrollable area can change too. Browsers don't fire an event when that happens, so by default, useScroll doesn't update until the next "scroll" event.
Set trackContentSize to true to have useScroll check for changes to the content size on every frame:
useScroll({ trackContentSize: true });