All components

Vucko Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A tiny centered video preview grows from a 0.25 thumbnail to full scale as the intro section scrolls into view, driven by a GSAP ScrollTrigger scrub timeline (start top bottom, end top 10%) whose onUpdate interpolates translateY, scale, gap and a two-phase title font-size. A requestAnimationFrame loop applies the transforms and adds an eased mouse-follow horizontal parallax, all under Lenis smooth scroll.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safedesktop-first
Scrollhijacks scrolling

scroll scrub video-reveal scale mouse-parallax lenis showreel editorial minimal

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

Scroll-Grow Showreel Video Reveal (Scrub + Mouse Parallax)

Goal

Build a minimal editorial landing page where a tiny video-preview thumbnail (scaled to 0.25, pulled up above its section) grows into a full-width 16:9 showreel as the intro section scrolls into view. A GSAP ScrollTrigger scrub timeline drives nothing directly — its onUpdate only interpolates values into a shared state object (translateY, scale, column gap, and a two-phase title font-size), and a requestAnimationFrame loop applies them as a transform string, adding an eased mouse-follow horizontal parallax that fades out as the video reaches full scale. Scroll is smoothed with Lenis. Desktop-only effect.

Tech

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

import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

Everything runs inside a DOMContentLoaded listener, and the entire script body is wrapped in if (window.innerWidth >= 900) { ... } — below 900px no Lenis, no ScrollTrigger, no rAF loop at all. 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 tweens and no eases anywhere — the ScrollTrigger onUpdate computes linear interpolations with gsap.utils.interpolate, and a rAF loop writes inline styles.

Layout / HTML

<body>
  <nav>
    <div class="logo"><a href="#">Motionprompts</a></div>
    <div class="links">
      <a href="#">Home</a><a href="#">About</a><a href="#">Videos</a><a href="#">Contact</a>
    </div>
  </nav>

  <section class="hero">
    <h1>Motionprompts</h1>
    <div class="hero-copy">
      <p>One subscription, endless web design.</p>
      <p>(Scroll)</p>
    </div>
  </section>

  <section class="intro">
    <div class="video-container-desktop">
      <div class="video-preview">
        <div class="video-wrapper">
          <img class="showreel-img" src="(showreel)" alt="PRO Showreel" />
        </div>
      </div>
      <div class="video-title">
        <p>PRO Showreel</p>
        <p>2023 - 2024</p>
      </div>
    </div>

    <div class="video-container-mobile">
      <!-- exact same inner markup: .video-preview > .video-wrapper > img, then .video-title with the two <p> -->
    </div>
  </section>

  <section class="outro">
    <p>Delve into coding without clutter.</p>
  </section>
  <script type="module" src="./script.js"></script>
</body>

The JS queries .intro, .video-container-desktop, and .video-title p — keep those class names exact. Note the duplicated video block: .video-container-desktop (animated) and .video-container-mobile (static fallback, hidden on desktop).

Styling

