All components

Adaline Scroll Animation

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A pinned hero where a 104-frame image sequence is played back frame-by-frame on a <canvas> driven by scroll. A single ScrollTrigger (pin + scrub over 7x viewport height, Lenis-smoothed) maps scroll progress to the frame index while its onUpdate also fades the nav out, pushes the headline back on the Z axis and fades it, then flies a framed photo in from translateZ(1000px) to 0 near the end.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

scroll canvas image-sequence frame-by-frame pin scrub 3d-perspective lenis cinematic

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Adaline-Style Scroll Animation (Canvas Image-Sequence Hero)

Goal

Build a cinematic landing hero where a 104-frame image sequence plays back frame-by-frame on a full-viewport <canvas>, driven entirely by scroll. The hero section is pinned for 7 viewport-heights of scrolling; as the user scrolls, a single ScrollTrigger's onUpdate maps progress to (1) the current video frame drawn on canvas, (2) a fade-out of the fixed nav, (3) the headline block being pushed away on the Z axis (translateZ 0 → −500px) while fading, and (4) a product-dashboard mockup that flies in from deep 3D space (translateZ(1000px)0) and lands centered as the sequence ends. A plain outro section follows. Scroll is smoothed with Lenis.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scrolling:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

Everything runs inside a DOMContentLoaded listener. Register ScrollTrigger, then wire Lenis the standard way:

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

No timelines and no tweens — the whole effect is one ScrollTrigger.create() whose onUpdate writes absolute states with gsap.set plus manual canvas drawing.

Layout / HTML

<body>
  <nav>
    <div class="nav-links">
      <a href="#">Overview</a><a href="#">Solutions</a><a href="#">Resources</a>
    </div>
    <div class="logo"><a href="#"><img src="(logo)" alt=""> Byewind</a></div>
    <div class="nav-buttons">
      <div class="btn primary"><a href="#">Live Demo</a></div>
      <div class="btn secondary"><a href="#">Get Started</a></div>
    </div>
  </nav>

  <section class="hero">
    <canvas></canvas>
    <div class="hero-content">
      <div class="header">
        <h1>One unified workspace to build, test, and ship AI faster</h1>
        <p>Trusted by</p>
        <div class="client-logos">
          <div class="client-logo"><img src="(client-logo-1)" alt=""></div>
          <div class="client-logo"><img src="(client-logo-2)" alt=""></div>
          <div class="client-logo"><img src="(client-logo-3)" alt=""></div>
          <div class="client-logo"><img src="(client-logo-4)" alt=""></div>
        </div>
      </div>
    </div>
    <div class="hero-img-container">
      <div class="hero-img"><img src="(dashboard)" alt=""></div>
    </div>
  </section>

  <section class="outro">
    <h1>Join teams building faster with Byewind.</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>

The class names .hero, .header, .hero-img, plus the bare nav and canvas elements are what the JS queries — keep them exact.

Styling

