GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Scroll Trigger Animation: 9 Engines, No GSAP

Scroll trigger animation is the device that fires a movement the moment an element enters the viewport.

Auto-plays · click a tile to jump to its section · all nine in one zip

The spine of the nine is the order in which an engine gets built. The observer core stands first (01), a threshold gauge is bolted on (02), and the first consumers arrive — play once or replay every pass (03), then the counter (04). From there the visuals take over the screen: words flow in sequence (05), direction (06) and velocity (07) get noticed, layers split into depth (08), and sections finally snap to the frame (09). The observer part covers the ground that 9 CSS Scroll Animations, 0 JS and CSS Parallax Scroll: 9 Recipes approached from the opposite side — those two hand everything to the browser; this one has you driving the engine yourself. Reading-position displays belong to 9 Scroll Position Indicators, and pinning scenes to the screen is Scroll Storytelling Sections territory.

01Trigger engine core

The engine itself: an observer that stamps an is-visible class the moment a section enters the viewport, with direction variants, threshold, once, and early-detection margin all living in data-scroll attributes on the HTML.

Trigger engine core
Open in a new tab

01 · Trigger engine core — Measured area 20.597% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserverdata-thresholdis-visible

What one observer reads

function attach(card) {
  var threshold = parseFloat(card.getAttribute('data-threshold')) || 0;
  var once = card.getAttribute('data-once') === 'true';
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
        if (once) io.unobserve(entry.target);
      } else if (!once) {
        entry.target.classList.remove('is-visible');
      }
    });
  }, { root: view, rootMargin: '0px 0px -8px 0px', threshold: threshold });
  io.observe(card);
  observers.push(io);
}

Setting root to the scrolling box instead of the page is the difference in the first snippet, and the -8px on the bottom edge holds the light off until the card clears the very bottom by 8 pixels — on a real page you would write -10% and let percentages do the pushing.

CSS needs to know one class

.te__card {
  opacity: 0;
  transition: opacity $dur-enter $easing, transform $dur-enter $easing;
}
.te__card[data-scroll="up"] { transform: translateY(18px); }
.te__card[data-scroll="left"] { transform: translateX(-24px); }
.te__card.is-visible {
  opacity: 1;
  transform: none;
}

Each data-scroll value gives the card a different starting position but they all end at transform: none, and this two-layer split — the observer owns the moment, the transition owns the look — is what runs every scroll reveal animation in the reveal family.

02Threshold gauge

Shows how much of the element is actually inside the viewport as an intersectionRatio gauge from 0 to 1, and switching the threshold candidate changes the moment the light turns on.

Threshold gauge
Open in a new tab

02 · Threshold gauge — Measured area 20.001% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

intersectionRatiothreshold: [0, 0.25, 0.5, 1]scaleX

Values arrive as a staircase

function paint(ratio, isIn) {
  numEl.textContent = ratio.toFixed(2);
  fill.style.transform = 'scaleX(' + ratio + ')';
  var on = isIn && ratio >= selected - 0.01;
  led.classList.toggle('is-on', on);
  stateEl.textContent = on ? 'Threshold reached' : 'Waiting';
}
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    paint(entry.intersectionRatio, entry.isIntersecting);
  });
}, { root: view, threshold: [0, 0.25, 0.5, 1] });
io.observe(target);

The observer calls back only when a threshold is crossed, so the value refreshes as a staircase rather than a stream — in the measured run the readout stepped 0.00 → 0.07 → 0.25 (lights) → 0.61 → 1.00 while the gauge's scaleX drew exactly the same numbers.

The threshold array is the callback map

threshold: [0, 0.25, 0.5, 1] is an order to report each time the ratio crosses one of these four values, which is why switching the candidate from 0.25 to 1 held the light off until scrollTop 92 in the same motion — and why a section taller than the viewport, whose ratio can never reach 1, must not use 1 as its threshold.

03Once vs every pass

The same reveal plays exactly once on the left (unobserve) and re-fires on every pass on the right.

Once vs every pass
Open in a new tab

03 · Once vs every pass — Measured area 23.665% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

unobserveoffsetWidthdata-once

The reflow trick restarts a finished animation

