Back to Articles
React
November 11, 202511 min read

Cinematic Scroll-Driven Video Experiences in React

A cinematic scroll transforms simple scrolling into a narrative lens: every gesture becomes an interaction, every reveal a transition. Done right, it turns passive visitors into active participants, boosting engagement, dwell time, and shareability.

Also published on:

Medium

React
Animation
GSAP
Scroll

The Scene We'll Bring to Life

A scroll-controlled sequence where a lone cowboy rides at sunset, fading to bright grayscale, revealing the logo through a zooming mask, and ending with a clean brand lockup.

Demo

Live Preview

Playground: Open it in CodeSandbox to explore the source code.

Repository

Browse the full project on GitHub to see the complete structure and implementation details.

Thinking Like a Director

Before diving into the steps, let's look at this concept through a director's eyes. In film, every frame serves the story. On the web, every scroll event can do the same. Our architecture borrows filmmaking terms for clarity:

  • Scene: CinematicScene. Controls and coordinates all visual layers.
  • Shot: <video>. The main video element tied to scroll progress.
  • Screenplay: GSAP timeline. Defines the animation flow and pacing.
  • Stage: Scroll container. Provides space for the narrative.
  • Mask / Logo: SVG overlays. Used for brand reveals and transitions.
  • Acts: Timeline segments. Define phases of the animation such as Intro → Reveal → Fade.

This perspective gives engineers and designers a shared language focused on rhythm, emotion, and pacing rather than pixels.

With the idea in mind, let's start building it step by step.

Step 1: Setting Up GSAP

For this cinematic animation system, we use GSAP (GreenSock Animation Platform), a JavaScript library that provides fine-grained control over animations and timelines. It helps synchronize video playback and effects with scroll position in a clean, performant way.

Install it with npm or pnpm (used in this project):

1pnpm add gsap @gsap/react 2# or 3npm i gsap

Step 2: Project Structure

Our cinematic system is split into modular, testable units that separate UI, logic, and data handling. This makes the project scalable and easy to debug.

1src/ 2├── App.tsx 3├── cinematic-scene/ 4│ ├── cinematic-scene.tsx # Scene orchestrator 5│ ├── components/ 6│ │ ├── CinematicShot.tsx # Renders and synchronizes the video element 7│ │ └── SceneOverlay.tsx # Displays logo mask and overlays 8│ ├── hooks/ 9│ │ ├── useScreenplayAnimation.ts # Manages GSAP scroll-based animation logic 10│ │ ├── useShotPreload.ts # Handles video preload and buffering 11│ ├── styles/ 12│ │ └── cinematic-scene.css 13│ ├── types/ 14│ │ └── cinematic.types.ts 15│ └── utils/ 16│ └── screenplay.utils.ts # GSAP timeline creation 17└── contexts/ 18 └── VideoLoadingContext.tsx # Manages video readiness state

Step 3: Building the CinematicScene Component

This is the main orchestrator that unites the video shot, overlays, preloading, and animation logic. It manages references, loading states, and coordinates the GSAP timeline through custom hooks.