Google Fonts: "DM Mono" (300/400/500 + italics) and "Host Grotesk" (variable 300–800). Palette via CSS variables: --fg: #241910 (near-black warm brown), --bg: #fefbf4 (warm off-white cream).

  • Global reset * { margin:0; padding:0; box-sizing:border-box; }. body { font-family:"DM Mono", monospace; }. img { width:100%; height:100%; object-fit:cover; }.
  • h1 { font-family:"Host Grotesk"; font-size:3rem; font-weight:400; line-height:1.1; }.
  • p and a: text-transform:uppercase; font-size:0.8rem; font-weight:500; — links color:var(--fg); text-decoration:none;.
  • .btn { padding:0.75rem 1.5rem; border-radius:0.25rem; }.primary has background:var(--bg) with --fg text; .secondary is inverted (background:var(--fg), --bg text).
  • nav: position:fixed; width:100vw; padding:1.5rem 2rem; display:flex; align-items:center; gap:2rem; z-index:2; will-change:opacity; — each of its three direct children gets flex:1 so the logo sits dead-center. .nav-links { display:flex; gap:3rem; }; .logo centers its content, logo img width:2rem, logo text is Host Grotesk 1.5rem with text-transform:none; .nav-buttons { display:flex; gap:1.5rem; justify-content:flex-end; }.
  • Every section: position:relative; width:100vw; height:100svh; overflow:hidden;.
  • .outro: flex-centered text, padding:2rem, background:var(--bg), color:var(--fg).
  • canvas { width:100%; height:100%; object-fit:cover; } — fills the hero.
  • .hero-content: position:absolute; top:25%; left:50%; transform:translateX(-50%); transform-style:preserve-3d; perspective:1000px; padding:0.5rem 0;this perspective is the camera for the headline's Z push; do not omit it.
  • .header: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100vw; flex column, centered, gap:1.5rem; text-align:center; color:var(--fg); transform-origin:center; will-change:transform,opacity;. Its h1 is width:50%; margin-bottom:0.5rem;; its p has opacity:0.35.
  • .client-logos: width:30%; display:flex; gap:0.5rem; — each .client-logo is flex:1, its img object-fit:contain.
  • .hero-img-container: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; transform-style:preserve-3d; perspective:1000px; — the camera for the mockup fly-in.
  • .hero-img: position:relative; width:100%; height:100%; with initial state in CSS: transform:translateZ(1000px); opacity:0; and will-change:transform,opacity; — it must start invisible and deep behind the camera even before JS runs.
  • @media (max-width:1000px): h1 { font-size:2rem; }, hide .nav-links and .nav-buttons, and give .header h1, .client-logos, .hero-img-container a width of calc(100% - 4rem).

GSAP effect (the important part — be exhaustive)

1. Hi-DPI canvas setup
const canvas = document.querySelector("canvas");
const context = canvas.getContext("2d");
const setCanvasSize = () => {
  const pixelRatio = window.devicePixelRatio || 1;
  canvas.width = window.innerWidth * pixelRatio;
  canvas.height = window.innerHeight * pixelRatio;
  canvas.style.width = window.innerWidth + "px";
  canvas.style.height = window.innerHeight + "px";
  context.scale(pixelRatio, pixelRatio);
};
setCanvasSize();
2. Frame loading — a moving window, not a gate
  • const frameCount = 104; frames live in a frames/ folder named frame_0001.jpgframe_0104.jpg (index + 1, zero-padded to 4 digits).
  • Keep the current frame in an object: let videoFrames = { frame: 0 };.
  • Do not create all the Image objects at once, and do not wait for all of them before drawing. The obvious version of this effect — 200-odd new Image() in a loop, a countdown, render() and setupScrollTrigger() only when the counter hits zero — is what makes these hero sequences notorious: the browser opens every connection it has for frames nobody is looking at yet, everything else on the page queues behind them, and the hero stays blank until the *last* frame lands. Load them in order, through a window of six, and start as soon as you have a handful:
const images = new Array(frameCount);
const LOAD_WINDOW = 6;   // concurrent downloads
const START_AFTER = 10;  // frames in hand before the ScrollTrigger is created
let nextFrame = 0, loadedCount = 0, started = false;

const loadNext = () => {
  if (nextFrame >= frameCount) return;
  const i = nextFrame++;
  const img = new Image();
  const settled = () => {                  // bound to onload AND onerror
    loadedCount++;
    if (i === 0) render();                 // first frame paints as soon as it exists
    if (!started && loadedCount >= Math.min(START_AFTER, frameCount)) {
      started = true;
      setupScrollTrigger();
    }
    loadNext();                            // the chain feeds itself
  };
  img.onload = settled;
  img.onerror = settled;
  img.src = currentFrame(i);
  images[i] = img;
};
for (let i = 0; i < LOAD_WINDOW; i++) loadNext();

The scrub can now outrun the download, which is fine as long as render() degrades (next section). onerror must run the same handler as onload or one missing file stalls the whole chain.

3. render() — manual cover-fit draw

Each call clears the canvas (clearRect over innerWidth × innerHeight — CSS pixels, since the context is scaled) and draws the current frame with aspect-ratio cover math.

