Building a Scroll-Scrubbed Lottie Animation With lottie-web and GSAP ScrollTrigger

Build a scroll-scrubbed Lottie animation using lottie-web and GSAP ScrollTrigger. Sync animation frames with scrolling for smooth interactive effects.
Building a Scroll-Scrubbed Lottie Animation With lottie-web and GSAP ScrollTrigger

You've almost certainly scrolled past one of these without knowing what powered it: a slick vector animation on a landing page that doesn't just play on a loop, but advances as you scroll — a product drawing itself, an illustration assembling, a progress ring filling exactly in step with how far down the page you are. That's a scroll-scrubbed Lottie, and it's the difference between an animation that runs on its own clock and one the visitor feels like they're driving. The Lottie Scroll Scrub snippet builds the whole thing — a glowing progress ring that "draws" itself around a circle and a checkmark that strokes in at the end, all tied frame-for-frame to the scrollbar — using the official lottie-web player and GSAP's ScrollTrigger, with the animation itself living right in the code as plain JSON.

Here's the effect live — scroll inside the frame to draw the ring, and scroll back up to rewind it:

Grab the code, or open the full editor with live HTML/CSS/JS panels: Lottie Scroll Scrub on FWD Tools. It's plain HTML, CSS, and JavaScript with two CDN scripts, but the same editor has one-click export buttons for React, Vue, Angular, and React + Tailwind if you'd rather drop it into a component-based project than copy raw markup.

In this post I'll walk through the whole thing A to Z — what "scrubbing" a Lottie actually means, where it earns its keep, and then the full build: loading a Lottie from inline JSON, freezing its clock so scroll controls it instead, mapping scroll progress onto animation frames, the trim-path trick that makes the ring draw itself, and the one non-obvious keyframe detail that will silently break the whole effect if you miss it. By the end you'll understand it well enough to drop in your own Lottie file and drive it however you like.

What the effect actually is

A Lottie is a vector animation exported as JSON — most often from After Effects via the Bodymovin plugin, though you can author one by hand too. Normally you load it and it plays on a timer: press go, and it runs at its designed frame rate from start to finish. Scrubbing throws that timer away. Instead of "play," the animation is told to sit frozen on a single specific frame, and which frame is decided entirely by how far the visitor has scrolled. Scroll to 25% of the way through a section and the animation jumps to 25% of its frames; scroll to 60% and it's on frame 60%; scroll back up and it walks backward through its own frames. The animation becomes a slider, and the scrollbar is the handle.

Everything else in this snippet is built on that one idea. The visible result — a ring that draws on and a checkmark that strokes in — is just what this particular Lottie happens to contain. Swap the JSON for any other Lottie and the exact same scrubbing machinery drives it, whether that's a character walking, a logo assembling, or a box unfolding.

Where you'd actually use this

Scroll-scrubbed Lottie is a strong signature when the animation should feel connected to the reading, not just decorative:

  • Scroll-to-complete progress. A ring or bar that fills exactly as the reader reaches the end of a section gives real, felt feedback on how far they've come.
  • Product and feature reveals. Drive a designer-made After Effects animation with the scrollbar so a product assembles, unfolds, or animates in step with the copy beside it.
  • Onboarding and explainer steps. Scrub through a multi-stage illustration as the user scrolls, pairing each beat of the animation with a matching caption.
  • Hero and landing-page motion. A scroll-reactive vector centrepiece reads as premium motion design while staying tiny — a Lottie is a fraction of the weight of a video.
  • Storytelling and data reveals. Tie a Lottie's timeline to a scroll narrative so the visual and the text advance together, one paragraph at a time.

In every one of these, the appeal is the same: the animation is under the visitor's control, so it reads as responsive and intentional rather than something looping away on its own in the corner.

The tech stack: lottie-web and GSAP ScrollTrigger

This snippet uses exactly two libraries, both from a CDN — no bundler, no build step. lottie-web is the official player that turns Lottie JSON into an animated SVG (or canvas) and exposes the API for seeking to a specific frame. GSAP with its ScrollTrigger plugin handles the scroll side: pinning a section in place and producing a smooth 0-to-1 progress value as the visitor scrolls through it. The whole effect is the marriage of those two — ScrollTrigger says "you're 40% through," and lottie-web is told "then show me frame 40%."

