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.

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

typescript
// 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
typescript
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 quantizes the animation’s scroll-derived progress to discrete points. It does not move or lock the browser’s scrollbar. This is useful 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.

typescript
// 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.

typescript
// 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.

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

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

typescript
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 linesstart positions (scroller and element)
  • Red linesend positions (scroller and element)

Marker Configuration

Pass a MarkerConfig object to customize marker colors, size, and horizontal offset.

Property Type Default Description
startColor string "lime" 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
typescript
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. Delete the markers option for a live page. Use Motion.cleanup() during teardown only: it safely removes marker/spacer nodes and detaches active ScrollTriggers while leaving their timelines registered.


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.

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

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.

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

typescript
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: scroll or overflow: auto and a fixed height to be scrollable. The window scroll is used as fallback when scroller is undefined.


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:

plaintext
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 none none none"

Common Patterns

typescript
// 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.

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

typescript
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

typescript
interface ScrollConfig {
  target?:         string | Element;
  endTarget?:      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

typescript
interface MarkerConfig {
  startColor?:  string;   // CSS color. Default: "lime"
  endColor?:    string;   // CSS color. Default: "red"
  fontSize?:    string;   // e.g. "12px"
  fontWeight?:  string;
  indent?:      number;   // px horizontal offset
}

onUpdate in AnimationConfig

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

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

typescript
// 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. Remove the markers option from live code. During teardown, Motion.cleanup() detaches active ScrollTriggers and safely removes their marker and spacer nodes.


Related: Scroll Trigger · Performance Tips