Because frames arrive progressively, don't reach for images[videoFrames.frame] directly: walk backwards from the target to the last frame that actually loaded (img.complete && img.naturalWidth > 0) and draw that one. A scrub that outruns the download then freezes for an instant on the previous frame instead of flashing an empty canvas.

let img = null;
for (let k = videoFrames.frame; k >= 0; k--) {
  const c = images[k];
  if (c && c.complete && c.naturalWidth > 0) { img = c; break; }
}
if (img) { /* cover math + drawImage */ }

The cover math:

  • imageAspect = naturalWidth / naturalHeight, canvasAspect = innerWidth / innerHeight.
  • If imageAspect > canvasAspect: drawHeight = canvasHeight; drawWidth = drawHeight * imageAspect; drawX = (canvasWidth - drawWidth) / 2; drawY = 0;
  • Else: drawWidth = canvasWidth; drawHeight = drawWidth / imageAspect; drawX = 0; drawY = (canvasHeight - drawHeight) / 2;
  • context.drawImage(img, drawX, drawY, drawWidth, drawHeight).
4. The single ScrollTrigger
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${window.innerHeight * 7}px`,  // 7 viewport-heights of pinned scroll
  pin: true,
  pinSpacing: true,
  scrub: 1,                               // 1s catch-up smoothing
  onUpdate: (self) => { /* everything below */ },
});

All animation lives in onUpdate, reading progress = self.progress (0 → 1 across the whole pinned distance) and writing absolute states with gsap.set — there are no eases or durations anywhere; every ramp is a linear function of progress, and the softness comes from scrub: 1 + Lenis inertia.

Phase A — video scrub (0 → 0.9):

const animationProgress = Math.min(progress / 0.9, 1);
const targetFrame = Math.round(animationProgress * (frameCount - 1));
videoFrames.frame = targetFrame;
render();

The full 104-frame sequence completes at 90% of the pin; the last 10% of scroll holds the final frame.

Phase B — nav fade (0 → 0.1): while progress <= 0.1, opacity = 1 - progress / 0.1 (linear 1 → 0 across the first tenth); past 0.1, hard-set gsap.set(nav, { opacity: 0 }).

Phase C — headline Z push + fade (0 → 0.25): while progress <= 0.25:

  • translateZ = (progress / 0.25) * -500 — the header recedes linearly from 0 to −500px.
  • Opacity stays 1 until progress = 0.2, then fades linearly 1 → 0 over 0.2 → 0.25 (opacity = 1 - (progress - 0.2) / 0.05, clamped).
  • Apply via a full transform string so the centering survives: gsap.set(header, { transform: \translate(-50%, -50%) translateZ(${translateZ}px)\, opacity }).
  • Past 0.25, just gsap.set(header, { opacity: 0 }).

Phase D — dashboard fly-in (0.6 → 0.9): three branches on .hero-img:

  • progress < 0.6: hold the rest state transform: "translateZ(1000px)", opacity: 0.
  • 0.6 ≤ progress ≤ 0.9: imgProgress = (progress - 0.6) / 0.3; translateZ = 1000 - imgProgress * 1000 (1000px → 0, linear). Opacity ramps 0 → 1 faster than the Z travel: opacity = (progress - 0.6) / 0.2 while progress ≤ 0.8, then 1 from 0.8 to 0.9 (so the mockup is fully opaque for the last third of its flight). Set transform: \translateZ(${translateZ}px)\`` and the opacity.
  • progress > 0.9: lock transform: "translateZ(0px)", opacity: 1.

Because the parent containers carry perspective: 1000px, the mockup starting at translateZ(1000px) sits exactly at the camera plane — it materializes huge/at-lens and settles back to its natural 50%-width size, a "flying in from your face" landing.

5. Resize
window.addEventListener("resize", () => { setCanvasSize(); render(); ScrollTrigger.refresh(); });

Assets / images

  • 104-frame JPG image sequence, 16:9 landscape in frames/frame_0001.jpgframe_0104.jpg — consecutive frames of one slow, continuous cinematic camera move (the original is a gentle aerial drift over rolling desert sand dunes under a pale hazy sky: warm sand tones that harmonize with the cream/brown palette). Any smooth ambient footage exported as sequential JPGs works; the key is that adjacent frames differ only slightly so the scrub reads as video.

