ScrollTrigger Advanced
Pin elements, add spacing, configure snap points, and use scroll markers for debugging.
Build on the basics of .onScroll() with pinning, snap points, custom scroll containers, debug markers, and progress callbacks. These options let you create complex scroll-driven experiences — from sticky sections and horizontal galleries to scroll-linked progress bars.
If you’re new to scroll triggers, start with Scroll Trigger first.
Pin
Pinning fixes an element in place while the user continues to scroll, keeping it visible throughout the scroll range you define. Set pin: true to pin the animation’s target element.
import { Motion } from "@motion.page/sdk";
Motion("sticky-panel", ".panel", {
from: { opacity: 0, y: 30 },
duration: 1,
}).onScroll({
scrub: true,
pin: true,
start: "top top",
end: "+=600",
}); The element stays fixed at its start position until the scroll distance defined by end has been traveled. After that, it unpins and scrolls normally.
Pin a Different Element
Pass a CSS selector string to pin to pin a different element than the one being animated — typically a parent wrapper.
// Pin the section while the inner content animates
Motion("reveal-inner", ".content-inner", {
from: { opacity: 0, y: 60 },
duration: 1,
ease: "power2.out",
}).onScroll({
scrub: true,
pin: ".section-wrapper",
start: "top top",
end: "+=800",
}); pinSpacing
When an element is pinned, the page loses that section’s natural scroll height. pinSpacing compensates by adding space below (or around) the pinned element so subsequent content doesn’t jump up unexpectedly.
| Value | Behavior |
|---|---|
true (default) | Adds padding-bottom to push content down |
"padding" | Same as true — explicit padding mode |
"margin" | Adds margin-bottom instead of padding |
false | Disables spacing entirely — content flows directly under the pinned element |
Motion("pinned-hero", ".hero", {
to: { scale: 0.9, opacity: 0.6 },
duration: 1,
}).onScroll({
scrub: true,
pin: true,
pinSpacing: "margin",
start: "top top",
end: "+=500",
}); Use pinSpacing: false when the pinned element overlaps content intentionally (e.g., sticky overlays, fullscreen covers).
Snap
Snap locks scroll progress to discrete points instead of letting it settle anywhere. This is essential for step-by-step galleries, onboarding flows, and presentation-style layouts.
Snap to Even Intervals
Pass a single number between 0 and 1. The SDK divides the range into equal steps at that interval.
// Snaps to 0, 0.25, 0.5, 0.75, 1.0
Motion("stepped", ".panel", {
from: { opacity: 0.2 },
duration: 1,
}).onScroll({
scrub: true,
snap: 0.25,
}); Snap to Specific Points
Pass an array of progress values (0–1) to define exact snap positions.
// Snap to 3 panels in unequal proportions
Motion("custom-snap", ".panel", {
from: { x: "0%" },
to: { x: "-200%" },
duration: 1,
}).onScroll({
scrub: true,
pin: true,
start: "top top",
end: "+=2000",
snap: [0, 0.4, 1],
}); Snap with a Custom Function
For dynamic layouts or non-linear snapping, pass a function. It receives the raw scroll progress (0–1) and returns the snapped value.
Motion("dynamic-snap", ".slider", {
to: { x: "-300%" },
duration: 1,
}).onScroll({
scrub: true,
pin: true,
end: "+=3000",
snap: (progress) => Math.round(progress * 3) / 3,
}); Snapping for Horizontal Scroll
Combine snap with each or compute the increment from your section count:
const panels = Motion.utils.toArray(".h-panel");
const snapIncrement = 1 / (panels.length - 1);
Motion("h-gallery", ".h-track", {
to: { x: `-${(panels.length - 1) * 100}%` },
duration: 1,
}).onScroll({
scrub: true,
pin: true,
start: "top top",
end: `+=${panels.length * 100}vh`,
snap: snapIncrement,
}); Markers
Enable markers: true to render visual debug overlays showing exactly where your start and end positions fall relative to both the scroller and the animated element.
Motion("debug-reveal", ".section", {
from: { opacity: 0, y: 50 },
duration: 0.8,
}).onScroll({
scrub: true,
start: "top 80%",
end: "top 30%",
markers: true,
}); Two pairs of markers appear:
- Green lines —
startpositions (scroller and element) - Red lines —
endpositions (scroller and element)
Marker Configuration
Pass a MarkerConfig object to customize marker colors, size, and horizontal offset.
| Property | Type | Default | Description |
|---|---|---|---|
startColor | string | "green" | CSS color for start markers |
endColor | string | "red" | CSS color for end markers |
fontSize | string | — | Font size for marker labels, e.g. "12px" |
fontWeight | string | — | Font weight for marker labels |
indent | number | 0 | Horizontal offset in px — use when multiple trigger zones overlap |
Motion("debug-custom", ".hero", {
from: { opacity: 0 },
duration: 1,
}).onScroll({
scrub: true,
markers: {
startColor: "blue",
endColor: "orange",
fontSize: "11px",
indent: 40,
},
}); Remove markers in production. Marker DOM nodes are left in the document after the animation runs. Call
Motion.cleanup()to remove all[data-scrolltrigger-markers]nodes, or simply delete themarkersoption before deploying.
Horizontal Scrolling
Horizontal scroll scenes require a pinned container that holds while the inner track translates on the X axis. The total scroll travel defines how far the track moves.
import { Motion } from "@motion.page/sdk";
const panels = Motion.utils.toArray(".panel");
Motion("horizontal-scroll", ".track", {
to: { x: `-${(panels.length - 1) * 100}%` },
duration: 1,
}).onScroll({
scrub: true,
pin: ".pin-container",
start: "top top",
end: `+=${panels.length * 100}vh`,
snap: 1 / (panels.length - 1),
}); Required CSS:
.pin-container {
overflow: hidden;
height: 100vh;
}
.track {
display: flex;
width: calc(100% * var(--panel-count)); /* or set explicitly */
}
.panel {
width: 100vw;
height: 100vh;
flex-shrink: 0;
} The .track element moves from x: 0 to x: -N * 100% as the user scrolls through the pinned range, while the container stays fixed at the top of the viewport.
Custom Scroll Container (scroller)
By default, scroll triggers listen to the window scroll. Use scroller to attach a trigger to any scrollable element instead.
Motion("inner-scroll", ".list-item", {
from: { opacity: 0, x: -20 },
duration: 0.5,
stagger: { each: 0.08 },
}).onScroll({
scrub: false,
toggleActions: "play none none none",
start: "top 90%",
scroller: ".sidebar-panel",
}); scroller accepts either a CSS selector string or a direct Element reference:
const container = document.querySelector(".modal-body");
Motion("modal-anim", ".modal-item", {
from: { opacity: 0, y: 20 },
duration: 0.4,
stagger: 0.05,
}).onScroll({
toggleActions: "play none none none",
start: "top 85%",
scroller: container,
}); The scroller element must have
overflow: scrolloroverflow: autoand a fixed height to be scrollable. The window scroll is used as fallback whenscrollerisundefined.
Scroll Lifecycle and toggleActions
For non-scrub animations, toggleActions controls exactly what the timeline does at each of the four scroll lifecycle events. It’s a space-separated string of four actions:
toggleActions: "onEnter onLeave onEnterBack onLeaveBack" | Position | Event | When it fires |
|---|---|---|
| 1st | onEnter | Scrolling down into the trigger zone |
| 2nd | onLeave | Scrolling down past the end of the trigger zone |
| 3rd | onEnterBack | Scrolling up back into the trigger zone |
| 4th | onLeaveBack | Scrolling up out the top of the trigger zone |
Valid actions: play · pause · resume · reverse · restart · reset · complete · none
Default: "play reverse play reverse"
Common Patterns
// Play once and stay — never reverse
Motion("once-reveal", ".card", {
from: { opacity: 0, y: 40 },
duration: 0.7,
ease: "power2.out",
}).onScroll({
each: true,
toggleActions: "play none none none",
start: "top 85%",
});
// Play forward on enter, reverse on leave back (no action when past end)
Motion("in-out", ".feature", {
from: { opacity: 0, scale: 0.9 },
duration: 0.6,
}).onScroll({
each: true,
toggleActions: "play none none reverse",
start: "top 80%",
end: "bottom 20%",
});
// Restart every time the element enters
Motion("repeating-anim", ".highlight", {
from: { backgroundColor: "transparent" },
to: { backgroundColor: "#fbbf24" },
duration: 0.5,
}).onScroll({
each: true,
toggleActions: "restart none none reset",
start: "top 75%",
}); Progress Tracking with onUpdate
Use the onUpdate callback inside AnimationConfig to run code every frame as the animation progresses. With scrubbing enabled, this callback fires continuously as the user scrolls — making it ideal for syncing secondary UI elements like reading progress bars, counters, or parallax overlays.
import { Motion } from "@motion.page/sdk";
Motion("reading-progress", ".progress-bar", {
from: { width: "0%" },
to: { width: "100%" },
duration: 1,
onUpdate: (progress) => {
const label = document.querySelector(".progress-label");
if (label) {
label.textContent = `${Math.round(progress * 100)}%`;
}
},
}).onScroll({
scrub: true,
start: "top top",
end: "bottom bottom",
}); onUpdate receives a normalized progress value between 0 and 1.
Driving Multiple Elements
Because onUpdate runs in the animation’s update loop, you can drive any number of secondary effects without creating additional timelines.
const nav = document.querySelector(".navbar");
const progress = document.querySelector(".nav-progress");
Motion("page-scroll", "body", {
to: { opacity: 1 }, // dummy — only onUpdate drives behavior
duration: 1,
onUpdate: (p) => {
// Darken navbar as user scrolls down
const alpha = Math.min(p * 2, 1);
nav?.style.setProperty("--nav-bg-alpha", String(alpha));
// Widen progress bar
progress?.style.setProperty("width", `${p * 100}%`);
},
}).onScroll({
scrub: true,
start: "top top",
end: "bottom bottom",
}); Complete API Reference
ScrollConfig
interface ScrollConfig {
target?: string | Element;
start?: string;
end?: string;
scrub?: boolean | number;
pin?: boolean | string;
pinSpacing?: boolean | "margin" | "padding";
snap?: number | number[] | ((progress: number) => number);
markers?: boolean | MarkerConfig;
scroller?: string | Element;
toggleActions?: string;
each?: boolean;
} MarkerConfig
interface MarkerConfig {
startColor?: string; // CSS color. Default: "green"
endColor?: string; // CSS color. Default: "red"
fontSize?: string; // e.g. "12px"
fontWeight?: string;
indent?: number; // px horizontal offset
} onUpdate in AnimationConfig
interface AnimationConfig {
// ...other properties
onUpdate?: (progress: number) => void; // progress: 0–1
} Tips and Gotchas
Recalculate after layout changes. If your page layout shifts after initialization (e.g., fonts load, images resize, components mount), scroll trigger positions become stale. Call Motion.refreshScrollTriggers() to recompute all positions.
window.addEventListener("load", () => {
Motion.refreshScrollTriggers();
}); Scrub disables toggleActions. When scrub is enabled, the animation progress is tied directly to scroll position. Setting toggleActions alongside scrub has no effect — use scrub OR toggleActions, not both.
end with += is relative to start. end: "+=800" means 800px of scroll travel measured from the start point, not from the element’s position.
// 800px of scroll travel from wherever start fires
end: "+=800"
// One full viewport height of travel
end: "+=100vh" Pin spacing defaults to true. If pinned content is overlapping the next section unexpectedly, confirm pinSpacing isn’t set to false. The default adds padding to preserve document flow.
Clean up markers before shipping. Call Motion.cleanup() to remove marker and spacer DOM nodes left by debug runs.
Related: Scroll Trigger · Performance Tips