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; }.panda:text-transform:uppercase; font-size:0.8rem; font-weight:500;— linkscolor:var(--fg); text-decoration:none;..btn { padding:0.75rem 1.5rem; border-radius:0.25rem; }—.primaryhasbackground:var(--bg)with--fgtext;.secondaryis inverted (background:var(--fg),--bgtext).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 getsflex:1so the logo sits dead-center..nav-links { display:flex; gap:3rem; };.logocenters its content, logo imgwidth:2rem, logo text is Host Grotesk1.5remwithtext-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;— thisperspectiveis 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;. Itsh1iswidth:50%; margin-bottom:0.5rem;; itsphasopacity:0.35..client-logos:width:30%; display:flex; gap:0.5rem;— each.client-logoisflex:1, its imgobject-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;andwill-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-linksand.nav-buttons, and give.header h1,.client-logos,.hero-img-containera width ofcalc(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 aframes/folder namedframe_0001.jpg…frame_0207.jpg(index + 1, zero-padded to 4 digits).- Create all 207
Imageobjects up front, push them into animagesarray, and count them down with a shared handler bound to bothonloadandonerror. When the counter hits zero, callrender()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
1untilprogress = 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 statetransform: "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.2whileprogress ≤ 0.8, then1from 0.8 to 0.9 (so the mockup is fully opaque for the last third of its flight). Settransform: \translateZ(${translateZ}px)\`` and the opacity.progress > 0.9: locktransform: "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.jpg…frame_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:containin an equal flex column.
Use the neutral brand name "Byewind" — no real company names.
Behavior notes
- Total scroll distance for the hero is
7 × innerHeightof pin spacing, then the outro section scrolls in normally. - The nav is
position:fixedand only reappears if the user scrolls back up (its opacity is progress-driven, fully reversible — as is everything, since the whole effect is scrub-basedgsap.setwrites). - Frames only advance after all 207 images finish loading; before that the canvas is blank and the ScrollTrigger doesn't exist yet.
scrub: 1plus Lenis gives the floaty, damped feel — don't usescrub: true.- Mobile (≤1000px): nav links/buttons hidden, headline/logos/mockup widen to
calc(100% - 4rem); the effect itself runs unchanged. Sections use100svhso mobile browser chrome doesn't clip. - No SplitText, no CustomEase, no Three.js, no reduced-motion branch in the original.