var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        card.classList.remove('is-in');
        void card.offsetWidth; // reflow trick — a forced recalculation here makes the next class attach a fresh animation
        card.classList.add('is-in');
        plays += 1;
        dots.forEach(function (d, i) { d.classList.toggle('is-lit', i < Math.min(plays, 3)); });
        if (once) io.unobserve(card); // left — plays exactly once, then stops observing
      } else if (!once) {
        card.classList.remove('is-in'); // right — resets on exit, replays on the next entry
      }
    });
  }, { root: view, threshold: 0.5 });

Removing and re-adding the same class does not restart a transition on its own, so the forced recalculation of void card.offsetWidth between the two is what makes the next class attach a brand-new animation.

Three round trips, counted

Across three measured round trips the left column played once and the right column three times, and counting transitionstart events gave 1 versus 6 — the right side starts two transitions per trip, one for the entry and one for the exit.

04Counter trigger

The moment it enters the viewport, requestAnimationFrame counts the number up from 0 to the target — then the observer stops.

Counter trigger
Open in a new tab

04 · Counter trigger — Measured area 13.421% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameeaseOutCubicunobserve

requestAnimationFrame keeps the clock

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// inside the observer callback:
      io.unobserve(entry.target); // observing stops here — scrolling back up never counts twice
      var t0 = null;
      function tick(ts) {
        if (t0 === null) t0 = ts;
        var p = Math.min((ts - t0) / MS, 1);
        var eased = easeOutCubic(p);
        numEl.textContent = String(Math.round(TARGET * eased));
        bar.style.transform = 'scaleX(' + eased + ')';
        if (p < 1) requestAnimationFrame(tick);
        else done.classList.add('is-shown');
      }
      requestAnimationFrame(tick);

Measuring time from the frame timestamps ts instead of setInterval is the point — when the tab sleeps in the background, frames and the clock sleep together and the number never jumps.

Why the curve

The measured count rose 0 → 42 → 92 → 138 → 248 along the decelerating easeOutCubic curve and stayed at 248 after scrolling back and re-entering — a linear count stops dead at the target while an eased one settles into it.

05Timeline stagger

Split the sentence into words, give each fragment a transition-delay based on its index, and one trigger plays them like a timeline.

Timeline stagger
Open in a new tab

05 · Timeline stagger — Measured area 27.501% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

transition-delayvar(--i)data-stagger

Splitting words takes six lines

var words = line.textContent.trim().split(/\s+/);
line.textContent = '';
words.forEach(function (word, i) {
  var span = document.createElement('span');
  span.className = 'sw__word';
  span.textContent = word;
  span.style.setProperty('--i', i);
  line.appendChild(span);
  if (i < words.length - 1) line.appendChild(document.createTextNode(' '));
});

JavaScript only stamps the order index --i on each fragment, and other ways to build that stagger live next door in 9 CSS Stagger Animations.

One CSS calculation owns the delay

.sw__word {
  display: inline-block;
  opacity: 0;
  transform: translateY(14px);
  transition-property: opacity, transform;
  transition-duration: $dur-quick, 420ms;
  transition-timing-function: ease-out, $easing;
  transition-delay: calc(var(--i) * var(--stagger, 90ms)), calc(var(--i) * var(--stagger, 90ms));
}
.sw__card.is-visible .sw__word {
  opacity: 1;
  transform: none;
}

With six words the measured delays landed as a staircase of 0 / 0.09 / 0.18 / 0.27 / 0.36 / 0.45 seconds — the division of labor between delay in CSS and moment in the engine, showing up as raw numbers in this scroll reveal text animation.

06Direction enter & exit

Compares against the last scroll position to tell up from down, then styles entering and leaving differently.

Direction enter & exit
Open in a new tab

06 · Direction enter & exit — Measured area 34.152% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

isIntersectingscrollTopis-exit

Direction is two lines of comparison

function readDir() {
  // compare with the previous scroll position — going down is down, going up is up
  var y = view.scrollTop;
  if (y > lastY) curDir = 'down';
  else if (y < lastY) curDir = 'up';
  root.setAttribute('data-dir', curDir);
  lastY = y;
}

The observer callback stamps the current curDir onto the card every time isIntersecting flips, and in the measured run scrolling down gave the card is-visible with is-enter on the root, scrolling it out swapped it to is-exit with is-leave, and scrolling back up stamped direction up for a different entrance.