Export them for the web, not for an editor. This is the single heaviest thing on the page and the easiest place to ship 30 MB by accident: the demo's frames are 1440px wide at JPEG q74, ~40 KB each, 4.1 MB for the whole sequence. Two levers, in this order — halve the frame count before you touch quality (a scrubbed sequence over seven viewport-heights reads as smooth at ~100 frames; 200+ is invisible extra weight), then cap the width at 1440. Exporting at 4K, or at one frame per source frame, is what turns a hero into a 30-second wait.

  • 1 product-dashboard screenshot, landscape ≈3:2 — a clean SaaS analytics/dashboard UI mockup; it is the element that flies in from deep Z.
  • 1 small square logo mark (~2rem display size) for the nav brand.
  • 4 monochrome client wordmark logos, wide landscape, transparent background — the "Trusted by" row, each object-fit:contain in an equal flex column.

Use the neutral brand name "Byewind" — no real company names.

Behavior notes

  • Total scroll distance for the hero is 7 × innerHeight of pin spacing, then the outro section scrolls in normally.
  • The nav is position:fixed and only reappears if the user scrolls back up (its opacity is progress-driven, fully reversible — as is everything, since the whole effect is scrub-based gsap.set writes).
  • The canvas paints as soon as frame 1 lands and the ScrollTrigger appears once ten frames are in; the rest of the sequence keeps loading underneath. Scrolling fast on a cold cache holds the last loaded frame for a moment — that is the intended degradation, not a bug.
  • scrub: 1 plus Lenis gives the floaty, damped feel — don't use scrub: true.
  • Mobile (≤1000px): nav links/buttons hidden, headline/logos/mockup widen to calc(100% - 4rem); the effect itself runs unchanged. Sections use 100svh so mobile browser chrome doesn't clip.
  • No SplitText, no CustomEase, no Three.js, no reduced-motion branch in the original.

Images

This component ships with 106 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/adaline-scroll-animation/caravan.jpg
https://motionprompts.dev/c/adaline-scroll-animation/frames/frame_0001.jpg
https://motionprompts.dev/c/adaline-scroll-animation/frames/frame_0002.jpg
https://motionprompts.dev/c/adaline-scroll-animation/frames/frame_0003.jpg
https://motionprompts.dev/c/adaline-scroll-animation/frames/frame_0004.jpg
https://motionprompts.dev/c/adaline-scroll-animation/frames/frame_0005.jpg
… 100 more under https://motionprompts.dev/c/adaline-scroll-animation/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--paper, --ink, --sienna, --stone, --haze, --dusk. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: a single script that waits for DOMContentLoaded, reaches into the page with document.querySelector for nav, .header, .hero-img and the bare canvas, streams in 104 frame images, wires the one ScrollTrigger that pins .hero for seven viewport-heights and drives the canvas scrub, the nav fade, the headline's Z-push and the dashboard fly-in from a single onUpdate. React withdraws all three of those guarantees — the ready-made document, the free run of an unscoped selector, the license to never tear anything down — and it does it quietly: the hero renders and scrubs correctly on first load, and the damage only shows up on a second mount or a real navigation away.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component accumulates more live state than most before that first unmount can land: a Lenis instance, a gsap.ticker callback pumping it every frame, one pinned ScrollTrigger, a resize listener, and a self-feeding chain of Image loads that keeps requesting frames after the fact. A double mount that doesn't undo all of it leaves two Lenis instances fighting over the same wheel event, a ticker calling into the one you just destroyed, and two ScrollTriggers pinning the same .hero with disagreeing scrub state. None of this reproduces in a production build — React only double-invokes in development — so treat the teardown below as load-bearing, not optional.

