<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hadi Moussawi</title>
    <description>The latest articles on DEV Community by Hadi Moussawi (@hadimoussawi).</description>
    <link>https://dev.to/hadimoussawi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4134008%2Fbf3d5073-92f9-435c-9726-8a4068f847a1.jpg</url>
      <title>DEV Community: Hadi Moussawi</title>
      <link>https://dev.to/hadimoussawi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hadimoussawi"/>
    <language>en</language>
    <item>
      <title>How I built a scroll-driven video hero that still scores 100</title>
      <dc:creator>Hadi Moussawi</dc:creator>
      <pubDate>Sun, 20 Sep 2026 11:08:28 +0000</pubDate>
      <link>https://dev.to/hadimoussawi/how-i-built-a-scroll-driven-video-hero-that-still-scores-100-f63</link>
      <guid>https://dev.to/hadimoussawi/how-i-built-a-scroll-driven-video-hero-that-still-scores-100-f63</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://hadimoussawi.com/scroll-driven-video-hero" rel="noopener noreferrer"&gt;hadimoussawi.com&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The hero on &lt;a href="https://hadimoussawi.com" rel="noopener noreferrer"&gt;my site&lt;/a&gt; is a ten second film. Your scroll is its timeline: scroll down and the camera descends, scroll back and it climbs. It streams while it downloads, it never loads at all on phones, and the page still scores 100 for performance in Lighthouse.&lt;/p&gt;

&lt;p&gt;Here is how it works, and the four traps that cost me the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;A video that plays on scroll is not a video that autoplays. There is no playback at all. The page maps scroll position to &lt;code&gt;video.currentTime&lt;/code&gt;, so the viewer is scrubbing, exactly like dragging the playhead in a video editor. The hero is seven screens tall and pinned, and five caption bands fade in and out across that distance.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The scroll engine never touches React state
&lt;/h2&gt;

&lt;p&gt;Scroll drives the video and five caption bands about sixty times a second. Routing that through React state would mean a re-render per animation frame, which is exactly the cost this design cannot afford. So React owns the markup, and a hook owns the loop, writing to nodes through refs, only when a value actually changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// normalised to a 60fps reference, so the feel is frame rate independent:&lt;/span&gt;
&lt;span class="c1"&gt;// a plain per-frame constant converges twice as fast on a 120Hz screen&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;16.667&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. The easing is time-based, not frame-based, so a 120Hz laptop and a 60Hz monitor feel the same. And the loop stops itself once the value converges, instead of running forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Seeks have to be gated
&lt;/h2&gt;

&lt;p&gt;Writing &lt;code&gt;currentTime&lt;/code&gt; while a seek is already in flight piles requests up, and that pile is the difference between smooth and choppy in Chrome. One flag, one pending target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seekBusy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pendingTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// coalesce: keep only the newest target&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;seekBusy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// on 'seeked': release, then run the one pending target, if any&lt;/span&gt;
&lt;span class="c1"&gt;// on 'error':  release as well, or a failed seek deadlocks the gate forever&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error handler is not optional. Without it, one failed seek leaves the gate closed and the hero freezes for good.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The film plays while it is still downloading
&lt;/h2&gt;

&lt;p&gt;The first version fetched the whole file, then started. On a normal connection that meant the picture sat frozen for eight to fourteen seconds after the page had visibly finished loading. Anyone who scrolled immediately thought the site was broken.&lt;/p&gt;

&lt;p&gt;The fix has two halves. First, the file is encoded as a fragmented MP4, with a keyframe and a fragment every eight frames:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; master.mp4 &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-level&lt;/span&gt; 3.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-crf&lt;/span&gt; 27 &lt;span class="nt"&gt;-preset&lt;/span&gt; slow &lt;span class="nt"&gt;-g&lt;/span&gt; 8 &lt;span class="nt"&gt;-keyint_min&lt;/span&gt; 8 &lt;span class="nt"&gt;-sc_threshold&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; frag_keyframe+empty_moov+default_base_moof hero.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, each chunk of the download is handed straight to the video through Media Source, and every seek is held to the frames that have actually arrived:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fullyLoaded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;EDGE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;behind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// the scroll is ahead of the download&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;edge&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// nothing decodable yet&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// show the furthest frame that has arrived&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When more data lands, anything marked &lt;code&gt;behind&lt;/code&gt; is re-seeked, so the picture catches up on its own. Measured on the live site: the picture starts following the scroll &lt;strong&gt;1.5 seconds&lt;/strong&gt; after the page opens, and the whole file is in by 3.3 seconds. Before, nothing moved for twelve.&lt;/p&gt;

