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
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
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 gsapStep 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 stateStep 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
mutedandplaysInlineattributes, enabling touch activation on Safari. - Call
URL.revokeObjectURLon 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
- The cinematic pacing, framing, and logo reveal style of Rockstar Games' Grand Theft Auto VI.