*(1) The entry point* — the whole effect is wrapped in document.addEventListener("DOMContentLoaded", ...). By the time a React component mounts, DOMContentLoaded has already fired, so that listener is registered and never called: the canvas never gets a first frame, the images never start loading, no ScrollTrigger ever exists. Delete the listener and move its entire body — plugin registration, the Lenis wiring, setCanvasSize, the frame-loading chain, setupScrollTrigger, and the resize listener — directly into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) can stay at module scope instead; re-registering on every mount is harmless but pointless.

*(2) Element lookups* — document.querySelector("nav"), .header, .hero-img and the bare canvas each assume there is exactly one match in the whole document. Put a root ref on the element that wraps nav, .hero and .outro, and scope all four lookups to it (root.querySelector(".header"), and so on) — or better, put a dedicated ref directly on the <canvas>, since the effect also needs its 2D context, and reserve scoped querySelector calls for nav, .header and .hero-img. During the StrictMode remount two copies of this markup exist for an instant, and an unscoped lookup can bind to the copy that is already on its way out — gsap.set then spends the rest of the scroll writing transform and opacity to a detached node every time onUpdate fires.

*(3) Cleanup* — wrap the effect body in a gsap.context scoped to the root ref and revert it on cleanup:

useEffect(() => {
  const ctx = gsap.context(() => {
    // Lenis setup, canvas sizing, image preload, setupScrollTrigger, resize listener
  }, rootRef);
  return () => ctx.revert();
}, []);

The pin is what makes this matter here specifically: ScrollTrigger.create({ pin: true, pinSpacing: true, ... }) inserts a pin-spacer element around .hero and rewrites .hero's own inline styles for the length of the seven-viewport-height scroll. ctx.revert() is what removes that spacer and restores the original styles — skip it and a remount leaves an orphaned spacer in the DOM plus a second pinned trigger disagreeing with the first about the current scrub frame.

ctx.revert() does not reach gsap.ticker.add((time) => lenis.raf(time * 1000)) — a ticker subscription is neither a tween nor a trigger, so the context never records it, and this is exactly the case the ticker matters most: that callback is what drives Lenis's own frame loop. Keep the function reference and remove it explicitly, before destroying Lenis:

const onTick = (time) => lenis.raf(time * 1000);
gsap.ticker.add(onTick);
// cleanup, in this order:
gsap.ticker.remove(onTick);
lenis.destroy();
ctx.revert();

Get the order wrong — destroy lenis while onTick is still registered — and any ticker frame that lands between the two calls invokes .raf() on an instance that no longer exists.

Lenis itself is a document-level resource, not this section's alone. If this hero ships as one section of a larger app, lift the new Lenis() call to the app shell and have this effect subscribe lenis.on("scroll", ScrollTrigger.update) on the shared instance instead of constructing a second one. If the hero genuinely owns scroll for the whole page, construct it inside the effect as shown and destroy it in the cleanup above.

The other thing this effect can't finish synchronously is the frame loading: the chain keeps creating Image objects until all 104 are in, and setupScrollTrigger() fires from inside one of those callbacks. That whole sequence is real elapsed time — seconds on a slow network — and a StrictMode unmount, or a genuine navigation away, lands squarely inside it. Guard the chain with a cancellation flag the same way you would an unresolved promise, and check it in both places: before creating the trigger, and before queueing the next download.

let cancelled = false;
// first line of the shared `settled` handler:
if (cancelled) return;   // no draw, no trigger, no next request
// cleanup:
return () => {
  cancelled = true;
  gsap.ticker.remove(onTick);
  lenis.destroy();
  ctx.revert();
};

Without the flag the chain outlives the component: it keeps pulling frames for a canvas nobody will see, and calls setupScrollTrigger() against a .hero that may already be gone — or, if this was only the StrictMode remount and the node is still there, they pin it a second time, stacked on top of the trigger the live effect's own call to setupScrollTrigger() is about to create.

Finally, window.addEventListener("resize", ...) is registered with an inline arrow function, so there is nothing to hand removeEventListener unless you name it first. Declare it inside the same effect and remove it in the same cleanup — it calls setCanvasSize(), render() and ScrollTrigger.refresh(), all three of which are meaningless once the canvas ref and the trigger are gone.