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. Itsh1: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 the100svh)..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, sameborder-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).fontSizeis two-phase:progress ≤ 0.4:firstPartProgress = progress / 0.4; fontSize80 → 40.progress > 0.4:secondPartProgress = (progress − 0.4) / 0.6; fontSize40 → 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) × movementMultiplierpx 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-mobileblock 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.