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

typescript
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

OptionTypeDefaultDescription
targetstring | ElementFirst animated elementElement whose scroll position drives the trigger
startstring"top bottom"Position string: when trigger fires on scroll down
endstring"bottom top"Position string: when trigger ends / leaves
scrubboolean | numberfalseSync animation to scrollbar. true = instant; number = smoothing seconds
toggleActionsstring"play reverse play reverse"Four-part string controlling playback at each scroll state
markersboolean | MarkerConfigShow debug markers for start and end positions
scrollerstring | ElementwindowCustom scroll container
pinboolean | stringFix an element in place during the scroll range
pinSpacingboolean | "margin" | "padding"How space is added around a pinned element
snapnumber | number[] | functionSnap scroll progress to fixed points
eachbooleanfalseCreate 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:

ValueMeaning
topTop edge of the element
centerVertical midpoint of the element
bottomBottom edge of the element
top+100px100px below the element’s top edge
bottom-50px50px above the element’s bottom edge

The viewport edge is where on the viewport (or scroll container) the trigger fires:

ValueMeaning
topTop of the viewport
centerVertical midpoint of the viewport
bottomBottom of the viewport
80%80% from the top of the viewport
200px200px from the top of the viewport

Combine them to describe exactly when the trigger fires:

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

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

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

typescript
// 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 valueBehaviour
false (default)Animation plays/reverses on trigger, not tied to scroll
trueAnimation progress mirrors scroll position exactly
0.5Smooth scrub, 0.5s lag
2Very smooth scrub, 2s lag

toggleActions is ignored when scrub is 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:

plaintext
"onEnter  onLeave  onEnterBack  onLeaveBack"
PositionFires when
onEnterScrolling down into the trigger zone
onLeaveScrolling down past the trigger zone
onEnterBackScrolling up back into the trigger zone
onLeaveBackScrolling up out of the trigger zone

Valid actions: play, pause, resume, reverse, restart, reset, complete, none

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

play-once.ts
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.

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

typescript
Motion("debug", ".section", {
  from: { opacity: 0 },
  duration: 0.6,
}).onScroll({
  start: "top 80%",
  end: "bottom 20%",
  markers: true,
});

Customise marker colours for stacked triggers:

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

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

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