Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 14.3Advanced ~24 min · shot 3 of 7

ScrollTrigger

In one line

ScrollTrigger ties a GSAP animation to scroll positions: it measures where an element's start and end are, then plays, scrubs or pins as the scroll bar passes them. It must re-measure whenever the layout changes.

0/7 · 0%

Think of it like this

A film strip pulled through a projector by hand. Pull a little and the picture moves a little; stop and it stops; pull back and it runs backward. The scroll bar is the hand, and scrub makes the animation follow it frame for frame.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

ScrollTrigger
A GSAP plugin that links animations to scroll positions: play, scrub, pin, snap.
scrub
Make the animation's progress follow the scroll position, forward and backward.
pin
Hold an element still on screen while the scroll passes through a range.
pin spacer
The wrapper div.pin-spacer ScrollTrigger puts around a pinned element so the layout keeps the room the pin needs.

Step by step

01Read the start and end numbers

Open "Scrub in a scroll box". The console says start 72 end 178 scrollable 250. The box's top is 160 px down the scroller, which is 110 px tall. The 80% line is at 88 px, so the box reaches it after 160 − 88 = 72 px of scrolling: that is start. The box's bottom (200 px) meets the 20% line (22 px) at 178: that is end.

02Scrub it by hand

Drag the scroll box's scroll bar slowly. The Volt label slides right and turns as you drag, and the readout moves from 0 to 1. At the middle of the range (scrollTop 125) the progress is 0.50, the box is 80 px along and turned half a turn, and scrolling back runs it backward. The coloured marker lines inside the scroller are the start and end.

03Pin the battery

In "Pin and the spacer" the console shows the section's parent change from scroller to pin-spacer, and the spacer is 260 px tall for a 60 px section: the 200 px you asked for is added. Scroll to 180: the section is held at the top (pinned: yes, moved by a 70 px transform inside this scroll box) and the meter is 35% full: the pin starts at scrollTop 110, and 70 ÷ 200 = 0.35.

04Let the layout move

In "Late layout" a photo above the box grows by 100 px. Before refresh(), ScrollTrigger still believes the start is 48, but the box now reaches the line at 148: scrolling to 48 would play the animation 100 px too early. After ScrollTrigger.refresh() both numbers agree. The example does this once by itself, 0.6 s after load (the three console lines). Press Photo loads for another 100 px: the line under the buttons then says start 148 | … scrollTop 248, and after refresh() it says start 248 | … 248.

Try it yourself

  1. 1

    Start the scrub later

    In "Scrub in a scroll box" change start: "top 80%" to start: "top 50%". The console now says start 105: the 50% line is at 55 px, and 160 − 55 = 105. The animation covers less scrolling, so it moves faster per pixel.

  2. 2

    Turn the pin spacing off

    In "Pin and the spacer" add pinSpacing: false after pin: true, then scroll to 180. The spacer is only 60 px tall and the scroll range falls from 370 to 170. The next block ("After the battery") now slides up and draws over the pinned section.

Code & diagrams

Scrub in a scroll box

Scroll the box. `scroller` points ScrollTrigger at this box instead of the page, so the example works in the small viewfinder. Turn the timeline's speed down and scroll slowly to see the scrub.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

start 72 end 178 scrollable 250
Pin and the spacer

The battery section is pinned for 200 px of scrolling while its meter fills. The console shows what ScrollTrigger did to the DOM.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

parent before: scroller | parent now: pin-spacer
section height: 60 | spacer height: 260
scroll range: 370
Late layout: the stale start

An image arrives above the box after setup. Read the three console lines (they print 0.6 s after load), or press the buttons yourself.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

at load:        start 48 | the box really reaches the 90% line at scrollTop 48
photo loaded:   start 48 | the box really reaches the 90% line at scrollTop 148
after refresh:  start 148 | the box really reaches the 90% line at scrollTop 148

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Create the trigger before the layout is final

In "Late layout" wait for the three console lines, press Photo loads (+100 px) and read the line under the buttons, without pressing refresh().

