Scroll scrub 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-scrub-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 scroll-driven video hero sections where a video scrubs frame-by-frame as the user scrolls. Covers video encoding, blob preloading, rAF-driven playback, mobile fallback, and the two common modes (fast Soleiman-style vs. full-section chronological).
SKILL.md
9.0 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it
Scroll Scrub Video Hero
Build a scroll-driven video section where video.currentTime is driven by the user's scroll position. This is the technique Apple uses on product pages and is used on soleiman-advocatuur.vercel.app.
Two Modes (critical to get right)
Mode A: Fast scrub with overlay fade (Soleiman-style)
- Video finishes scrubbing after 1.5 viewport heights of scroll
- Content overlay/text fades out (5–25% scroll)
- Remaining scroll in the hero section shows the last video frame
- Used for: storytelling where the video is a backdrop that transitions to content below
- Hero height: 250vh | Scrub distance: 150vh | Fade: yes
Mode B: Chronological full-section scrub
- Video scrubs from start to end across the entire hero section (e.g., 240vh)
- Content overlay stays visible throughout
- When the user finishes scrolling the hero, the video is at the final frame
- Used for: product transformations, "before → after" reveals, where the video IS the content
- Hero height: 340vh | Scrub distance: 240vh | Fade: no
Always confirm with the user which mode they want before building. Default to Mode B for transformation/reveal content.
Video Encoding
The video MUST be encoded with every frame as a keyframe (-g 1) for instant seeking. Standard video files have keyframes every ~250 frames, causing visible stutter when scrubbing.
# Production scrub video — 720p, 24fps, every frame a keyframe
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
Video spec (proven):
- Resolution: 1280x720
- Framerate: 24fps
- Codec: H.264,
-g 1(every frame keyframe) - Bitrate: ~6 Mbps
- File size: ~5 MB for 7 seconds
- No audio (
-an)
Why these specs: The Soleiman reference video (lady-justice-scrub.mp4) is 1280x714 @ 24fps, 6MB. Higher resolution or FPS increases file size without visible benefit (the video is a background element). 24fps = fewer frames to seek through = smoother scrub.
Implementation
HTML Structure
<section class="hero" style="height: 340vh">
<div class="hero-sticky" style="position: sticky; top: 0; height: 100vh; overflow: hidden">
<video id="heroVideo" muted playsInline preload="auto" poster="poster.jpg">
<source src="videos/scrub.mp4" type="video/mp4">
</video>
<div class="hero-overlay">
<!-- overlay content stays visible or fades per mode -->
</div>
</div>
</section>
CSS
.hero { position: relative; width: 100%; height: 340vh; 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; }
JavaScript (rAF-only, no scroll event listener)
(function() {
const video = document.getElementById('heroVideo');
const hint = document.getElementById('heroHint');
const progressBar = document.getElementById('progressBar');
if (!video) return;
// Detect constraints
const isMobile = window.matchMedia('(max-width: 767px)').matches;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const saveData = navigator.connection?.saveData === true;
// Mobile fallback: autoplay loop
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 for instant seeking
const src = video.querySelector('source')?.src || video.currentSrc;
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(() => {});
}
// Read duration
let duration = 7;
video.addEventListener('loadedmetadata', () => { duration = video.duration || 7; });
if (video.readyState >= 1) duration = video.duration || 7;
// Safari prime: muted play+pause on first interaction to unlock 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 });
// rAF loop — read scrollY directly, no scroll event listener
const hero = document.getElementById('hero');
let cur = 0;
let raf = 0;
const tick = () => {
raf = requestAnimationFrame(tick);
const scrollable = hero.offsetHeight - window.innerHeight;
const scrolled = Math.max(0, window.scrollY);
const progress = Math.min(1, scrolled / scrollable);
const target = progress * duration;
// Smooth lerp — prevents jitter from individual scroll wheel ticks
cur += (target - cur) * 0.12;
if (Math.abs(target - cur) < 0.004) cur = target;
// Frame-accurate seek
if (video.readyState >= 2 && Number.isFinite(cur) && duration > 0) {
const seekTime = Math.min(cur, duration - 0.05);
if (Math.abs(video.currentTime - seekTime) > 0.01) {
try { video.currentTime = seekTime; } catch (e) {}
}
}
// Scroll hint fade (only at very start)
if (hint) {
const h = Math.max(0, 1 - progress / 0.05);
hint.style.opacity = h;
hint.style.visibility = h <= 0 ? 'hidden' : 'visible';
}
// Progress bar
if (progressBar) progressBar.style.width = (progress * 100) + '%';
};
raf = requestAnimationFrame(tick);
})();
Key Implementation Details
Blob preloading: Standard <video preload="auto"> does NOT guarantee the browser has buffered the entire file. fetch() + URL.createObjectURL() puts the full video in memory, eliminating disk I/O and network latency during seeking.
rAF-only scroll reading: Do NOT attach a scroll event listener. Read window.scrollY directly inside requestAnimationFrame. This avoids the overhead of a separate event handler and eliminates synchronization issues.
Lerp factor (0.12): Smooths individual scroll wheel ticks into a fluid motion. Higher values = more responsive but potentially jittery. Lower values = smoother but laggy. 0.12 is proven across multiple production implementations.
Mobile fallback: On screens <768px, prefers-reduced-motion, or save-data connections, fall back to a standard autoplay loop. Don't force scroll scrubbing on mobile.
Safari/iOS: iOS Safari will NOT decode video frames for seeking unless the video has been played at least once. The "prime" pattern (muted play + pause on first touch/scroll interaction) unlocks seeking.
Pitfalls
- Overlay fade surprises: The Soleiman-style fade at 5–25% scroll looks like the video is "fading to an end screen." For transformation/reveal content, keep the overlay visible (Mode B).
- Wrong scrub distance: Using
PIN = 1.5(150vh) when the user expects full-section scrub (240vh) makes the video complete too fast. Always confirm the scrub distance. - Video too large: A 21MB 1080p30 video vs a 5MB 720p24 video makes a dramatic difference in scrub smoothness. Encode at 720p24 with
-g 1. - No Safari prime: Without the play+pause priming, the video stays black on iOS Safari because seeking doesn't decode frames.
- Canvas overhead: Don't render video frames onto a
<canvas>viadrawImage. Use a direct<video>element withobject-fit: cover. Canvas adds a compositing bottleneck. - Overlay pointer-events: Set
pointer-events: noneon overlays so clicks pass through to navigation/buttons beneath.
Verification
- Open the page and scroll down — video should progress frame-by-frame
- Scroll back up — video should reverse smoothly
- Test on Safari/iOS — video should play, not stay black
- Check blob loading via DevTools Network tab — video should load once, then show
blob:URL in video element - Open on mobile (<768px) — should autoplay in a loop, not scrub===ME:scroll-scrub-video