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.

ValueBehavior
true (default)Adds padding-bottom to push content down
"padding"Same as true — explicit padding mode
"margin"Adds margin-bottom instead of padding
falseDisables 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 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.

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.

PropertyTypeDefaultDescription
startColorstring"green"CSS color for start markers
endColorstring"red"CSS color for end markers
fontSizestringFont size for marker labels, e.g. "12px"
fontWeightstringFont weight for marker labels
indentnumber0Horizontal 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. 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 the markers option 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.

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"
PositionEventWhen it fires
1stonEnterScrolling down into the trigger zone
2ndonLeaveScrolling down past the end of the trigger zone
3rdonEnterBackScrolling up back into the trigger zone
4thonLeaveBackScrolling 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

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;
  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: "green"
  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. Call Motion.cleanup() to remove marker and spacer DOM nodes left by debug runs.


Related: Scroll Trigger · Performance Tips