Scroll-Cinematic Experience
A two-phase scroll narrative. Phase 1: video hero with overlaid UI. Phase 2: a
black panel slides up over the video and holds a scattered image grid whose
cards scale in as they enter and out as they leave. An outro fades a white
overlay + "view" CTA at the end. Everything is driven by a RAF loop reading
scrollY, not by scroll events — that's what keeps it buttery.
Before building
Read /mnt/skills/public/frontend-design/SKILL.md. This is complex — confirm
the user actually wants scroll-driven behavior (not a static hero) before
committing. Collect: hero VIDEO_URL(s), a set of GALLERY_IMAGES (8–12),
BRAND wordmark, a caption, product/collection labels + price, and the outro
CTA word (e.g. "view"). Palette: black + white, overlays use mix-blend-mode: exclusion so text stays legible over both.
Stack
React 19 + TypeScript + Vite, Tailwind (v4 via @tailwindcss/vite is fine),
gsap + @gsap/react (ScrollTrigger), motion (motion/react). Fonts: a
tight grotesk like "Inter Tight". Single-page, one big component tree.
Structure & phases
Root #scroll-spacer: position: relative, user-select: none, tall height
set dynamically to vh + maxScroll + 2*vh (via GSAP after measuring the
gallery), cursor: none on desktop.
- Hero layer (
position: fixed, inset: 0, z-0): the video container. Entrance-animate the overlaid UI with Motion (logo0s, nav0.15s, caption0.3s, product info0.45s;opacity 0→1, y 12→0, ease[0.25,0.1,0.25,1]). All overlays arepointer-events-none+mix-blend-mode: exclusion. - Black panel (
position: fixed, inset: 0, bg-black, z-10): startstranslateY(100vh). A GSAP ScrollTrigger (scrub: true) slides it totranslateY(0)over the first100vhof scroll. - Gallery inside the panel: a responsive grid (2 / 3 / 4 cols) built by a
layout function that scatters images (one per row at a computed column, a
second every 3rd row). Each
.bp-cardisscale(0)with a bottom transform-origin (left-half cards originright bottom, right-halfleft bottom).
The RAF card-scale loop (core mechanic)
Do NOT use scroll events. Each frame, read scrollY and set each card's scale
from its viewport position:
const vh = window.innerHeight;
// enter: grows 0→1 as the card rises into view
const enter = Math.min(1, (vh - top) / (vh * 0.6));
// exit: shrinks 1→0 as it leaves the top
const exit = Math.min(1, bottom / (vh * 0.4));
const scale = (bottom <= 0 || top >= vh) ? 0 : Math.min(enter, exit);
card.style.transform = `scale(${scale})`;
Phase 1 (scrollY < vh): compute top/bottom with a panel offset of
vh - scrollY. Phase 2 (scrollY > vh): pin the panel, translate the inner
wrapper up by -(scrollY - vh). Outro (scrollY > vh + maxScroll): fade a
white overlay in, slide the product info up, scale the "view" pill from 0→1,
fade the footer in; progress = (scrollY - vh - maxScroll) / (vh - 100).
Custom cursor & optional video scrubbing
Desktop cursor: a fixed pointer-events-none z-50 element positioned by
mousemove (direct style.left/top), mix-blend-mode: exclusion, containing a
small SVG glyph. Optional dual-video interaction: scrub two <video>s by cursor
X with a center dead zone (Math.max(30, width*0.05)), only setting
currentTime when !video.seeking to avoid jitter; on touch, auto-alternate
the two clips on ended.
CSS
.bp-card { will-change: transform; }
@media (prefers-reduced-motion: reduce) { .bp-card { will-change: auto; } }
Responsive
Breakpoints 640 / 1024. Custom cursor desktop-only; touch gets default cursor + autoplaying video. Grid columns step 2→3→4. Use RAF position tracking throughout; never rely on native scroll snapping.
Quality checklist
- Panel slides up over the video during the first viewport of scroll.
- Cards scale in on enter and out on exit, computed per-frame in RAF.
- Outro overlay + CTA appear at the end; footer fades in.
- Overlays use
mix-blend-mode: exclusionandpointer-events-none. - Works with reduced-motion and on touch (no custom cursor there).
- Spacer height set so all phases have room to play out.