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 the first time the element enters the trigger range. Later scroll boundaries take no action unless you provide toggleActions.


Options

Option Type Default Description
target string | Element First animated element Element whose scroll position drives the trigger
endTarget string | Element target Element used to resolve an absolute end position; relative ends ignore it
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 none none none" 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 Quantize animation 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:

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 value Behaviour
false (default) Animation uses toggleActions (play once by default), not scroll-linked progress
true Animation progress mirrors scroll position exactly
0.5 Smooth scrub, 0.5s lag
2 Very 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"
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

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 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" Reverse whenever the trigger range is left


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, quantized animation steps, and horizontal scroll panels. snap changes animation progress; it does not move the browser scrollbar. These options are covered in Scroll Trigger Advanced.