Lottie Scroll Animation (Scroll-Scrubbed Full-Screen Lottie Hero)
Goal
Build a cinematic, full-viewport hero whose background is a Lottie animation scrubbed frame-by-frame by scroll position. A fixed, full-screen Lottie (a movie exported to a JSON of embedded frames) sits behind the page; a GSAP ScrollTrigger with scrub tweens a virtual playhead from frame 0 to the Lottie's last frame, calling goToAndStop on every update, so the clip plays forward as you scroll down and reverses as you scroll up — never autoplaying on its own. A tall gradient section wipes the fixed animation to black, and normal black website content scrolls up over it. A slowly-jittering film-grain overlay and a saturate(2) filter give it a warm, analog, cinematic finish.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lottie-web for the Lottie player. No Lenis, no other plugins.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import lottie from "lottie-web";
gsap.registerPlugin(ScrollTrigger);
Everything runs inside a DOMContentLoaded listener. There is exactly one tween (a gsap.to on a playhead object) whose motion is entirely scroll-driven — the Lottie is loaded with autoplay:false, loop:false, so nothing moves except via scroll.
Layout / HTML
<body>
<nav>
<div class="logo"><a href="#">Motionprompts<sup>©</sup></a></div>
<div class="links">
<a href="#">Home</a><a href="#">Work</a><a href="#">Expertise</a>
<a href="#">Agency</a><a href="#">Jobs</a><a href="#">Contact</a>
</div>
</nav>
<section class="lottie-container">
<div class="animation"></div> <!-- the Lottie mounts here; JS injects the SVG -->
</section>
<section class="gradient"></section> <!-- transparent→black wipe over the fixed Lottie -->
<section class="website-content">
<div class="end-lottie"></div> <!-- invisible 1px marker = ScrollTrigger endTrigger -->
<h1>Your website content goes here</h1>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Natus magnam est, fuga earum repudiandae aliquid corrupti repellendus nesciunt culpa ipsam possimus cupiditate veritatis minima, ratione itaque…</p>
</section>
<script type="module" src="./script.js"></script>
</body>
The class names .animation, .end-lottie (and the sections .gradient, .website-content) are what the JS/CSS query — keep them exact. .animation is where lottie-web mounts the rendered SVG; .end-lottie is a zero-content marker used only to define where the scroll scrub finishes.
Styling
Font family "PP Neue Montreal" (a tight modern grotesk; if unavailable fall back to a neutral geometric/grotesk sans like Helvetica/Arial). Everything sits on pure black #000, text white #fff.
- Global reset:
* { margin:0; padding:0; box-sizing:border-box; }. body { width:100%; height:500vh; background:#000; font-family:"PP Neue Montreal"; }— the page is 5 viewport-heights tall; that scroll length is the scrub track.h1 { font-size:80px; font-weight:500; letter-spacing:-0.02em; margin:0.25em 0; }.p { width:80%; font-size:24px; font-weight:400; line-height:175%; }.nav:position:fixed; top:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:flex-start; z-index:2;..links { display:flex; align-items:center; gap:2em; }.nav a { text-decoration:none; color:#fff; font-weight:500; }..logo a { font-size:28px; }, itssupisposition:relative; top:-2px; font-size:14px;..lottie-container .animation(the Lottie stage):position:fixed; top:0; width:100vw; height:100vh; z-index:-1; filter:saturate(2);— pinned behind everything, full-screen, colors boosted. Because it is fixed withz-index:-1, it never moves; only its frames change.- Grain overlay —
.animation:after:content:""; background-image:url("noise.png"); position:fixed; top:0; left:0; width:200%; height:200%; opacity:0.1; animation:animateGrain 8s steps(10) infinite;. TheanimateGrainkeyframes jittertransform:translate()in stepped hops between roughly(-5%,-10%)and(-15%,-20%)across 0→100% (10 stepped stops), giving a restless film-grain shimmer. .gradient:position:relative; width:100vw; height:200vh; z-index:1; background:linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%);— 2 viewport-heights tall; transparent at its top (Lottie shows through) fading to solid black at its bottom, so scrolling gradually curtains the fixed animation to black..website-content:position:relative; width:100%; height:300vh; padding:2em; background:#000; color:#fff; z-index:1;— 3 viewport-heights of normal black content that scrolls up over the (now-hidden) Lottie..end-lottie:position:absolute; top:100vh; width:100%; height:1px;— a 1px-tall invisible line one viewport down inside.website-content; it exists purely as the ScrollTriggerendTrigger.
Layout math: .gradient (200vh) + .website-content (300vh) = 500vh of flow, matching body. .animation and .lottie-container are out of normal flow (fixed).
GSAP effect (the important part — be exhaustive)
The whole thing is a small helper, LottieScrollTrigger(vars), called once on DOM ready. It loads the Lottie, then binds a scrub tween to a fake playhead.
1. The call site (exact config)
Inside document.addEventListener("DOMContentLoaded", …):
LottieScrollTrigger({
trigger: ".animation",
start: "top center",
endTrigger: ".end-lottie",
end: `bottom center+=${document.querySelector(".animation").offsetHeight}`,
renderer: "svg",
target: ".animation",
path: "hero-lottie.json", // the movie-to-lottie JSON
scrub: 2,
});
Note end is built from a template literal: .animation's offsetHeight is one viewport height (100vh), so the end resolves to "bottom center+=<innerHeight>" — i.e. the scrub completes when the bottom of .end-lottie reaches viewport-center plus one extra viewport. The scrub therefore runs across roughly the first ~300vh of scroll (all of .gradient plus the first third of .website-content), then holds on the last frame.
2. The helper (LottieScrollTrigger) — reproduce faithfully
function LottieScrollTrigger(vars) {
let playhead = { frame: 0 },
target = gsap.utils.toArray(vars.target)[0],
speeds = { slow: "+=2000", medium: "+=1000", fast: "+=500" },
st = { // ScrollTrigger defaults, overridden by vars below
trigger: ".trigger",
end: speeds[vars.speed] || "+=1000",
scrub: 1,
markers: false,
},
ctx = gsap.context && gsap.context(),
animation = lottie.loadAnimation({
container: target,
renderer: vars.renderer || "svg",
loop: false,
autoplay: false,
path: vars.path,
rendererSettings: vars.rendererSettings || {
preserveAspectRatio: "xMidYMid slice", // cover-fit, centered, cropped
},
});
for (let p in vars) { st[p] = vars[p]; } // merge caller vars over the defaults
animation.addEventListener("DOMLoaded", function () {
let createTween = function () {
animation.frameTween = gsap.to(playhead, {
frame: animation.totalFrames - 1, // 0 → last frame (this Lottie: 0 → 99)
ease: "none", // strictly linear; scroll = time
onUpdate: () => animation.goToAndStop(playhead.frame, true), // true = value is a FRAME
scrollTrigger: st, // the whole scrub config
});
return () => animation.destroy && animation.destroy();
};
ctx && ctx.add ? ctx.add(createTween) : createTween();
});
return animation;
}
3. Mechanics that must be exact
- Single tween on a playhead object.
playhead = { frame: 0 }is a plain JS object; GSAP tweens itsframefrom0toanimation.totalFrames - 1. This Lottie has 100 frames (in-point 0, out-point 100, 25 fps), so the playhead runs 0 → 99. onUpdatedrives the Lottie manually. Every tick callsanimation.goToAndStop(playhead.frame, true). The second argtruetells lottie-web the number is a frame index, not milliseconds — this is what makes the scrub land on discrete frames.ease: "none"— the frame maps linearly to scroll; all smoothing comes fromscrub, not easing.scrub: 2(from the caller, overriding the defaultscrub:1) — ScrollTrigger takes 2 seconds to catch the playhead up to the scroll position, giving a heavy, floaty, filmic lag. Reversing scroll runs the Lottie backward with the same 2s damping. Do not usescrub:true(instant) — the lag is the signature.- Load flags
loop:false, autoplay:false— the animation is inert until the scrub touches it; there is no idle playback. preserveAspectRatio:"xMidYMid slice"— the SVG covers the full 100vw×100vh stage (center-cropped likeobject-fit:cover), so the 16:9 frames fill any viewport.DOMLoadedgate — the tween is only created after lottie-web has parsed and mounted the SVG (animation.totalFramesisn't known before then). Wrap ingsap.context()when available so it can be reverted cleanly.- The
speeds/st.trigger:".trigger"/markers:falsebits are generic defaults inside the helper; the caller'svars(above) overridetrigger,start,end,endTrigger,scrub, etc., so the effective ScrollTrigger is:trigger:".animation",start:"top center",endTrigger:".end-lottie",end:"bottom center+=<innerHeight>",scrub:2.
4. Net visual behavior
As you scroll from the top: the fixed full-screen Lottie plays forward (the model sweeps the fan across her face) while the .gradient section simultaneously fades a black curtain over it; by the time the black gradient is opaque the Lottie is fully hidden and the black .website-content (headline + paragraph) scrolls up in its place. Scroll back up and everything reverses — the Lottie rewinds frame-by-frame, always 2 seconds behind your input.
Assets / images
- 1 Lottie JSON (
hero-lottie.json) — a "movie-to-lottie" export: 100 layers, each an embedded 1920×1080 JPEG frame (fr_0.jpg…fr_99.jpg), 25 fps, 100 frames, 16:9. It is effectively a short video baked into a Lottie. Content: a cinematic studio portrait clip — a model in a coral/pink floral-print kimono with dark contrast sleeve linings holds a round red paper hand-fan (uchiwa) and slowly sweeps it down across her face, from raised beside her head to fully covering her face, in front of a large glowing warm-yellow spotlight circle on a neutral warm-grey seamless backdrop. Slow, elegant, continuous motion so adjacent frames differ only slightly and the scrub reads as smooth video. Any such short, slow, warm-toned studio clip exported to a frames-based Lottie works; thesaturate(2)CSS filter intensifies the reds/yellows. - 1 film-grain texture (
noise.png) — a seamless grayscale monochrome noise/grain tile, tiled at 200% and animated as the overlay (opacity:0.1). Any fine photographic grain PNG works.
No real brand names — the nav wordmark is the neutral demo brand "Motionprompts©".
Behavior notes
- Reversible & scrub-only: the Lottie never autoplays; it only moves with scroll and rewinds on scroll-up, always ~2s damped.
- Lightweight & mobile-safe: SVG renderer, one tween, no WebGL/canvas. Works down to mobile; the fixed stage uses
100vh(swap to100svhif you want to avoid mobile browser-chrome clipping). - No SplitText, no CustomEase, no lerp loop, no Three.js, no pinning — the only "easing" is ScrollTrigger's
scrub:2catch-up plus the linear frame mapping. - The grain overlay and
saturate(2)are pure CSS and run independently of scroll. - No explicit reduced-motion branch in the original; if desired, gate the ScrollTrigger creation behind
matchMedia("(prefers-reduced-motion: no-preference)").