Font: "PP Neue Montreal" loaded via @import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");. Palette: text #1a1a1a on warm paper #e3e3db; the video placeholder plate is #b9b9b3; nav links are #fff (inverted by blend mode).

  • Global reset * { margin:0; padding:0; box-sizing:border-box; }; body { font-family:"PP Neue Montreal"; color:#1a1a1a; background:#e3e3db; overflow-x:hidden; }.
  • Base type: h1 { font-size:60px; font-weight:500; }, p { font-size:20px; font-weight:500; }, a { text-decoration:none; color:#fff; font-size:20px; font-weight:500; }.
  • nav: position:fixed; top:0; left:0; width:100vw; padding:2em 2.5em; display:flex; justify-content:space-between; mix-blend-mode:difference; z-index:2; — the white links invert against the light background. .links { display:flex; gap:1em; }.
  • Every section: width:100vw; height:100svh; padding:2.5em;.
  • .hero: flex column, justify-content:space-between, padding-top:4em. Its h1: position:relative; left:-0.05em; text-transform:uppercase; font-weight:500; font-size:20vw; letter-spacing:-0.04em; line-height:1; — a giant single-line wordmark. .hero-copy { display:flex; justify-content:space-between; align-items:flex-end; } (tagline left, "(Scroll)" right).
  • .intro { height:100%; } (overrides the 100svh).
  • .video-container-desktop: position:relative; display:flex; flex-direction:column; gap:2em; will-change:transform; with initial state in CSS: transform: translateY(-105%) scale(0.25); — before JS runs it must already sit pulled up above the intro (overlapping the hero as a small centered thumbnail) at quarter scale. Its .video-title p { position:relative; font-size:78px; font-weight:500; } (the JS immediately takes over the font-size).
  • .video-container-mobile: display:none; width:100%; max-width:800px; margin:0 auto;.
  • .video-preview: position:relative; width:100%; aspect-ratio:16/9; border-radius:1.5rem; background:#b9b9b3; overflow:hidden;. .video-wrapper: absolutely positioned full-bleed inside it, same border-radius:1.5rem; overflow:hidden;. The img: width:100%; height:100%; object-fit:cover; display:block;.
  • @media (max-width:900px): nav, section { padding:1.5em; }; .hero { justify-content:flex-end; gap:2em; }; .hero h1 { font-size:19vw; }; .video-container-desktop { display:none; }; .video-container-mobile { display:flex; flex-direction:column; gap:1em; }.

GSAP effect (the important part — be exhaustive)

1. Responsive initial values (breakpoints table)

The starting translateY (in % of the container's own height) and the mouse-parallax strength depend on viewport width. Check in order, first match wins:

const breakpoints = [
  { maxWidth: 1000, translateY: -135, movMultiplier: 450 },
  { maxWidth: 1100, translateY: -130, movMultiplier: 500 },
  { maxWidth: 1200, translateY: -125, movMultiplier: 550 },
  { maxWidth: 1300, translateY: -120, movMultiplier: 600 },
];
// width > 1300 → { translateY: -105, movementMultiplier: 650 }

A getInitialValues() helper returns { translateY, movementMultiplier } for the current window.innerWidth.

2. Shared animation state

All animated values live in one plain object (no GSAP targets):

const animationState = {
  scrollProgress: 0,
  initialTranslateY: initialValues.translateY,
  currentTranslateY: initialValues.translateY,
  movementMultiplier: initialValues.movementMultiplier,
  scale: 0.25,
  fontSize: 80,
  gap: 2,
  targetMouseX: 0,
  currentMouseX: 0,
};

On resize: recompute getInitialValues(), overwrite initialTranslateY and movementMultiplier, and only reset currentTranslateY if scrollProgress === 0 (don't snap mid-scroll).

3. The ScrollTrigger (scrub, no pin)

One gsap.timeline({ scrollTrigger: { ... } }) with an empty timeline — the ScrollTrigger exists purely for its onUpdate:

scrollTrigger: {
  trigger: ".intro",
  start: "top bottom",   // fires as soon as the intro enters the viewport
  end: "top 10%",        // completes when the intro's top reaches 10% from the viewport top
  scrub: true,
  onUpdate: (self) => { ... },
}

No pinning, no pinSpacing. In onUpdate, set animationState.scrollProgress = self.progress and interpolate linearly with gsap.utils.interpolate:

  • currentTranslateY: initialTranslateY → 0 (% — the container slides down from above into its natural place).
  • scale: 0.25 → 1.
  • gap: 2 → 1 (em — the gap between video and title tightens as it grows).
  • fontSize is two-phase:
  • progress ≤ 0.4: firstPartProgress = progress / 0.4; fontSize 80 → 40.
  • progress > 0.4: secondPartProgress = (progress − 0.4) / 0.6; fontSize 40 → 20.
  • (Fast shrink early, slow settle late — at full scale the title reads as a normal 20px caption.)
4. Mouse tracking
document.addEventListener("mousemove", (e) => {
  animationState.targetMouseX = (e.clientX / window.innerWidth - 0.5) * 2; // −1 … 1
});
5. The rAF apply loop (this is what actually moves pixels)

An animate() function called via requestAnimationFrame(animate) forever (kick it off once). It bails with return (stopping the loop) if window.innerWidth < 900. Each frame:

const scaledMovementMultiplier = (1 - scale) * movementMultiplier;
const maxHorizontalMovement = scale < 0.95 ? targetMouseX * scaledMovementMultiplier : 0;
animationState.currentMouseX = gsap.utils.interpolate(
  currentMouseX, maxHorizontalMovement, 0.05   // lerp factor 0.05 → soft trailing ease
);
videoContainer.style.transform =
  `translateY(${currentTranslateY}%) translateX(${animationState.currentMouseX}px) scale(${scale})`;
videoContainer.style.gap = `${gap}em`;
videoTitleElements.forEach((el) => { el.style.fontSize = `${fontSize}px`; });

Key behaviors this produces:

  • Parallax strength is proportional to how small the video is: (1 − scale) × movementMultiplier px of max travel at each side, so the tiny thumbnail swings widely (up to ±~487px at scale 0.25 on wide screens) and the movement dies to nothing as it grows.
  • Hard gate at scale ≥ 0.95: the target snaps to 0, and the 0.05 lerp glides the video back to center as it reaches full size.
  • The transform order must be exactly translateY(%) translateX(px) scale().
  • Everything is fully reversible — scrolling back up shrinks the video back into the floating thumbnail.

Assets / images

  • 1 landscape 16:9 image used as the showreel poster inside the video preview: a cinematic, moody motion-blurred human figure — dark, atmospheric, long-exposure feel, like a frame from a motion-design reel. It fills the rounded 16:9 plate with object-fit:cover. (Same image in both the desktop and mobile containers.)

Use the neutral brand name "Motionprompts" — no real studio or client names.

Behavior notes

  • Desktop-only animation: below 900px viewport width nothing initializes (no Lenis, no ScrollTrigger, no parallax); the CSS media query swaps in the static .video-container-mobile block instead.
  • The initial translateY(-105%) scale(0.25) in CSS matches the JS default for >1300px, so there's no first-frame jump on wide screens; on narrower desktops the JS breakpoint value takes over on the first rAF tick.
  • The scrub has no smoothing of its own (scrub: true, not a number) — the floaty feel comes from Lenis inertia plus the 0.05 mouse lerp.
  • No SplitText, no CustomEase, no Three.js, no pinning, no reduced-motion branch in the original.