Scroll hero video
Build Your Own AI Employee. The complete starter kit for OpenClaw + Hermes Agent. 155 deduplicated skills, drag-and-drop workspace, case studies, install scripts.
npx -y skills add S3YED/appie-kit --skill scroll-hero-videoAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 6 stars6 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Build Apple-style scroll-driven video hero sections. A full-screen video scrubs frame-by-frame as the user scrolls, with text overlay fade, mobile fallback, and buttery-smooth rAF lerp.
SKILL.md
10.1 KB, ~2.7k tokens by cl100k_base, as published. Nobody here has run it
Scroll Hero Video
Build a scroll-driven video hero where a full-screen video scrubs in sync with scroll position — the Apple product page effect.
When to use
- Landing page hero needs a premium, interactive feel
- You have a short video (5–10s) that tells a story (before/after, product reveal, transformation)
- Desktop-first: mobile gets autoplay loop fallback
Requirements
- Short video (5–10s, 720p recommended)
- ffmpeg for encoding
- No JS dependencies needed (vanilla JS)
Step 1: Encode the video
The video MUST have every frame as a keyframe (-g 1) so seeking is instant. Use Soleiman/LuxHom proven specs:
ffmpeg -i source.mp4 \
-c:v libx264 \
-g 1 \
-crf 26 \
-preset medium \
-pix_fmt yuv420p \
-movflags +faststart \
-an \
-vf "scale=1280:720:flags=lanczos,fps=24" \
scrub.mp4
Key params:
| Param | Why |
|---|---|
-g 1 | Every frame is a keyframe — enables instant seeking to any position |
-crf 26 | Balance quality/file size (28 = smaller/lower, 24 = larger/better) |
scale=1280:720 | 720p is plenty for background video — 1080p adds 3-4x weight |
fps=24 | Lower FPS = fewer frames to seek = smoother scrub |
-an | No audio needed for scrub |
| Target: ~5-6MB for a 7s video at 5.8 Mbps |
Soleiman reference: 1280x714, 24fps, 6Mbps, 6MB for 8s video.
Step 2: HTML structure
<section class="hero" id="hero">
<div class="hero-sticky">
<video id="heroVideo" muted playsInline preload="auto" poster="poster.jpg">
<source src="videos/scrub.mp4" type="video/mp4">
</video>
<div class="hero-overlay" id="heroOverlay">
<!-- Your hero content: pre-title, heading, tagline, CTAs -->
</div>
<div class="hero-scroll-hint" id="scrollHint">
<span>Scroll voor de transformatie</span>
</div>
</div>
</section>
Key:
- Section height:
250vh(scrubs over ~1.5 viewports) - Inner div:
position: sticky; top: 0; height: 100vh— pins video - Video:
position: absolute; inset: 0; object-fit: cover
Step 3: CSS
.hero { position: relative; width: 100%; height: 250vh; background: #000; }
.hero-sticky { position: sticky; top: 0; height: 100vh; overflow: hidden; will-change: transform; }
.hero video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; will-change: transform; }
.hero video.mobile-fallback { will-change: auto; }
.hero-overlay {
position: absolute; inset: 0; z-index: 5;
display: flex; flex-direction: column; justify-content: center;
align-items: center; text-align: center; pointer-events: none;
transition: opacity .15s linear;
}
.hero-scroll-hint {
position: absolute; bottom: 2.5rem; left: 50%; transform: translateX(-50%); z-index: 10;
display: flex; flex-direction: column; align-items: center; gap: .75rem;
color: rgba(255,255,255,.35); font-size: .7rem; letter-spacing: .15em; text-transform: uppercase;
transition: opacity .15s linear, visibility .15s linear;
}
CSS tips:
will-change: transformon sticky + video hints GPU acceleration — the compositor knows these layers are animatedpointer-events: noneon overlay so clicks pass through to nav/CTAs underneath.15s lineartransitions feel snappier than1s ease— the rAF loop updates every frame anyway, the CSS transition is just a safety net
Step 4: JavaScript (Soleiman-exact)
(function() {
const video = document.getElementById('heroVideo');
const overlay = document.getElementById('heroOverlay');
const hint = document.getElementById('scrollHint');
const progressBar = document.getElementById('progressBar');
if (!video) return;
// Mobile / reduced motion / save-data → autoplay loop
const isMobile = window.matchMedia('(max-width: 767px)').matches;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const saveData = navigator.connection?.saveData === true;
if (isMobile || reduceMotion || saveData) {
video.classList.add('mobile-fallback');
video.loop = true;
video.play().catch(() => {});
return;
}
video.loop = false; video.pause();
// ─── BLOB PRELOAD: fetch entire video into memory ───
const src = video.querySelector('source')?.src;
if (src) {
fetch(src, { cache: 'force-cache' })
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
video.src = url;
const old = video.querySelector('source');
if (old) old.remove();
const s = document.createElement('source');
s.src = url; s.type = 'video/mp4';
video.appendChild(s);
video.load();
})
.catch(() => {});
}
let duration = 7;
const dur = () => video.duration || duration;
video.addEventListener('loadedmetadata', () => { duration = video.duration || 7; });
if (video.readyState >= 1) duration = video.duration || 7;
// Safari/iOS: prime with play+pause for seeking
let primed = false;
const prime = () => {
if (primed) return; primed = true;
video.play().then(() => video.pause()).catch(() => {});
window.removeEventListener('touchstart', prime);
};
window.addEventListener('touchstart', prime, { passive: true });
const PIN = 1.5; // viewport heights to scrub through
let cur = 0; // smoothed TIME value (Soleiman-exact)
let raf = 0;
const tick = () => {
raf = requestAnimationFrame(tick);
// Read scrollY directly in rAF — NO scroll event listener needed
const progress = Math.min(1, Math.max(0, window.scrollY / (window.innerHeight * PIN)));
const target = progress * dur();
// Soleiman-exact lerp: fixed 0.12 factor, 0.004 threshold
cur += (target - cur) * 0.12;
if (Math.abs(target - cur) < 0.004) cur = target;
// Seek — only when video has data
if (video.readyState >= 2 && Number.isFinite(cur) && duration > 0) {
const seekTime = Math.min(cur, dur() - 0.05);
if (Math.abs(video.currentTime - seekTime) > 0.01) {
try { video.currentTime = seekTime; } catch (e) {}
}
}
// Overlay fade — Soleiman-exact: fades between 5% and 25% scroll
const f = Math.max(0, Math.min(1, 1 - (progress - 0.05) / 0.20));
if (overlay) overlay.style.opacity = f;
// Scroll hint fade — gone by 8% scroll
if (hint) {
const h = Math.max(0, 1 - progress / 0.08);
hint.style.opacity = h;
hint.style.visibility = h <= 0 ? 'hidden' : 'visible';
}
// Progress bar
if (progressBar) progressBar.style.width = (progress * 100) + '%';
};
raf = requestAnimationFrame(tick);
})();
Key techniques explained
| Technique | Why |
|---|---|
Blob preload (cache: force-cache) | Whole video in memory. Browsers may ignore preload=auto for large files. Blob = guaranteed instant seeking. Cache hint avoids re-download. |
| rAF-only scroll | Read window.scrollY directly in rAF. No separate scroll event listener = no layout thrashing, no dual-update bugs. |
| Fixed 0.12 lerp on time values | Soleiman-exact. Lerp on currentTime (not 0-1 progress), fixed factor. Prevents the "catching up" feel of adaptive lerps. |
| 0.004 threshold | Snaps to exact position when close enough — prevents micro-jitter when user stops scrolling. |
| Safari prime | iOS/Safari won't decode frames for a video that has never played. A play()+pause() on first interaction unlocks seeking. |
dur() helper | Readable pattern: `const dur = () => video.duration |
Mobile fallback
On small screens (<768px), prefers-reduced-motion, or save-data: the video autoplays in a loop instead of scrubbing. The text overlay stays visible. This avoids wasting bandwidth on a scroll effect that doesn't work well on touch.
Reference implementations
| Site | Video specs | Notes |
|---|---|---|
Soleiman Advocatuur (soleiman-advocatuur.vercel.app) | 1280x714, 24fps, 6Mbps, 6MB (8s) | Production reference. Uses blob preload + rAF-only scroll + fixed 0.12 lerp. React/framer-motion but vanilla JS equivalent works identically. |
LuxHom (luxhom-site.vercel.app) | 1280x720, 24fps, 5.8Mbps, 4.8MB (7s) | Vanilla HTML/JS rebuild. Soleiman-exact lerp, no scroll listener, blob preload with cache: force-cache. |
Pitfalls
- Don't skip blob preload. Without it, seeking depends on browser's buffering state.
preload=autois not reliable for large files. - Don't use a scroll event listener. Read
window.scrollYdirectly in rAF. A separate scroll handler + rAF creates dual-update bugs and unnecessary overhead. - Don't use canvas. Drawing video frames to canvas with
drawImageadds compositing overhead. Direct<video>withobject-fit: coveris GPU-accelerated and smoother. - Don't use 1080p. 720p looks identical at full-screen on most devices and is 3-4x smaller.
- Don't use 30fps. 24fps is the sweet spot — fewer frames to seek, smoother feel.
- Don't use adaptive lerp on progress. Soleiman-exact: lerp on
currentTime(time value) with a fixed 0.12 factor. This feels more consistent than adaptive approaches. - Don't add
transform: translate(-50%, -50%)on the video element for centering — useobject-fit: coverwithinset: 0. The translate approach breaks the sticky positioning. - Don't forget the Safari prime. Without it, iOS Safari shows a black/blank video until the user interacts. Only
touchstartis needed (not scroll). - Don't use
1s easeon overlay transitions. Use.15s linear— the rAF loop updates every frame anyway, the CSS transition is just a safety net for missed frames. - Don't forget
pointer-events: noneon the overlay div — otherwise clicks on the hero area won't reach the navigation or CTA buttons underneath. - Don't set
width: 100%on sticky element when not needed.position: stickynaturally fills width. Redundantwidth: 100%can cause horizontal scroll issues on some browsers.