Back to guide
Development
Intermediate

8 min read

Scroll-Triggered and Interactive Lottie Animations

Most Lottie animations just autoplay in a loop, but the player APIs expose enough control to drive an animation from scroll position, hover state, or a click — without needing separate animation files for each state.

Play once when it scrolls into view

The most common pattern: don't autoplay immediately on page load, wait until the container is actually visible using IntersectionObserver, then play once.

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        animation.play();
        observer.unobserve(entry.target); // only trigger once
      }
    });
  },
  { threshold: 0.5 }
);

observer.observe(document.getElementById('lottie-container'));

Scrubbing: tie animation progress to scroll position

Instead of playing forward automatically, you can map scroll progress directly to a frame number and call goToAndStop on every scroll event, giving the effect of the animation being "attached" to the scrollbar. This is how a lot of product-page scroll-hijack animations work.

function updateFrame() {
  const rect = container.getBoundingClientRect();
  const progress = Math.min(
    Math.max((window.innerHeight - rect.top) / (window.innerHeight + rect.height), 0),
    1
  );
  const frame = Math.floor(progress * animation.totalFrames);
  animation.goToAndStop(frame, true); // true = frame value, not milliseconds
}

window.addEventListener('scroll', () => requestAnimationFrame(updateFrame));

Always wrap the scroll handler in requestAnimationFrame (or throttle it) — calling goToAndStop synchronously on every raw scroll event will visibly stutter on lower-end devices.

Click and hover: playing specific segments

If your animation has distinct sections — an idle loop, a "success" burst, an "error" shake — playSegments lets you jump to and play just that range of frames without loading a separate file.

button.addEventListener('click', () => {
  // play frames 30 to 90, then stop (don't loop back to idle automatically)
  animation.playSegments([30, 90], true);
});

button.addEventListener('mouseenter', () => animation.setDirection(1));
button.addEventListener('mouseleave', () => animation.setDirection(-1));