Takes a video file (e.g. a product deconstruction/assembly animation) and builds a beautiful, performant website with scroll-driven animation. The video plays forward/backward as the user scrolls, creating a mesmerizing scroll-stopping effect. Uses frame extraction via FFmpeg, canvas-based rendering, and modern scroll-driven animation techniques. Includes: animated starscape background, annotation cards with snap-stop scroll, specs section with count-up animations, navbar with scroll-to-pill transform, loader, and full mobile responsiveness. Trigger when the user says "scroll-stop build", "scroll animation website", "scroll-driven video", "build the scroll-stop site", or provides a video file and asks to make it scroll-controlled. Also trigger if the user mentions "Apple-style scroll animation" or "video on scroll".
You take a video file and build a production-quality website where the video playback is controlled by scroll position — creating a dramatic, Apple-style scroll-stopping effect.
Before building anything, you MUST gather information from the user through a brief interview. Do not assume any brand names, colors, or content — everything is customized per project.
Before touching any code or extracting any frames, ask the user these questions. Do not skip this step — the whole point of the skill is to build something tailored, not generic.
Ask these in a natural, conversational way — not as a numbered interrogation:
Ask the user how they want to provide the website content:
If the user provides a URL, use WebFetch to retrieve the page content and extract relevant
copy, product details, feature descriptions, spec numbers, and any other usable content.
Ask whether the user wants these included:
Only include these sections if the user explicitly opts in.
brew install ffmpeg if not)Once the interview is complete, construct the design system from the user's answers:
backdrop-filter: blur(20px), border-radius: 20pxThe most reliable approach for scroll-driven video:
This is the same technique Apple uses for their product pages.
Why not <video> with currentTime?
Browser video decoders aren't optimized for seeking on every scroll event. Canvas + pre-extracted
frames is buttery smooth and gives frame-perfect control.
ffprobe -v quiet -print_format json -show_streams -show_format "{VIDEO_PATH}"
Extract duration, fps, resolution, total frame count. Target 60-150 frames total.
mkdir -p "{OUTPUT_DIR}/frames"
ffmpeg -i "{VIDEO_PATH}" -vf "fps={TARGET_FPS},scale=1920:-2" -q:v 2 "{OUTPUT_DIR}/frames/frame_%04d.jpg"
Use -q:v 2 for high quality JPEG. Use JPEG not PNG for smaller files.
Create a single HTML file. The site has these sections (top to bottom):
For full implementation details of each section, read references/sections-guide.md.
Canvas rendering with Retina support:
canvas.width = window.innerWidth * window.devicePixelRatio;
canvas.height = window.innerHeight * window.devicePixelRatio;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
Cover-fit drawing (desktop) — zoomed contain-fit (mobile): On desktop, use cover-fit so the frame fills edge-to-edge. On mobile, use a slightly zoomed contain-fit approach so the object stays centered and visible.
Annotation cards with snap-stop scroll: Annotation cards appear at specific scroll progress points (data-show/data-hide attributes). The scroll FREEZES briefly at each card position — creating a "boom, boom, boom" effect where each card pops up as you stop. Uses JS-based snap: detects when scroll progress enters a snap zone, scrolls to the exact position, locks the body overflow for ~600ms, then releases. The number of annotation cards is flexible — match it to the content the user provides.
Navbar scroll-to-pill transform: The navbar starts full-width, then on scroll shrinks to a centered pill shape (max-width ~820px) with rounded corners and glass-morphism background.
Count-up animation: Spec numbers animate from 0 to target with easeOutExpo easing, staggered 200ms apart. Numbers get an accent-color glow pulse while counting. Triggered by IntersectionObserver.
Animated starscape: A fixed canvas behind everything with ~180 stars that slowly drift and twinkle. Each star has random drift speed, twinkle speed/phase, and opacity. Creates a subtle living background.
All content comes from the interview (Step 0). Use the real brand name, real product details, and real copy — never use placeholder "Lorem ipsum" text. If content came from a website URL, use the actual text from that site. Adapt:
cd "{OUTPUT_DIR}" && python3 -m http.server 8080
Open http://localhost:8080 and test. Then open the browser URL for the user.
Key mobile adaptations:
requestAnimationFrame for drawing — Never draw directly in scroll handler{ passive: true } on scroll listener — Enables scroll optimizationsdevicePixelRatio — Crisp on Retina displaysdrawFrame when frame index changesscroll-behavior: smooth — Would interfere with frame-accurate scroll mappingposition: sticky keeps canvas viewport-fixed while scroll container moves| Issue | Solution |
|---|---|
| Frames don't load | Check file paths, ensure local server is running (can't load from file://) |
| Animation is choppy | Reduce frame count, ensure JPEG not PNG, check file sizes (<100KB each) |
| Canvas is blurry | Ensure devicePixelRatio scaling is applied |
| Scroll feels too fast/slow | Adjust .scroll-animation height (200vh=fast, 500vh=slow, 800vh=cinematic) |
| Mobile cards overlap content | Use compact single-line card design, position at bottom: 1.5vh |
| Snap-stop feels jarring | Reduce HOLD_DURATION to 400ms or increase SNAP_ZONE |
| Stars too bright/dim | Adjust starscape canvas opacity (default 0.6) |
| First frame isn't white | Ask user to re-export video with white opening frame |
When embedding scroll-driven animations into a Next.js Pages Router project:
Create a dedicated page or component that loads the scroll animation. Since the animation uses vanilla JS with canvas, wrap it in a useEffect to avoid SSR issues:
import { useEffect, useRef } from 'react';
import { PresentationLayout } from '@components';
const ScrollAnimationPage = () => {
const canvasRef = useRef(null);
useEffect(() => {
// Initialize canvas and scroll animation logic here
// This runs only on the client side
const canvas = canvasRef.current;
if (!canvas) return;
// Frame loading and scroll mapping logic...
return () => {
// Cleanup scroll listeners
};
}, []);
return (
<PresentationLayout>
<div className="scroll-animation" style={{ height: '500vh' }}>
<canvas ref={canvasRef} style={{ position: 'sticky', top: 0 }} />
</div>
</PresentationLayout>
);
};
export default ScrollAnimationPage;
Extract frames to the public/ directory so Next.js can serve them as static files:
# Extract frames to public/frames/
ffmpeg -i input.mp4 -vf "fps=30,scale=1920:-2" -q:v 5 public/frames/frame-%04d.jpg
Frame paths become: /frames/frame-0001.jpg, /frames/frame-0002.jpg, etc.
For preloading frame sequences with the project's lazy-blur system:
import { Image } from '@components';
// Preload critical frames (first few for immediate display)
const criticalFrames = Array.from({ length: 10 }, (_, i) =>
`/frames/frame-${String(i + 1).padStart(4, '0')}.jpg`
);
// Use <link rel="preload"> for critical frames in _document.js or via next/head
If the scroll animation code is large, use next/dynamic to avoid loading it on pages that don't need it:
import dynamic from 'next/dynamic';
const ScrollAnimation = dynamic(() => import('@components/ScrollAnimation'), {
ssr: false, // Canvas-based, client-only
loading: () => <div className="h-screen flex items-center justify-center"><Spinner /></div>,
});