Scroll Trigger
Trigger or scrub animations based on scroll position with start, end, and toggleActions.
Chain .onScroll() to any timeline to activate it based on scroll position. Use it to play an animation when an element enters the viewport, or to scrub animation progress directly with the scrollbar.
Basic Usage
import { Motion } from "@motion.page/sdk";
Motion("reveal", ".card", {
from: { opacity: 0, y: 40 },
duration: 0.6,
ease: "power2.out",
}).onScroll(); Without options, .onScroll() plays the animation when the element enters the viewport and reverses it when it leaves.
Options
| Option | Type | Default | Description |
|---|---|---|---|
target | string | Element | First animated element | Element whose scroll position drives the trigger |
start | string | "top bottom" | Position string: when trigger fires on scroll down |
end | string | "bottom top" | Position string: when trigger ends / leaves |
scrub | boolean | number | false | Sync animation to scrollbar. true = instant; number = smoothing seconds |
toggleActions | string | "play reverse play reverse" | Four-part string controlling playback at each scroll state |
markers | boolean | MarkerConfig | — | Show debug markers for start and end positions |
scroller | string | Element | window | Custom scroll container |
pin | boolean | string | — | Fix an element in place during the scroll range |
pinSpacing | boolean | "margin" | "padding" | — | How space is added around a pinned element |
snap | number | number[] | function | — | Snap scroll progress to fixed points |
each | boolean | false | Create independent trigger instances per matched element |
Start and End Position Syntax
start and end take a two-part string: "element-edge viewport-edge".
The element edge is where on the target element the trigger fires:
| Value | Meaning |
|---|---|
top | Top edge of the element |
center | Vertical midpoint of the element |
bottom | Bottom edge of the element |
top+100px | 100px below the element’s top edge |
bottom-50px | 50px above the element’s bottom edge |
The viewport edge is where on the viewport (or scroll container) the trigger fires:
| Value | Meaning |
|---|---|
top | Top of the viewport |
center | Vertical midpoint of the viewport |
bottom | Bottom of the viewport |
80% | 80% from the top of the viewport |
200px | 200px from the top of the viewport |
Combine them to describe exactly when the trigger fires:
// Fire when element top hits viewport bottom (default — starts just off-screen)
.onScroll({ start: "top bottom" })
// Fire when element center crosses viewport center
.onScroll({ start: "center center" })
// Fire when element top is 20% from the top of the viewport
.onScroll({ start: "top 20%" })
// Fire when element top+100px hits the viewport 80% mark
.onScroll({ start: "top+100px 80%" }) Relative end positions with += measure from the start position, not the element:
// End 500px of scroll travel after start
.onScroll({ scrub: true, end: "+=500" })
// End one full viewport height after start
.onScroll({ scrub: true, end: "+=100vh" })
// End halfway through the viewport after start
.onScroll({ scrub: true, end: "+=50%" }) Scrub
scrub ties the animation’s progress directly to the scrollbar position. As the user scrolls forward the animation advances; as they scroll back it reverses.
// Instant scrub — animation progress matches scroll position exactly
Motion("parallax", ".hero-bg", {
from: { y: -50 },
to: { y: 50 },
duration: 1,
}).onScroll({ scrub: true }); Pass a number to add smoothing — the animation takes that many seconds to “catch up” to the scrollbar. Higher values feel floatier.
// Smooth scrub — 1 second lag behind the scrollbar
Motion("smooth-parallax", ".section-image", {
from: { y: -30 },
to: { y: 30 },
duration: 1,
}).onScroll({ scrub: 1 }); scrub value | Behaviour |
|---|---|
false (default) | Animation plays/reverses on trigger, not tied to scroll |
true | Animation progress mirrors scroll position exactly |
0.5 | Smooth scrub, 0.5s lag |
2 | Very smooth scrub, 2s lag |
toggleActionsis ignored whenscrubis enabled — they are mutually exclusive.
toggleActions
toggleActions controls what happens at each of the four scroll states. It takes a space-separated string of four action keywords:
"onEnter onLeave onEnterBack onLeaveBack" | Position | Fires when |
|---|---|
onEnter | Scrolling down into the trigger zone |
onLeave | Scrolling down past the trigger zone |
onEnterBack | Scrolling up back into the trigger zone |
onLeaveBack | Scrolling up out of the trigger zone |
Valid actions: play, pause, resume, reverse, restart, reset, complete, none
// Play only once when entering — never reverse (the classic reveal)
Motion("reveal", ".section", {
from: { opacity: 0, y: 30 },
duration: 0.6,
ease: "power2.out",
}).onScroll({ toggleActions: "play none none none" });
// Reverse on scroll back up
Motion("fade", ".card", {
from: { opacity: 0, scale: 0.95 },
duration: 0.5,
}).onScroll({ toggleActions: "play none reverse none" });
// Restart every time the element enters — useful for looping effects
Motion("pop", ".badge", {
from: { opacity: 0, scale: 0.8 },
duration: 0.4,
ease: "back.out",
}).onScroll({ toggleActions: "restart none restart none" }); Common presets:
toggleActions string | Behaviour |
|---|---|
"play none none none" | Play once on first scroll down |
"restart none none none" | Restart each time element enters scrolling down |
"restart none restart none" | Restart when entering from either direction |
"play none reverse none" | Play down, reverse when scrolling back |
"play none none reverse" | Play down, reverse when leaving on scroll back |
"play reverse play reverse" | Always reverse when leaving (default) |
Play Once on Scroll
The most common reveal pattern: play when the element enters, never reverse.
Motion("reveal", ".section", {
from: { opacity: 0, y: 40 },
duration: 0.7,
ease: "power2.out",
stagger: 0.1,
}).onScroll({ toggleActions: "play none none none" }); Per-Element Triggers with each
By default, all elements matching the selector share a single ScrollTrigger driven by the first element. Set each: true to give every element its own independent trigger, so each one fires when it individually enters the viewport.
// ❌ Without each — all cards trigger when the first card enters the viewport
Motion("cards", ".card", {
from: { opacity: 0, y: 30 },
duration: 0.5,
stagger: 0.1,
}).onScroll({ toggleActions: "play none none none" });
// ✅ With each — every card triggers independently as it scrolls into view
Motion("cards", ".card", {
from: { opacity: 0, y: 30 },
duration: 0.5,
}).onScroll({ each: true, toggleActions: "play none none none" }); Use each: true when elements are spread across the page rather than grouped together. Combine with a stagger only in the non-each case — with per-element triggers, each animation runs independently so stagger has no effect on sequencing.
Debug Markers
Pass markers: true to render visual start and end lines in the browser. Remove before shipping.
Motion("debug", ".section", {
from: { opacity: 0 },
duration: 0.6,
}).onScroll({
start: "top 80%",
end: "bottom 20%",
markers: true,
}); Customise marker colours for stacked triggers:
.onScroll({
start: "top 80%",
markers: {
startColor: "lime",
endColor: "red",
fontSize: "12px",
indent: 40,
},
}) Custom Scroll Container
By default the trigger listens to the window scroll. Use scroller to target a scrollable div instead.
Motion("list-reveal", ".list-item", {
from: { opacity: 0, x: -20 },
duration: 0.4,
stagger: 0.07,
}).onScroll({
each: true,
scroller: ".sidebar-scroll",
start: "top 90%",
toggleActions: "play none none none",
}); Recalculating Positions
Call Motion.refreshScrollTriggers() after a layout change — for example after an accordion opens, images load, or dynamic content is injected — to recalculate all trigger positions.
accordion.addEventListener("open", () => {
Motion.refreshScrollTriggers();
}); Advanced: Pin, Snap, and Horizontal Scroll
pin, pinSpacing, and snap enable more complex patterns like fixed-position sections during a scroll range, scroll-jacking to snap between sections, and horizontal scroll panels. These options are covered in Scroll Trigger Advanced.