If GSAP ScrollTrigger is new to you, the one concept to hold onto is the scrub: rather than firing an animation once when an element enters the viewport, a scrubbed ScrollTrigger ties an animation's progress directly to scroll position, so it plays forward as you scroll down and backward as you scroll up. The same scrubbing idea powers the scroll typewriter snippet — here we point it at a Lottie's frame instead of typed characters.

Building it, step by step

Step 1 — The HTML and CSS: a pinned stage with a mount point

The markup is deliberately minimal: a full-height section that will get pinned, containing a single empty <div> for lottie-web to render into, plus a caption overlay and a small progress read-out.

<section class="lot-stage" id="lotStage">
  <div class="lot-intro-overlay"><p>Scroll ↓ to play the Lottie</p></div>
  <div class="lot-wrap"><div class="lot-canvas" id="lotMount"></div></div>
  <div class="lot-hud"><span id="lotPct">0</span>% complete</div>
</section>
<section class="lot-bottom"><p>Animation complete — scroll up to rewind it.</p></section>

#lotMount is the only element that matters to the animation — lottie-web will inject an <svg> into it. It's given a fixed size in CSS (around 340×340) so the vector has a box to scale into. The .lot-stage is height: 100vh and position: relative so the caption overlay can sit centered over the animation, and a second .lot-bottom section below it gives the page something to scroll into after the pinned stage releases.

Step 2 — Lottie is just JSON: load it inline with animationData

Most Lottie tutorials load the animation from a URL, like lottie.loadAnimation({ path: 'anim.json' }). That works, but it means an extra network request and a file you have to host somewhere. This snippet takes the other route: it passes the animation directly as an animationData object, so the JSON ships inside the component and there's nothing to fetch and nothing that can 404.

const animationData = {
  v: '5.7.4', fr: 60, ip: 0, op: 120, w: 400, h: 400, nm: 'scroll-ring', ddd: 0, assets: [],
  layers: [ /* a track ring, a progress ring, and a checkmark */ ]
};

const anim = lottie.loadAnimation({
  container: document.getElementById('lotMount'),
  renderer: 'svg',
  loop: false,
  autoplay: false,   // ← the important part, see Step 3
  animationData,
});

Those top-level fields are the Lottie schema: fr is the frame rate, ip and op are the in and out points in frames (so this animation is 120 frames long), w/h are its dimensions, and layers is the array of shapes. The best part of inlining it: swapping in your own animation is a one-line change. Delete the animationData object and pass path: 'your-animation.json' instead — every other line in this tutorial stays identical, because the player's API is the same either way.

Step 3 — autoplay:false and goToAndStop: making it scrubbable

The single most important option above is autoplay: false. A normal Lottie starts running on its own clock the instant it loads; here we never want that internal timer to tick, because scroll position — not elapsed time — decides which frame is visible. With autoplay disabled and loop: false, the animation sits frozen at frame 0 until we explicitly move it.

Moving it is one method call:

anim.goToAndStop(frame, true);

That second argument, true, tells lottie-web the value is a frame number rather than a time in milliseconds, and goToAndStop renders that single still frame without starting playback. To turn a 0-to-1 scroll progress into a frame, you multiply by the total number of frames:

const p = 0.4;                              // 40% scrolled
anim.goToAndStop(p * (anim.totalFrames - 1), true);   // show frame ~48 of 120

Because lottie-web interpolates between keyframes, a fractional frame like 47.6 renders smoothly — so the ring grows continuously as you scroll rather than snapping between whole frames.

Step 4 — Driving one scrubbed value with ScrollTrigger

Rather than reading the raw scroll offset by hand, the snippet lets GSAP produce a clean 0-to-1 value. It tweens a single plain number — state.p — from 0 to 1, tied to a ScrollTrigger that pins the stage and scrubs over a range several viewport-heights tall:

gsap.registerPlugin(ScrollTrigger);

const state = { p: 0 };
gsap.to(state, {
  p: 1,
  ease: 'none',
  scrollTrigger: {
    trigger: '#lotStage',
    start: 'top top',
    end: '+=400%',   // the pinned scroll distance
    scrub: 0.5,      // smooth the value toward the scroll position
    pin: true,
  },
});

Then a plain requestAnimationFrame loop reads state.p every frame and seeks the Lottie to the matching frame — independent of the scroll callback, exactly like the pattern used across the other scroll snippets:

function tick() {
  requestAnimationFrame(tick);
  if (!total) return;                        // total = anim.totalFrames once loaded
  const p = Math.max(0, Math.min(1, state.p));
  anim.goToAndStop(p * (total - 1), true);
  document.getElementById('lotPct').textContent = Math.round(p * 100);
}
tick();

Deriving the frame from one normalized value each frame is what makes the effect fully reversible for free: scrolling up simply produces smaller values of state.p, so goToAndStop seeks earlier frames and the animation plays backward — the ring un-draws and the checkmark retracts — with no extra reverse-playback code. The numeric scrub: 0.5 adds about half a second of easing so the animation glides toward the scroll position instead of snapping frame-perfectly to every noisy wheel tick.

Step 5 — The draw effect: Lottie trim paths

The ring and checkmark don't fade in — they stroke on, like a pen drawing a line. That's a Lottie trim path, shape type tm, which renders only a portion of a stroked path from a start percentage to an end percentage. Animating the trim's end from 0 to 100 across the timeline makes the stroke appear to draw itself. Here's the progress ring: an ellipse, a trim whose end is keyframed 0→100, and a stroke, grouped together:

shapes: [{
  ty: 'gr',
  it: [
    { ty: 'el', s: { a: 0, k: [200, 200] }, p: { a: 0, k: [0, 0] } },   // ellipse
    { ty: 'tm', s: { a: 0, k: 0 },                                       // trim: start 0
      e: { a: 1, k: [ /* end keyframes 0 -> 100 */ ] }, o: { a: 0, k: 0 }, m: 1 },
    { ty: 'st', c: { a: 0, k: [0.45, 0.53, 0.98, 1] }, w: { a: 0, k: 16 } }, // stroke
    { ty: 'tr' }                                                         // group transform
  ]
}]

A second, static ring with no trim sits behind it at low opacity as the "track," so the circle's full path is always visible — exactly like a circular progress indicator. The checkmark is the same idea: a small open path whose trim end is keyframed to draw in over the last third of the timeline, so it only appears once the ring is nearly complete. Because trim is part of the JSON, the drawing behavior travels with the animation wherever the player runs — it's the Lottie equivalent of animating stroke-dashoffset on an SVG, but authored once inside the file.

Step 6 — The keyframe gotcha that silently breaks everything

This is the one detail worth burning into memory, because it produces a blank animation with no error message. When you hand-author (or tweak) a keyframed Lottie property, each animated keyframe must carry its easing tangents — the i (in) and o (out) bezier handles. Leave them off and lottie-web treats the keyframe as a hold keyframe: it never interpolates, so the value stays pinned at the first keyframe forever. For a trim animating 0→100, that means the end stays at 0, the trimmed stroke has zero length, and the ring renders as an empty path — even though the frame is advancing correctly.

The fix is to give every animated keyframe linear tangents. Control points at (0.5, 0.5) describe a straight diagonal — i.e. true linear interpolation:

e: { a: 1, k: [
  { i: { x: [0.5], y: [0.5] }, o: { x: [0.5], y: [0.5] }, t: 0,   s: [0]   },
  { t: 120, s: [100] }
] }

Symptom to recognize: if a Lottie's currentFrame is clearly advancing but a trimmed or animated shape isn't changing, suspect missing i/o tangents before anything else. Bodymovin always exports them, which is why the problem only tends to show up when you write or edit the JSON by hand.

Step 7 — Fading the caption as the animation begins

One small touch keeps the intro caption from sitting on top of the finished animation. The same tick loop that seeks the frame also fades the overlay out once scrolling starts and back in when you return to the top:

intro.style.opacity = p > 0.02 ? '0' : '1';

Because it's driven by the same scrubbed p, it reverses automatically like everything else — scroll back to the very top and the "Scroll to play" prompt fades right back in.

Customizing it for your own project

A few knobs worth knowing about if you're adapting this:

  • Your own animation — the whole point of the inline JSON is that it's swappable. Replace animationData with path: 'your-animation.json' to drive any exported Lottie with the exact same scroll logic.
  • Scroll length — the ScrollTrigger end: '+=400%' controls how much scrolling maps to the full animation; raise it for a longer, slower playback or lower it for a quick one.
  • Smoothness — the numeric scrub value (0.5) sets how much the animation eases toward the scroll position; drop it toward 0 for a tighter, more immediate response, or raise it for a more floaty glide.
  • Rendererrenderer: 'svg' keeps the vector razor-sharp and easy to add a CSS glow to; for very heavy animations you can switch to 'canvas' for better raw performance at the cost of crispness.