Exits only count after a first entry

A flag keeps the exit branch from firing for elements that start below the fold on first load, so an off-screen card is not recorded as an exit it never performed.

07Velocity response

Measures scroll speed from the distance between frames and hands it to --vel, so faster scrolling tilts and stretches the text.

Velocity response
Open in a new tab

07 · Velocity response — Measured area 25.483% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

skewYclamp()--vel

Velocity is normalized before handoff

function tick() {
  var y = view.scrollTop;
  var dy = Math.abs(y - lastY);
  lastY = y;
  // normalize the distance to 0~1 and follow it lazily — no jumps, and it decays to 0 at rest
  var target = Math.min(dy / 26, 1);
  vel += (target - vel) * 0.16;
  if (vel < 0.004) vel = 0;
  root.style.setProperty('--vel', vel.toFixed(4));
  requestAnimationFrame(tick);
}

Fast scrolling measured --vel at 0.98 with a skew component of 0.105, slow scrolling 0.099 with 0.012, and releasing the wheel decayed the value to 0 and stood the type back upright.

CSS spends it as an angle

@property --vel {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
.vl__line:nth-child(odd) {
  transform: skewY(clamp(-6deg, calc(var(--vel, 0) * -7deg), 6deg)) translateY(calc(var(--vel, 0) * -4px));
}

Registering --vel as a number through @property is what lets CSS functions compute with it, and opposite signs on odd and even rows make the rows lean against each other harder the faster you scroll.

08Three-layer parallax

Far stars, mid hills, and front type each move by their own ratio via translate3d, and a per-frame lerp eases toward the target so even fast scrolling never stutters.

Three-layer parallax
Open in a new tab

08 · Three-layer parallax — Measured area 20.681% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

translate3dlerpwill-change

The HTML carries each ratio

<div class="px__layer px__layer--stars" data-rate="0.12" aria-hidden="true"></div>
<div class="px__layer px__layer--hills" data-rate="0.3" aria-hidden="true">
<!-- the type layer below carries data-rate="0.55" -->

At scrollTop 120 the three layers landed at -14.4 / -36 / -66 pixels — the exact products of rates 0.12, 0.3, and 0.55 — and at 60 they sat at exactly half, ratios intact.

A lerp chases the target every frame

function lerp(a, b, t) {
  return a + (b - a) * t;
}
function frame() {
  var target = view.scrollTop;
  // closing 12 percent of the gap each frame never stutters, and within 0.1px it snaps exact
  current = Math.abs(target - current) < 0.1 ? target : lerp(current, target, 0.12);
  for (var i = 0; i < layers.length; i++) {
    var rate = parseFloat(layers[i].getAttribute('data-rate')) || 0;
    layers[i].style.transform = 'translate3d(0, ' + (-current * rate).toFixed(2) + 'px, 0)';
  }
  requestAnimationFrame(frame);
}

translate3d hands the transform to the compositor, which is where it belongs in something that runs every frame, and the JS-free route to the same depth is charted in CSS Parallax Scroll: 9 Recipes.

09Snap section switch

scroll-snap-type makes each section snap to the screen, while the observer picks the centered one and marks it with aria-current.

Snap section switch
Open in a new tab

09 · Snap section switch — Measured area 35.251% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

scroll-snap-typescroll-snap-alignaria-current

Center detection is rootMargin's job

function pick(entries) {
  entries.forEach(function (entry) {
    if (!entry.isIntersecting) return;
    var i = panels.indexOf(entry.target);
    if (i < 0) return;
    panels.forEach(function (panel, j) {
      if (j === i) { panel.setAttribute('aria-current', 'true'); }
      else { panel.removeAttribute('aria-current'); }
    });
  });
}
// whichever panel overlaps the middle 10 percent band is the centered one
var io = new IntersectionObserver(pick, { root: view, rootMargin: '-45% 0px -45% 0px', threshold: 0 });

Slicing 45 percent off the top and bottom leaves a 10 percent band at the center of the screen, and the measured snap points sat at 0 / 160 / 320 — setting scrollTop to 80 settled at 0, at 240 settled at 160, and aria-current moved to whichever panel held the middle each time.

The snap CSS is three lines

.ss__view {
  scroll-snap-type: y mandatory;
}
.ss__panel {
  scroll-snap-align: center;
}

The preview loop sets scroll-snap-type to none and restores it on first touch, because a live snap would grab the animated track back and cancel the whole preview.

Scroll detection: scroll listener or IntersectionObserver?

Answer first: the observer for in-and-out detection, the listener when you need a continuous value. Most scroll animation css examples start at exactly this fork. A scroll listener fires every frame, and calling getBoundingClientRect() inside it recalculates layout each time; IntersectionObserver has the browser compute entries at its own leisure, which is the right tool for everything in 01 through 06 — questions of when to switch on and off. Flip it around, and 07·08 need a position or speed at every instant, which an observer cannot give because its callbacks only arrive at threshold crossings. There you read scrollTop inside a requestAnimationFrame loop and batch the reads into one per frame. Every scroll animation demo in this episode stands on one side or the other of that divide.

Where it breaks — the preview runs but the hand cannot

The movement in the grid is CSS keyframes; the moment a human scrolls is observer code. They are separate paths, so one can be dead while the render measurements still pass.

Four cards lit at once

The first build of 01 looked sequential in the preview, but the interaction probe lit all four cards 1111 at scrollTop 30 — the 156px viewport held every card, so they crossed their thresholds together. Dropping the view to 100px and enlarging the cards put the sequence back: 1100 → 1110 → 1111 at scrollTop 20, 60, and 120. The preview's animation-delay staircase was only an imitation of the order; what actually creates order is the geometry of viewport and threshold.

A fade stayed in the poster as a ghost

The first draft of 01 bundled the card's entrance into a single opacity fade. Twenty-four frames sample two seconds at 4.2 percent intervals, so the fade's middle states became frames, and the poster showed a half-visible card overlapping the next — pre-repair measurements read 29.19 percent area and 114.4 intensity. Splitting appearance into a steps(1, end) cut and motion into a transform eased the same scene up to 171.4 intensity and removed the ghost. The password for the nine zip files is 82xy9zm9, and the three-line-wrapped status label in 02 and the clipped bottom line in 04 were caught in the same visual pass.

A poster is one frame only

02 once produced a poster where the gauge read 1.00 while the highlighted threshold candidate said 0.25 — two elements phasing independently — fixed by aligning their loop phases. Eyeballing the grid is not something measurement values can replace, and 9 Scroll Position Indicators repeated the same lesson. The authoritative references are MDN's IntersectionObserver page and the scroll event page.

Three variants and accessibility

Change the three variables and the temperament of all nine engines shifts in lockstep.

Name Value changed Feel
duration $dur-enter .6s → .3s Section entrances double in speed and skim past
easing $ease-spring → $ease-pop Entrances bounce once more on arrival
color $subject-blue → $stage-orange The accent shifts from cool blue to warm orange

Accessibility splits in two. The reveal family (01·03·05·09) turns transitions and animations off under prefers-reduced-motion: reduce and leaves the content fully visible, because content must not disappear. The continuous family (07·08) keeps the position math and kills only the speed effect. The counter (04) skips the count and jumps straight to the target value under reduce. aria-current (09) is a state marker, not a movement, so it survives on both sides.

FAQ

Can I copy a scroll trigger animation codepen from here and run it as-is?

Yes — and the point of this episode is that there is no codepen to visit. The vanilla/ folder in the zip is an index.html plus style.css and style.scss, so you open the folder in a browser and it runs. The engine attaches to elements carrying data-scroll attributes, so copying the classes and attributes onto your own markup wires it into any page.

Should I just use GSAP ScrollTrigger instead?

At scale, features like scrub (binding animation time to scroll progress) and timeline alignment earn their keep. For the scope of this episode — entry detection, a counter, a snap — a few dozen observer lines are enough, and the loading and learning cost of a library disappears. The pragmatic route is to start with this engine and expand only when scrub becomes necessary.

Does IntersectionObserver work on iPhone Safari?

Yes — observers have shipped in every major browser, Safari included, since 2019, and scroll-snap-type (09) is just as well supported. The thing to watch is performance, not support: reading the DOM inside a scroll listener slows mobile down first, so the batch-your-reads-into-requestAnimationFrame pattern from 07·08 is the one to keep.

Enter the archive password

The password is inside this article. You will find it as you read.