/* ── Demo 49: Video Scroll Scrub ── */
/* Dark cinematic palette + clean system font */
:root {
--bg: #060608;
--panel: #0e0e14;
--border: #1c1c28;
--text: #f0eee8;
--muted: #7a7888;
--accent: #e8c060; /* warm amber for progress/labels */
--accent2: #60b8e8; /* cyan for code */
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: auto; } /* GSAP handles scroll, not native */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* ── Labels ── */
.label {
display: block;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1rem;
}
code {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8rem;
color: var(--accent2);
background: rgba(96, 184, 232, 0.08);
padding: 0.2em 0.5em;
border-radius: 3px;
display: inline-block;
margin-top: 0.6rem;
}
/* ── Intro ── */
.intro {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 3rem 2rem;
background: radial-gradient(ellipse at 50% 60%, rgba(232, 192, 96, 0.06) 0%, transparent 65%);
}
.intro-inner {
max-width: 600px;
}
.intro h1 {
font-size: clamp(3.5rem, 9vw, 7rem);
font-weight: 800;
line-height: 1.0;
letter-spacing: -0.03em;
margin-bottom: 1.5rem;
}
.intro h1 em {
font-style: normal;
color: var(--accent);
}
.intro p {
font-size: 1.05rem;
color: var(--muted);
line-height: 1.8;
margin-bottom: 3rem;
}
.scroll-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.scroll-hint span {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.sh-arrow {
font-size: 1.2rem;
color: var(--accent);
animation: bounce-arrow 1.5s ease-in-out infinite;
}
@keyframes bounce-arrow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
/* ── Scrub section ── */
.scrub-section {
position: relative;
}
.scrub-sticky {
position: sticky;
top: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.scrub-spacer {
height: 400vh; /* scroll distance = scrub range */
}
/* ── Video / Canvas ── */
.video-wrap {
position: relative;
width: 100%;
max-width: 1100px;
aspect-ratio: 16/9;
background: var(--panel);
overflow: hidden;
}
#video-canvas {
width: 100%;
height: 100%;
display: block;
}
/* Fallback gradient animation (simulates video) */
.video-fallback {
position: absolute;
inset: 0;
}
.vf-frame {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
hsl(220, 30%, 8%) 0%,
hsl(240, 25%, 12%) 25%,
hsl(200, 20%, 10%) 50%,
hsl(260, 30%, 8%) 75%,
hsl(220, 30%, 8%) 100%
);
/* Hue will be animated via CSS variable */
--hue: 220;
}
/* ── Overlay UI ── */
.scrub-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1.5rem;
}
.so-progress {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.so-bar {
flex: 1;
height: 2px;
background: rgba(255, 255, 255, 0.2);
border-radius: 1px;
overflow: hidden;
}
.so-fill {
height: 100%;
width: 0%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
border-radius: 1px;
transition: width 0.05s linear;
}
.so-time {
font-family: 'SF Mono', monospace;
font-size: 0.78rem;
color: rgba(255,255,255,0.6);
white-space: nowrap;
min-width: 36px;
}
.so-caption {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
.sc-label {
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent);
}
.sc-text {
font-size: 0.9rem;
color: rgba(255,255,255,0.8);
font-weight: 500;
}
/* ── Chapters / How it works ── */
.chapters-section {
padding: 6rem 3rem;
max-width: 900px;
margin: 0 auto;
}
.chapters-section h2 {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
margin-bottom: 3.5rem;
}
.chapters {
display: flex;
flex-direction: column;
gap: 0;
}
.chapter {
display: grid;
grid-template-columns: 60px 1fr;
gap: 1.5rem;
padding: 2rem 0;
border-bottom: 1px solid var(--border);
}
.chapter:first-child {
border-top: 1px solid var(--border);
}
.ch-num {
font-size: 0.72rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.05em;
padding-top: 3px;
}
.ch-content h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.ch-content p {
font-size: 0.9rem;
color: var(--muted);
line-height: 1.75;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.scrub-overlay { padding: 1rem; }
.so-caption { display: none; }
.chapters-section { padding: 4rem 1.5rem; }
.chapter { grid-template-columns: 40px 1fr; }
}
/* ── Reduced motion ── */
html.reduced-motion .sh-arrow {
animation: none;
}
html.reduced-motion .so-fill {
transition: none;
}
if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
gsap.registerPlugin(ScrollTrigger);
initDemoShell({
title: 'Video Scroll Scrub',
category: 'scroll',
tech: ['gsap', 'scroll-trigger', 'lenis', 'video-currenttime', 'canvas-2d'],
});
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add('reduced-motion');
// ── Lenis smooth scroll ───────────────────────────────────────────────────────
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
// ── Canvas setup ──────────────────────────────────────────────────────────────
const canvas = document.getElementById('video-canvas');
const ctx = canvas.getContext('2d');
const fallback = document.getElementById('video-fallback');
function resizeCanvas() {
const wrap = canvas.parentElement;
canvas.width = wrap.clientWidth;
canvas.height = wrap.clientHeight;
}
// ── Synthetic video frames (CSS-gradient simulation) ──────────────────────────
// Since we can't ship a real video file in this demo, we generate
// procedural "film frames" on Canvas that look like a scrubbing video.
// In production: replace with a real <video> element and draw to canvas.
const TOTAL_DURATION = 8; // simulated seconds
let currentProgress = 0;
let animating = false;
const scenes = [
{ at: 0.00, label: 'Opening', hue1: 220, hue2: 240, sat1: 30, sat2: 25, lig1: 8, lig2: 12 },
{ at: 0.20, label: 'Act I', hue1: 15, hue2: 30, sat1: 40, sat2: 30, lig1: 10, lig2: 15 },
{ at: 0.42, label: 'Rising', hue1: 200, hue2: 220, sat1: 50, sat2: 40, lig1: 12, lig2: 18 },
{ at: 0.65, label: 'Climax', hue1: 350, hue2: 330, sat1: 55, sat2: 45, lig1: 14, lig2: 20 },
{ at: 0.82, label: 'Resolution', hue1: 270, hue2: 290, sat1: 35, sat2: 25, lig1: 10, lig2: 15 },
{ at: 0.95, label: 'End Credits', hue1: 220, hue2: 240, sat1: 15, sat2: 10, lig1: 6, lig2: 8 },
];
function getSceneAt(progress) {
let scene = scenes[0];
for (let i = scenes.length - 1; i >= 0; i--) {
if (progress >= scenes[i].at) { scene = scenes[i]; break; }
}
return scene;
}
function lerpColor(a, b, t) {
return Math.round(a + (b - a) * t);
}
function drawFrame(progress) {
const w = canvas.width;
const h = canvas.height;
// Find scene blend
let sceneA = scenes[0], sceneB = scenes[1], blendT = 0;
for (let i = 0; i < scenes.length - 1; i++) {
if (progress >= scenes[i].at && progress < scenes[i + 1].at) {
sceneA = scenes[i];
sceneB = scenes[i + 1];
const range = scenes[i + 1].at - scenes[i].at;
blendT = (progress - scenes[i].at) / range;
break;
}
}
if (progress >= scenes[scenes.length - 1].at) {
sceneA = sceneB = scenes[scenes.length - 1];
blendT = 0;
}
// Interpolate hues
const h1 = lerpColor(sceneA.hue1, sceneB.hue1, blendT);
const h2 = lerpColor(sceneA.hue2, sceneB.hue2, blendT);
const s1 = lerpColor(sceneA.sat1, sceneB.sat1, blendT);
const s2 = lerpColor(sceneA.sat2, sceneB.sat2, blendT);
const l1 = lerpColor(sceneA.lig1, sceneB.lig1, blendT);
const l2 = lerpColor(sceneA.lig2, sceneB.lig2, blendT);
// Background gradient
const grad = ctx.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, `hsl(${h1}, ${s1}%, ${l1}%)`);
grad.addColorStop(0.35, `hsl(${h2}, ${s2}%, ${l2}%)`);
grad.addColorStop(0.65, `hsl(${h1 + 20}, ${s1}%, ${l1 + 3}%)`);
grad.addColorStop(1, `hsl(${h2 + 10}, ${s2}%, ${l2 + 2}%)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
// Light leak / lens flare effect
const flareX = w * (0.2 + progress * 0.6);
const flareGrad = ctx.createRadialGradient(flareX, h * 0.3, 0, flareX, h * 0.3, w * 0.5);
flareGrad.addColorStop(0, `hsla(${h1 + 30}, 80%, 70%, ${0.04 + blendT * 0.06})`);
flareGrad.addColorStop(1, 'transparent');
ctx.fillStyle = flareGrad;
ctx.fillRect(0, 0, w, h);
// Horizon line — simulates ground/horizon in a cinematic shot
const horizonY = h * (0.48 + Math.sin(progress * Math.PI * 2) * 0.04);
const horizonGrad = ctx.createLinearGradient(0, horizonY - 2, 0, horizonY + 2);
horizonGrad.addColorStop(0, 'transparent');
horizonGrad.addColorStop(0.5, `hsla(${h1 + 15}, 60%, 55%, 0.12)`);
horizonGrad.addColorStop(1, 'transparent');
ctx.fillStyle = horizonGrad;
ctx.fillRect(0, horizonY - 2, w, 4);
// Film grain (subtle noise dots)
ctx.save();
ctx.globalAlpha = 0.025;
for (let i = 0; i < 800; i++) {
const gx = Math.random() * w;
const gy = Math.random() * h;
const size = Math.random() * 1.5;
ctx.fillStyle = Math.random() > 0.5 ? '#ffffff' : '#000000';
ctx.fillRect(gx, gy, size, size);
}
ctx.restore();
// Vignette
const vig = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.75);
vig.addColorStop(0, 'transparent');
vig.addColorStop(1, 'rgba(0, 0, 0, 0.6)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, w, h);
// Timestamp / frame counter
const simTime = progress * TOTAL_DURATION;
const mins = Math.floor(simTime / 60).toString().padStart(2, '0');
const secs = Math.floor(simTime % 60).toString().padStart(2, '0');
ctx.font = '11px "SF Mono", monospace';
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.textAlign = 'right';
ctx.fillText(`${mins}:${secs} | FRAME ${Math.round(progress * 240).toString().padStart(5, '0')}`, w - 16, h - 16);
}
// ── UI updates ────────────────────────────────────────────────────────────────
const soFill = document.getElementById('so-fill');
const soTime = document.getElementById('so-time');
const scText = document.getElementById('sc-text');
function updateUI(progress) {
soFill.style.width = `${progress * 100}%`;
const simTime = progress * TOTAL_DURATION;
const mins = Math.floor(simTime / 60).toString().padStart(2, '0');
const secs = Math.floor(simTime % 60).toString().padStart(2, '0');
soTime.textContent = `${mins}:${secs}`;
const scene = getSceneAt(progress);
scText.textContent = scene.label;
}
// ── ScrollTrigger for scrub ───────────────────────────────────────────────────
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
drawFrame(0);
if (!reduced) {
ScrollTrigger.create({
trigger: '#scrub-section',
start: 'top top',
end: 'bottom bottom',
scrub: 0.5,
onUpdate: (self) => {
currentProgress = self.progress;
drawFrame(currentProgress);
updateUI(currentProgress);
},
});
} else {
// In reduced motion mode, just show a static frame and update on scroll
window.addEventListener('scroll', () => {
const section = document.getElementById('scrub-section');
const rect = section.getBoundingClientRect();
const spacer = section.querySelector('.scrub-spacer');
const scrollable = section.offsetHeight - window.innerHeight;
const prog = Math.max(0, Math.min(1, -rect.top / (spacer.offsetHeight)));
drawFrame(prog);
updateUI(prog);
});
}
// ── Intro animations ──────────────────────────────────────────────────────────
if (!reduced) {
gsap.set('.intro h1, .intro p, .scroll-hint', { opacity: 0, y: 20 });
gsap.timeline({ delay: 0.3, defaults: { ease: 'expo.out' } })
.to('.intro h1', { opacity: 1, y: 0, duration: 1 })
.to('.intro p', { opacity: 1, y: 0, duration: 0.8 }, '-=0.5')
.to('.scroll-hint',{ opacity: 1, y: 0, duration: 0.7 }, '-=0.4');
}
// ── Chapter reveals ───────────────────────────────────────────────────────────
if (!reduced) {
document.querySelectorAll('.chapter').forEach((ch, i) => {
gsap.set(ch, { opacity: 0, x: -20 });
gsap.to(ch, {
opacity: 1, x: 0, duration: 0.7, ease: 'expo.out',
delay: i * 0.1,
scrollTrigger: { trigger: '.chapters', start: 'top 75%', toggleActions: 'play none none reverse' },
});
});
}
// ── Motion toggle ─────────────────────────────────────────────────────────────
window.addEventListener('motion-preference', (e) => {
gsap.globalTimeline.paused(e.detail.reduced);
});