Using it in React, Vue, or Angular

The one rule that matters when porting this into a component framework is: create the animation and register the ScrollTrigger inside a mount lifecycle hook — useEffect in React, onMounted in Vue, ngAfterViewInit in Angular — against a ref to the mount element, never at render or module time, since lottie-web needs a real DOM node to draw into. Keep the anim instance and the scrubbed value in refs rather than component state, so a re-render never tears down the running animation. Most importantly, clean up on unmount: call anim.destroy() to release the SVG and the player, and kill the ScrollTrigger instance (or revert a gsap.context) so the pin and scroll listener don't leak. The official lottie-react wrapper works too, but calling the raw player as shown here keeps the frame-seeking logic fully in your control, which is exactly what you want for scrubbing.

The FWD Tools snippet editor has one-click exports for all of this already built if you'd rather skip writing the conversion by hand — grab the React, Vue, Angular, or Tailwind version directly from the snippet page.

Build, understand, optimize, and extend it with AI

You don't have to reverse-engineer how a JSON file becomes a scroll-scrubbed animation by hand. Paste the snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through exactly why autoplay has to be disabled, what goToAndStop's second argument does, or how the trim path produces the drawing effect. The same assistant is invaluable for the keyframe gotcha in Step 6 — hand it a Lottie whose animation isn't playing and ask it to check whether the animated keyframes are missing their i/o tangents. It's just as useful for extending the effect: ask it to load your own exported Lottie via a path instead of inline JSON, sync several Lottie layers to different scroll ranges, add a second animation that plays as the first finishes, or swap the SVG renderer for canvas on a heavier file and compare the frame rate. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

Build a "scroll-scrubbed Lottie animation" in plain HTML, CSS, and JavaScript using lottie-web and GSAP's ScrollTrigger plugin, both loaded from a CDN (no bundler, no build step).

Requirements:
- A pinned full-height section containing a centered container div for the Lottie, an intro caption overlay, and a live "percent complete" read-out.
- Load a Lottie with lottie.loadAnimation using renderer 'svg', loop false, and autoplay FALSE, so a timer never drives it. Provide the animation as an inline animationData JSON object, but leave a clear comment showing how to swap it for path: 'your-animation.json'.
- The inline Lottie should include a faint static "track" ring, a coloured progress ring whose stroke draws on via an animated trim path (type 'tm') whose end goes from 0 to 100, and a checkmark path that draws in over the last third of the timeline.
- Every animated keyframe in the JSON must include its easing tangents (the i and o bezier handles), otherwise lottie-web treats it as a hold keyframe and never interpolates. Use linear tangents with control points at 0.5/0.5.
- Register a GSAP tween on a ScrollTrigger targeting the pinned section, with pin: true, start at top top, a numeric scrub around 0.5, and an end several hundred percent tall, animating a single plain value p from 0 to 1.
- In a requestAnimationFrame loop (independent of the scroll callback), read p and call anim.goToAndStop(p * (anim.totalFrames - 1), true) to seek a single frame, and update the percent read-out.
- Fade the intro caption out once p passes a small threshold and back in when it returns to 0.
- Confirm scrolling back up reverses the animation, since the frame is derived from a fully scrubbed value rather than a one-way timer.

Final thought

What makes this snippet worth studying isn't the ring and checkmark — it's the pattern underneath: any Lottie becomes scroll-driven the moment you disable its autoplay, produce a clean 0-to-1 value from scroll, and feed that value into goToAndStop each frame. That's the entire recipe, and it works with a hand-drawn ring or a fifty-layer After Effects export exactly the same way. Add the trim-path draw effect and the keyframe-tangent detail from Steps 5 and 6, and you can author scroll-reactive vector motion from scratch without touching After Effects at all.

Grab the full HTML, CSS, and JS — or export it straight to React, Vue, Angular, or Tailwind — at Lottie Scroll Scrub on FWD Tools. It's free, runs entirely in your browser, and needs no sign-up. Browse more scroll-driven effects like this one at FWD Tools UI Snippets — Scroll Effects.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer, tech writer, and blogger who shares tutorials, technology news, and practical resources for developers. He is also the founder of FWD Tools.

Post a Comment