1import type { CinematicSceneProps } from './types/cinematic.types' 2 3import React, { useCallback, useEffect, useRef, useState } from 'react' 4 5import { useVideoLoading } from '../contexts/VideoLoadingContext' 6import { CinematicShot } from './components/CinematicShot' 7import { SceneOverlay } from './components/SceneOverlay' 8import { useScreenplayAnimation } from './hooks/useScreenplayAnimation' 9import { useShotPreload } from './hooks/useShotPreload' 10 11/** 12 * CinematicScene - Orchestrates a scroll-driven cinematic experience 13 * Composes shot, overlays, and screenplay animations into a cohesive scene 14 * 15 * Cinema Metaphor: 16 * - Scene: The complete visual sequence 17 * - Shot: The video element being played 18 * - Screenplay: The animation timeline 19 * - Stage: The container for the shot 20 * - LogoMask: Colored branding that masks the video during zoom-out (brand-colored.svg) 21 * - HeroLogo: Main brand identity with original colors (brand-hero.svg) 22 */ 23export const CinematicScene: React.FC<CinematicSceneProps> = ({ src, duration = '300vh' }) => { 24 // Scene element references 25 const shotRef = useRef<HTMLVideoElement>(null) 26 const stageRef = useRef<HTMLDivElement>(null) 27 const logoMaskRef = useRef<HTMLDivElement>(null) 28 const heroLogoRef = useRef<HTMLDivElement>(null) 29 const sceneRef = useRef<HTMLDivElement>(null) 30 31 // Track loading states 32 const [isBlobReady, setIsBlobReady] = useState(false) 33 const [isBufferReady, setIsBufferReady] = useState(false) 34 35 // Get video loading context 36 const { setVideoReady } = useVideoLoading() 37 38 // Handle blob creation complete 39 const handleBlobReady = useCallback(() => { 40 console.log('Blob is ready') 41 setIsBlobReady(true) 42 }, []) 43 44 // Check if both conditions are met 45 useEffect(() => { 46 if (isBlobReady && isBufferReady) { 47 console.log('Both blob and buffer ready - marking video as ready') 48 setVideoReady() 49 } 50 }, [isBlobReady, isBufferReady, setVideoReady]) 51 52 // Orchestrate the cinematic experience 53 useShotPreload({ shotRef, src, onBlobReady: handleBlobReady }) 54 useScreenplayAnimation({ 55 shotRef, 56 stageRef, 57 logoMaskRef, 58 heroLogoRef, 59 sceneRef, 60 src, 61 }) 62 63 // Monitor video buffer status 64 useEffect(() => { 65 const video = shotRef.current 66 if (!video) return 67 68 console.log('Starting video buffer monitoring') 69 let isReady = false 70 71 // Check video buffer 72 const checkBuffer = () => { 73 if (isReady) return false 74 75 const buffered = video.buffered 76 const duration = video.duration 77 78 // Check if we have at least 2 seconds buffered OR 30% of video buffered 79 if (buffered.length > 0 && duration > 0) { 80 const bufferedEnd = buffered.end(buffered.length - 1) 81 const bufferedAmount = bufferedEnd 82 const bufferPercentage = (bufferedAmount / duration) * 100 83 84 console.log( 85 `Video buffer: ${bufferedAmount.toFixed(2)}s / ${duration.toFixed(2)}s (${bufferPercentage.toFixed(1)}%)`, 86 ) 87 88 // Wait for meaningful buffer: 2s minimum OR 30% of video 89 if (bufferedAmount >= 2 || bufferPercentage >= 30) { 90 console.log('Video has sufficient buffer') 91 isReady = true 92 setIsBufferReady(true) 93 return true 94 } 95 } 96 return false 97 } 98 99 // Listen for progress events to check buffer 100 const handleProgress = () => { 101 if (video.readyState >= 3) { 102 checkBuffer() 103 } 104 } 105 106 // Multiple strategies to detect when video has data 107 video.addEventListener('progress', handleProgress) 108 video.addEventListener('canplay', checkBuffer) 109 video.addEventListener('canplaythrough', checkBuffer) 110 video.addEventListener('loadeddata', checkBuffer) 111 112 // Check immediately if already loaded 113 if (video.readyState >= 3) { 114 setTimeout(checkBuffer, 100) 115 } 116 117 // Fallback timeout - ensure we don't block forever 118 const fallbackTimeout = setTimeout(() => { 119 if (!isReady) { 120 console.log('Buffer check timeout - marking as ready anyway') 121 isReady = true 122 setIsBufferReady(true) 123 } 124 }, 8000) 125 126 return () => { 127 video.removeEventListener('progress', handleProgress) 128 video.removeEventListener('canplay', checkBuffer) 129 video.removeEventListener('canplaythrough', checkBuffer) 130 video.removeEventListener('loadeddata', checkBuffer) 131 clearTimeout(fallbackTimeout) 132 } 133 }, [src]) 134 135 return ( 136 <div ref={sceneRef}> 137 <div ref={stageRef}> 138 <CinematicShot src={src} shotRef={shotRef} duration={duration} /> 139 </div> 140 141 <SceneOverlay logoMaskRef={logoMaskRef} heroLogoRef={heroLogoRef} /> 142 </div> 143 ) 144}

Step 4: Implementing the useScreenplayAnimation Hook

This hook manages scroll-driven interactions and ensures GSAP animations start only when the video is ready. It also handles touch activation for iOS and cleans up properly when unmounted.

1import type { ScreenplayAnimationConfig } from '../types/cinematic.types' 2 3import { useGSAP } from '@gsap/react' 4import gsap from 'gsap' 5import { ScrollTrigger } from 'gsap/ScrollTrigger' 6 7import { activateShot, createScreenplay } from '../utils/screenplay.utils' 8 9gsap.registerPlugin(ScrollTrigger) 10 11/** 12 * Custom hook for orchestrating cinematic screenplay animations 13 * Manages scroll-based shot sequencing and visual narrative 14 */ 15export const useScreenplayAnimation = ({ 16 shotRef, 17 stageRef, 18 logoMaskRef, 19 heroLogoRef, 20 sceneRef, 21 src, 22}: ScreenplayAnimationConfig): void => { 23 useGSAP( 24 () => { 25 const shot = shotRef.current 26 const stage = stageRef.current 27 const logoMaskContainer = logoMaskRef.current 28 const heroLogoContainer = heroLogoRef.current 29 30 if (!shot || !stage) return 31 32 const logoMask = stage.nextElementSibling?.querySelector('svg') 33 if (!logoMask) return 34 35 // Setup touch activation for iOS 36 const handleActivateShot = (): void => activateShot(shot) 37 document.documentElement.addEventListener('touchstart', handleActivateShot, { once: true }) 38 39 // Setup screenplay when video is fully ready 40 const handleVideoReady = (): void => { 41 // Ensure video is ready and has valid duration 42 if (!shot.duration || isNaN(shot.duration) || !isFinite(shot.duration)) { 43 console.warn('Video duration not ready, retrying...', shot.duration) 44 // Retry after a short delay 45 setTimeout(handleVideoReady, 100) 46 return 47 } 48 49 // Double-check readyState 50 if (shot.readyState < 2) { 51 console.warn('Video not ready, waiting for data...', shot.readyState) 52 setTimeout(handleVideoReady, 100) 53 return 54 } 55 56 console.log('Video ready! Duration:', shot.duration, 'ReadyState:', shot.readyState) 57 58 const screenplay = createScreenplay({ 59 shot, 60 stage, 61 logoMask: logoMask as SVGElement, 62 logoMaskContainer, 63 heroLogoContainer, 64 }) 65 66 // Attach scroll trigger to screenplay 67 ScrollTrigger.create({ 68 trigger: stage, 69 start: 'top top', 70 end: 'bottom bottom', 71 scrub: 1, 72 animation: screenplay, 73 invalidateOnRefresh: true, 74 }) 75 } 76 77 // Listen to multiple events to ensure video is ready 78 shot.addEventListener('loadedmetadata', handleVideoReady, { once: true }) 79 shot.addEventListener('loadeddata', handleVideoReady, { once: true }) 80 shot.addEventListener('canplay', handleVideoReady, { once: true }) 81 82 // Fallback: try after a timeout if events don't fire 83 const fallbackTimeout = setTimeout(() => { 84 console.warn('Fallback: forcing video ready check') 85 handleVideoReady() 86 }, 2000) 87 88 return () => { 89 clearTimeout(fallbackTimeout) 90 shot.removeEventListener('loadedmetadata', handleVideoReady) 91 shot.removeEventListener('loadeddata', handleVideoReady) 92 shot.removeEventListener('canplay', handleVideoReady) 93 document.documentElement.removeEventListener('touchstart', handleActivateShot) 94 ScrollTrigger.getAll().forEach((trigger) => trigger.kill()) 95 } 96 }, 97 { scope: sceneRef, dependencies: [src] }, 98 ) 99}

Step 5: The Screenplay of a Scroll-Driven Narrative

This GSAP timeline defines the entire cinematic flow of the animation and synchronizes video playback with scroll progress.

1import type { ScreenplayRefs } from '../types/cinematic.types' 2 3import gsap from 'gsap' 4 5/** 6 * Creates GSAP screenplay for cinematic shot animation 7 * Orchestrates the visual narrative through scroll-based keyframes 8 */ 9export const createScreenplay = (refs: ScreenplayRefs): gsap.core.Timeline => { 10 const { shot, logoMaskContainer, logoMask, heroLogoContainer } = refs 11 12 const screenplay = gsap.timeline({ defaults: { duration: 1 } }) 13 14 // Get video duration safely 15 const videoDuration = 16 shot.duration && !isNaN(shot.duration) && isFinite(shot.duration) ? shot.duration : 10 // fallback duration 17 18 // Act 1: Sync shot playback with scroll 19 screenplay.fromTo( 20 shot, 21 { currentTime: 0 }, 22 { 23 currentTime: videoDuration, 24 ease: 'none', 25 duration: 1, 26 onUpdate: function () { 27 // Force update currentTime 28 const progress = this.progress() 29 const newTime = progress * videoDuration 30 31 if (newTime >= 0 && newTime <= videoDuration && !isNaN(newTime)) { 32 try { 33 shot.currentTime = newTime 34 } catch (e) { 35 console.warn('Failed to set currentTime:', e) 36 } 37 } 38 }, 39 }, 40 ) 41 42 // Act 2: Brighten and desaturate shot (fade to white for a classic black-and-white movie feel) 43 screenplay.fromTo( 44 shot, 45 { filter: 'brightness(1) saturate(1)' }, 46 { filter: 'brightness(5) saturate(0)', duration: 0.3, ease: 'power2.in' }, 47 '-=0.4', 48 ) 49 50 // Act 3: Fade in logo mask container 51 if (logoMaskContainer) { 52 screenplay.fromTo(logoMaskContainer, { opacity: 0 }, { opacity: 1, duration: 0.3 }, '-=0.2') 53 } 54 55 // Act 4a: Animate logo mask (zoom in from distance) 56 screenplay.fromTo( 57 logoMask, 58 { scale: 25, x: 3050, y: 900, opacity: 1 }, 59 { 60 scale: 0.3, 61 x: 0, 62 y: 0, 63 opacity: 1, 64 duration: 0.7, 65 ease: 'power2.out', 66 }, 67 '-=0.3', 68 ) 69 70 // Act 4b: Logo Mask Color transition to red (separate timeline for filter) 71 screenplay.fromTo( 72 logoMask, 73 { filter: 'brightness(2) saturate(6) hue-rotate(-11deg) contrast(1)' }, 74 { 75 filter: 'brightness(1.3) saturate(6) hue-rotate(-11deg) contrast(0.5)', 76 duration: 0.3, 77 ease: 'power2.out', 78 }, 79 '<', 80 ) 81 82 // Act 5: Animate hero logo (main brand reveal with original colors) 83 if (heroLogoContainer) { 84 screenplay.fromTo( 85 heroLogoContainer, 86 { scale: 25, x: 3050, y: 900, opacity: 0 }, 87 { scale: 0.3, x: 0, y: 0, duration: 0.7, ease: 'power2.out' }, 88 '<', 89 ) 90 screenplay.fromTo(heroLogoContainer, { opacity: 0 }, { opacity: 1, duration: 0.2 }, '-=0.2') 91 } 92 93 // Final Act: Fade out shot and logo mask 94 screenplay.to(shot, { opacity: 0, duration: 0.2 }, '-=0.2') 95 96 screenplay.to(logoMaskContainer, { opacity: 0, duration: 0.2 }, '>') 97 98 return screenplay 99}

Breakdown of Acts

Act 1: Scroll-Controlled Playback Links the video's playback directly to the scroll position, giving users the feeling of controlling the scene. This is the foundation of the cinematic illusion.

⚠️ Performance Tip: Works best at 30fps for smooth synchronization. If playback feels choppy or laggy, convert your video to 30fps using Video2Edit.

Act 2: Cinematic Glow Transition The video gradually brightens and loses its color, creating a soft fade to white moment that evokes the look of old black-and-white movies and adds a nostalgic cinematic tone before the logo reveal.

Act 3 & 4: Logo Mask Reveal (Most Challenging Segment) This is the most technically demanding part of the sequence. The brand logo appears by showing the video only through the shape of the logotype. The effect is created using mix-blend-mode: multiply with proper z-index layering, keeping the video below and the logo above.

1.scroll-video-player { 2 position: sticky; 3 top: 0; 4 width: 100%; 5 height: 100vh; 6 object-fit: cover; 7 display: block; 8 z-index: 1; 9 inset: 0; 10 /* Performance optimizations */ 11 will-change: transform; 12 transform: translateZ(0); 13 backface-visibility: hidden; 14} 15 16.mask-container { 17 mix-blend-mode: multiply; 18 color: white; 19 background: black; 20} 21 22.mask-container , .logo-container { 23 position: absolute; 24 top: 0; 25 height: 100vh; 26 width: 100%; 27 pointer-events: none; 28 display: flex; 29 align-items: center; 30 justify-content: center; 31 flex-direction: column; 32 z-index: 2; 33 opacity: 0; 34 backface-visibility: hidden; 35}

Then, in Act 4, the mask scales and moves into focus as the video slowly transitions into a rich red tone, creating a dramatic visual moment.

Although this step is complex, it results in a powerful and visually stunning effect.

Act 5: The hero logo enters in its original color.

Final Act: Smoothly fades out video and mask, resetting for the next sequence.

Performance Optimization and Best Practices

Creating cinematic scroll experiences requires more than beautiful animation. It needs consistent, high-performance execution. Follow these techniques to keep your animations smooth and reliable:

  • One ScrollTrigger per narrative to prevent overlapping playheads.
  • Use fromTo() to avoid cached start values.
  • Create timelines only after video metadata is ready.
  • Merge small tweens to minimize layout recalculations.
  • Use blob caching and readiness gates to avoid buffering.
  • Be sure to include muted and playsInline attributes, enabling touch activation on Safari.
  • Call URL.revokeObjectURL on unmount to prevent memory leaks.

Where This Approach Shines: Industries and Use Cases

Scroll-driven storytelling is more than a visual trick. It is a way to communicate through rhythm and movement, helping brands and creators turn motion into emotion. The same technique that powers a cinematic logo reveal can reshape how people experience digital stories across many industries.

🎮 Entertainment and Gaming

Game studios and entertainment platforms can transform their landing pages into interactive previews that mirror the game's atmosphere and mechanics. Instead of showing static screenshots or auto-playing trailers, scroll-driven sequences let players control the reveal, scrolling through epic battles, exploring virtual worlds, or witnessing character transformations frame by frame. This hands-on approach creates anticipation and emotional investment before the player even clicks "Play." It's particularly powerful for story-driven games, where the narrative unfolds gradually, or for gameplay mechanics that benefit from step-by-step demonstration. By giving visitors agency over the pacing, you turn passive promotion into active discovery.

🏛️ Tourism and Cultural Storytelling

Tourism platforms and museums can use scroll-driven storytelling to turn browsing into an experience. As users move through the page, they travel across landscapes, eras, or artworks, feeling time and emotion unfold naturally. Each scroll becomes a step in the journey, revealing destinations, history, or culture in a way that feels alive and memorable.

Of course, there are other approaches like 360° views that let users explore freely, but this one guides them through the story in a clear and intentional way, shaped with the mindset of a director to evoke feeling, emotion, and connection.

🏎️ Automotive and Luxury Brands

Car makers, fashion labels, and premium products can replace static visuals with scroll-based storytelling that highlights design, texture, and motion. Visitors experience the product instead of just viewing it, which helps express quality and craftsmanship in a more sensory way.

🧬 Technology and Innovation

Tech companies can use this approach to explain complex ideas, visualize data, or reveal products step by step. Motion becomes a teaching tool that guides the viewer through transformation and discovery, making information easier to grasp and remember.

🎬 Media and Streaming Platforms

Film studios and streaming services can create interactive teasers that unfold with the scroll, turning promotional pages into small cinematic journeys. It keeps viewers curious and encourages them to explore more deeply before watching the full story.

The Final Word: The Filmmaker's Mindset for Developers

Every great visual story, on screen or on the web, begins with intent. Code, like film, is a language of motion, timing, and emotion. The way you move elements on a screen shapes how users feel, focus, and remember.

  • Give every motion meaning. Don't animate for the sake of movement; let each transition serve the moment or guide attention.
  • Pace the rhythm. In film, silence builds anticipation, and in UI, a pause or gentle fade can say more than speed.
  • Prioritize performance. Even a slight delay or dropped frame can break immersion, so keep animations lean and responsive.
  • Design modularly. Reusable hooks, scenes, and timelines make iteration faster and collaboration easier.
  • Document your vision. Treat your timelines like storyboards so others can understand and refine the sequence you're creating.

A filmmaker's mindset helps developers think beyond pixels and transitions, shaping interactions that feel alive, meaningful, and cinematic.

A filmmaker's mindset helps developers think beyond pixels and transitions, shaping interactions that feel alive, meaningful, and cinematic.

Inspired by

References