&lt;p&gt;Browsers without Media Source get the whole file as a Blob, which is also why the video is fetched by the page rather than handed to the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element: plenty of hosts silently lack HTTP Range support, and without it every seek clamps to zero. Scrubbing then works locally and does nothing in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Phones never download the film at all
&lt;/h2&gt;

&lt;p&gt;A scrubbed video is a desktop idea. On phones it is a 2MB download to fight with a touch scroll. Five conditions swap the whole thing for a composed still:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(max-width: 720px)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(orientation: portrait) and (max-width: 1024px)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(orientation: portrait) and (pointer: coarse)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(orientation: landscape) and (pointer: coarse) and (max-height: 560px)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These strings have to stay character for character identical to the media query list in the stylesheet, or one side loads what the other side hides. They are also checked live, not once at load: a tablet rotating, a window being maximised, or reduced motion switched off mid-session all re-evaluate the CSS, so the JavaScript has to re-evaluate with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four traps that cost me the most
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A page fade that hid the Largest Contentful Paint.&lt;/strong&gt; The page faded in from &lt;code&gt;opacity: 0&lt;/code&gt;. Chrome discards text first painted at zero opacity, and a compositor fade never repaints it, so mobile had no measurable LCP at all: a failed Core Web Vital, and a performance score of zero. Removing the fade fixed it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prerendering revealed the headline three times.&lt;/strong&gt; The split-text animation renders a blurred copy and a sharp copy over the real line. Invisible in a client-rendered page; in prerendered HTML, search engines and link previews read "Hadi MoussawiHadi MoussawiHadi Moussawi". Now the animated copies carry no text: their letters are drawn with &lt;code&gt;content: attr(data-ch)&lt;/code&gt;, which is not page text.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A strict Content-Security-Policy broke the prerendered page.&lt;/strong&gt; Prerendered HTML arrives with about 120 inline &lt;code&gt;style&lt;/code&gt; attributes: per-letter animation offsets, the FAQ's collapsed height, the form's hidden spam trap. &lt;code&gt;style-src 'self'&lt;/code&gt; blocks those at parse time, and React does not reapply them on hydration. The fix is to allow inline style attributes while &lt;code&gt;style-src-elem 'self'&lt;/code&gt; still forbids inline &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A cached copy of the old video broke the new player.&lt;/strong&gt; Files in &lt;code&gt;/assets&lt;/code&gt; are cached for a week. When the re-encoded, fragmented file shipped under the same name, returning visitors kept the old one, which Media Source cannot play, so the hero failed outright for them. A new file name misses every stale cache. This is the one I would most want you to remember.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it measures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lighthouse on the live site: 100 performance, 100 accessibility, 100 best practices, 100 SEO.&lt;/li&gt;
&lt;li&gt;Largest Contentful Paint 0.47s on desktop, 1.64s on mobile.&lt;/li&gt;
&lt;li&gt;The page is prerendered, so about 1,450 words are readable with JavaScript switched off.&lt;/li&gt;
&lt;li&gt;Worst-case text contrast over the moving film, measured pixel by pixel: 8.76:1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Would I do it again?
&lt;/h2&gt;

&lt;p&gt;For a portfolio, a venue, a product launch: yes. For a business that needs to answer a question and take a booking: no, and I would say so. The interesting part is not the effect. It is that a page can carry a ten-second film and still load faster than most text-only sites, if every heavy thing is made optional and nothing lies to the browser.&lt;/p&gt;

&lt;p&gt;You can scroll the real thing at &lt;a href="https://hadimoussawi.com" rel="noopener noreferrer"&gt;hadimoussawi.com&lt;/a&gt;, and the full write-up lives at &lt;a href="https://hadimoussawi.com/scroll-driven-video-hero" rel="noopener noreferrer"&gt;hadimoussawi.com/scroll-driven-video-hero&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
