Turn a video into a premium scroll-driven animated website. Combines bold frontend design aesthetics with full GSAP/Lenis/canvas animation choreography, circle-wipe hero reveal, staggered section entrances, marquee text, and counter animations.
Turn a video file into a premium scroll-driven animated website — combining bold design direction with full animation choreography. This skill merges the aesthetic discipline of frontend-design with the technical precision of video-to-website.
The user provides: a video file path (MP4, MOV, etc.) and optionally:
If the user doesn't specify these, ask briefly or apply sensible creative defaults.
Before touching any code, commit to a BOLD aesthetic direction.
001 / Features--bg-light, --bg-dark, --bg-accent--text-on-light, --text-on-darktext-shadow if needed, not boxesFFmpeg and FFprobe must be available on PATH.
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,duration,r_frame_rate,nb_frames \
-of csv=p=0 "<VIDEO_PATH>"
Determine resolution, duration, frame rate, total frames. Decide:
mkdir -p frames
ffmpeg -i "<VIDEO_PATH>" \
-vf "fps=<CALCULATED_FPS>,scale=<WIDTH>:-1" \
-c:v libwebp -quality 80 \
"frames/frame_%04d.webp"
After extraction, count frames: ls frames/ | wc -l
project-root/
index.html
css/style.css
js/app.js
frames/frame_0001.webp ...
No bundler. Vanilla HTML/CSS/JS + CDN libraries only.
index.htmlRequired structure (in this order):
<!-- 1. Loader: #loader > .loader-brand, #loader-bar, #loader-percent -->
<!-- 2. Fixed header: .site-header > nav with logo + links -->
<!-- 3. Hero: .hero-standalone (100vh, solid bg, word-split heading) -->
<!-- Contains: .section-label, .hero-heading (words in spans), .hero-tagline -->
<!-- Scroll indicator with arrow -->
<!-- 4. Canvas: .canvas-wrap > canvas#canvas (fixed, full viewport) -->
<!-- 5. Dark overlay: #dark-overlay (fixed, full viewport, pointer-events:none) -->
<!-- 6. Marquee(s): .marquee-wrap > .marquee-text (fixed, 12vw font) -->
<!-- 7. Scroll container: #scroll-container (800vh+) -->
<!-- Content sections with data-enter, data-leave, data-animation -->
<!-- Stats section with .stat-number[data-value][data-decimals] -->
<!-- CTA section with data-persist="true" -->
Content section example:
<section class="scroll-section section-content align-left"
data-enter="22" data-leave="38" data-animation="slide-left">
<div class="section-inner">
<span class="section-label">002 / Feature</span>
<h2 class="section-heading">Feature Headline</h2>
<p class="section-body">Description text here.</p>
</div>
</section>
Stats section example:
<section class="scroll-section section-stats"
data-enter="54" data-leave="72" data-animation="stagger-up">
<div class="stats-grid">
<div class="stat">
<span class="stat-number" data-value="24" data-decimals="0">0</span>
<span class="stat-suffix">hrs</span>
<span class="stat-label">Cold retention</span>
</div>
</div>
</section>
CDN scripts (end of body, this order):
<script src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script src="js/app.js"></script>
css/style.cssUse the design direction from Phase 1. Key technical patterns:
:root {
--bg-light: #f5f3f0;
--bg-dark: #111111;
--bg-accent: #1a0a2e;
--text-on-light: #1a1a1a;
--text-on-dark: #f0ede8;
--font-display: '[DISPLAY FONT]', sans-serif;
--font-body: '[BODY FONT]', sans-serif;
}
/* Side-aligned text zones — product occupies center */
.align-left { padding-left: 5vw; padding-right: 55vw; }
.align-right { padding-left: 55vw; padding-right: 5vw; }
.align-left .section-inner,
.align-right .section-inner { max-width: 40vw; }
Layout rules:
position: absolute within scroll container, positioned at midpoint of enter/leave range, transform: translateY(-50%).#999 for important text on light bg. Use #666 minimum for body, var(--text-on-light) for headings.js/app.jsconst lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true
});
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Two-phase: load first 10 frames immediately (fast first paint), then load remaining in background. Show progress bar. Hide loader only after all frames ready.
const IMAGE_SCALE = 0.85; // 0.82–0.90 sweet spot
function drawFrame(index) {
const img = frames[index];
if (!img) return;
const cw = canvas.width, ch = canvas.height;
const iw = img.naturalWidth, ih = img.naturalHeight;
const scale = Math.max(cw / iw, ch / ih) * IMAGE_SCALE;
const dw = iw * scale, dh = ih * scale;
const dx = (cw - dw) / 2, dy = (ch - dh) / 2;
ctx.fillStyle = bgColor; // sampled from frame corners
ctx.fillRect(0, 0, cw, ch);
ctx.drawImage(img, dx, dy, dw, dh);
}
sampleBgColor() every ~20 framesdevicePixelRatio scaling for crisp renderingconst FRAME_SPEED = 2.0; // 1.8–2.2, higher = animation finishes earlier
ScrollTrigger.create({
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const accelerated = Math.min(self.progress * FRAME_SPEED, 1);
const index = Math.min(Math.floor(accelerated * FRAME_COUNT), FRAME_COUNT - 1);
if (index !== currentFrame) {
currentFrame = index;
requestAnimationFrame(() => drawFrame(currentFrame));
}
}
});
Each section reads data-animation and gets a DIFFERENT entrance. Never repeat the same type consecutively. Sections with data-persist="true" stay visible once animated in.
function setupSectionAnimation(section) {
const type = section.dataset.animation;
const persist = section.dataset.persist === "true";
const enter = parseFloat(section.dataset.enter) / 100;
const leave = parseFloat(section.dataset.leave) / 100;
const children = section.querySelectorAll(
".section-label, .section-heading, .section-body, .section-note, .cta-button, .stat"
);
const tl = gsap.timeline({ paused: true });
switch (type) {
case "fade-up":
tl.from(children, { y: 50, opacity: 0, stagger: 0.12, duration: 0.9, ease: "power3.out" });
break;
case "slide-left":
tl.from(children, { x: -80, opacity: 0, stagger: 0.14, duration: 0.9, ease: "power3.out" });
break;
case "slide-right":
tl.from(children, { x: 80, opacity: 0, stagger: 0.14, duration: 0.9, ease: "power3.out" });
break;
case "scale-up":
tl.from(children, { scale: 0.85, opacity: 0, stagger: 0.12, duration: 1.0, ease: "power2.out" });
break;
case "rotate-in":
tl.from(children, { y: 40, rotation: 3, opacity: 0, stagger: 0.1, duration: 0.9, ease: "power3.out" });
break;
case "stagger-up":
tl.from(children, { y: 60, opacity: 0, stagger: 0.15, duration: 0.8, ease: "power3.out" });
break;
case "clip-reveal":
tl.from(children, { clipPath: "inset(100% 0 0 0)", opacity: 0, stagger: 0.15, duration: 1.2, ease: "power4.inOut" });
break;
}
// Play/reverse based on scroll via ScrollTrigger onUpdate
// If persist === true, never reverse when scrolling past leave point
}
document.querySelectorAll(".stat-number").forEach(el => {
const target = parseFloat(el.dataset.value);
const decimals = parseInt(el.dataset.decimals || "0");
gsap.from(el, {
textContent: 0,
duration: 2,
ease: "power1.out",
snap: { textContent: decimals === 0 ? 1 : 0.01 },
scrollTrigger: {
trigger: el.closest(".scroll-section"),
start: "top 70%",
toggleActions: "play none none reverse"
}
});
});
document.querySelectorAll(".marquee-wrap").forEach(el => {
const speed = parseFloat(el.dataset.scrollSpeed) || -25;
gsap.to(el.querySelector(".marquee-text"), {
xPercent: speed,
ease: "none",
scrollTrigger: {
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true
}
});
// Fade marquee in/out based on scroll range using opacity transitions
});
function initDarkOverlay(enter, leave) {
const overlay = document.getElementById("dark-overlay");
const fadeRange = 0.04;
ScrollTrigger.create({
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const p = self.progress;
let opacity = 0;
if (p >= enter - fadeRange && p <= enter)
opacity = (p - (enter - fadeRange)) / fadeRange;
else if (p > enter && p < leave)
opacity = 0.9;
else if (p >= leave && p <= leave + fadeRange)
opacity = 0.9 * (1 - (p - leave) / fadeRange);
overlay.style.opacity = opacity;
}
});
}
function initHeroTransition() {
ScrollTrigger.create({
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const p = self.progress;
// Hero fades out as scroll begins
heroSection.style.opacity = Math.max(0, 1 - p * 15);
// Canvas reveals via expanding circle clip-path
const wipeProgress = Math.min(1, Math.max(0, (p - 0.01) / 0.06));
const radius = wipeProgress * 75;
canvasWrap.style.clipPath = `circle(${radius}% at 50% 50%)`;
}
});
}
npx serve . or python -m http.server 8000data-persist="true" keeps final section visibleclip-path: circle() as hero scrolls away| Type | Initial State | Animate To | Duration |
|---|---|---|---|
fade-up | y:50, opacity:0 | y:0, opacity:1 | 0.9s |
slide-left | x:-80, opacity:0 | x:0, opacity:1 | 0.9s |
slide-right | x:80, opacity:0 | x:0, opacity:1 | 0.9s |
scale-up | scale:0.85, opacity:0 | scale:1, opacity:1 | 1.0s |
rotate-in | y:40, rotation:3, opacity:0 | y:0, rotation:0, opacity:1 | 0.9s |
stagger-up | y:60, opacity:0 | y:0, opacity:1 | 0.8s |
clip-reveal | clipPath:inset(100% 0 0 0) | clipPath:inset(0% 0 0 0) | 1.2s |
All types use stagger (0.1–0.15s). Easing: power3.out except scale-up (power2.out) and clip-reveal (power4.inOut).
circle(0% at 50% 50%) → circle(75% at 50% 50%)inset(0 100% 0 0) → inset(0 0% 0 0)inset(100% 0 0 0) → inset(0% 0 0 0)polygon(50% 0%, 50% 0%, 50% 100%, 50% 100%) → polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)Math.max at 1.0) — product clips into header. Use IMAGE_SCALE 0.82–0.90Math.min) — leaves visible border that doesn't match page bgfile://scrub value, reduce frame countdevicePixelRatio scaling to canvas dimensionslenis.on("scroll", ScrollTrigger.update) is connecteddata-value attribute exists and snap settings match decimal places