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 207-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 207-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 preloading (gate the ScrollTrigger on it)
  • const frameCount = 207; frames live in a frames/ folder named frame_0001.jpgframe_0207.jpg (index + 1, zero-padded to 4 digits).
  • Create all 207 Image objects up front, push them into an images array, and count them down with a shared handler bound to both onload and onerror. When the counter hits zero, call render() once and only then create the ScrollTrigger — no scroll animation exists until every frame has settled.
  • Keep the current frame in an object: let videoFrames = { frame: 0 };.
3. render() — manual cover-fit draw

Each call clears the canvas (clearRect over innerWidth × innerHeight — CSS pixels, since the context is scaled) and draws images[videoFrames.frame] (only if img.complete && img.naturalWidth > 0) with aspect-ratio 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 207-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

  • 207-frame JPG image sequence, 16:9 landscape (e.g. 3840×2160) in frames/frame_0001.jpgframe_0207.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.
  • 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).
  • Frames only advance after all 207 images finish loading; before that the canvas is blank and the ScrollTrigger doesn't exist yet.
  • 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.