terminal
$ Read the line under the buttons, then press refresh()
── what you'll see ──
start 148 | the box really reaches the 90% line at scrollTop 248
(after refresh(): start 248 | the box really reaches the 90% line at scrollTop 248)

Break #2

Pin a section near the end of the page

Put a pinned section (start: "top top", pin: true, a meter scrubbed across the pin) near the end of a page with only a short footer after it. Test page: 400 px tall viewport, a 120 px section, a 100 px footer.

terminal
$ Scroll to the very bottom and read the section's position and the trigger's progress
── what you'll see ──
end: "+=300": start 608, end 908, the page can scroll to 736 → section pinned at the top, progress 0.43, the meter never fills
end: "+=100": start 608, end 708, the page can scroll to only 536 → section 72 px from the top, progress 0.00, the pin never starts

Myth vs fact

Myth

ScrollTrigger always keeps itself up to date.

Fact

It refreshes on page load, on resize and when a hidden tab returns with a different window size, and nothing else. A layout change above a trigger after that (an image, a font, an accordion) needs ScrollTrigger.refresh().

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    ScrollTrigger.refresh() is not free: it reverts every pin and trigger, measures, and applies them again. Creating a pin queues one batched refresh (a pin can move other triggers), and invalidateOnRefresh: true makes an animation read its start values again on each refresh. Refresh once after a batch of layout changes.

Remember this

  1. 1

    The problem this solves: CSS scroll-driven animation (Phase 12) covers many cases, but it needs a browser that supports it, has no pin option (in CSS you hold the element yourself with position: sticky), and gives you no callbacks. ScrollTrigger works in every browser GSAP does, adds pinning, snapping and callbacks, and lets one timeline be played, scrubbed or toggled by scroll.

  2. 2

    Start and end. Both are written as "<trigger position> <scroller position>": start: "top 80%" means "when the top of the trigger meets the 80% line of the scroller", and end: "bottom 20%" is the trigger's bottom reaching the 20% line. end: "+=300" means 300 px after the start. Positions can be top, center, bottom, a percentage or pixels. ScrollTrigger turns them into scroll offsets (st.start, st.end).

  3. 3

    Three modes. With toggleActions (the default, "play none none none") the animation plays on enter, as in "play once when it comes into view". With scrub: true the animation's progress equals the scroll progress between start and end, forward and backward; scrub: 0.5 lets it catch up half a second behind. With pin: true the element is held still while the scroll passes through the range. toggleActions is ignored when scrub is set.

  4. 4

    The pin spacer. To hold an element still, ScrollTrigger wraps it in a div.pin-spacer that takes its place in the layout and is made taller by the pin distance (pinSpacing, on by default), so the content below starts after the pinned part. While pinned, the element gets position: fixed when the scroller is the page, or a transform inside any other scroll box.

  5. 5

    Measuring is a snapshot. ScrollTrigger reads positions when it is created (in a batch, on the next frame), again on DOMContentLoaded and load, on window resize (0.2 s after the last one), when a hidden tab comes back with a different window size, and when you call ScrollTrigger.refresh(). It does not watch the content: an image that loads above the trigger, a font swap or an opening accordion moves everything, and the stored numbers stay stale until a refresh.

  6. 6

    On touch devices. Phone browsers resize the window when their address bar slides away. On touch devices ScrollTrigger ignores a resize that keeps the width and changes the height by under a quarter (ignoreMobileResize), so the sliding address bar does not refresh it. A pin also takes a phone's whole screen: the build turns it off below 700 px.

Explain it without notes

01

How is the start of a ScrollTrigger calculated?

02

What does the pin spacer do and what happens with pinSpacing: false?

Practice

01

Write a ScrollTrigger on #panel that plays a fade-in once when the panel's top reaches 85% of the viewport, and never replays.

02

Pin #story for 500 px of scrolling and scrub a progress bar #bar (scaleX 0 to 1) across the same range.

Done when you can

  • You can write start and end and say what number they become

  • You can scrub, toggle and pin, and say what toggleActions does when scrub is set

  • You can say what the pin spacer is and what pinSpacing: false does