Imported library

← Imported library
Free sampleMIT licence

ScrubVideo - A scroll-scrubbed <video> whose currentTime is driven by scroll progress (rAF, requestVideoFrameCallback when available) inside a pinned stage, degrading on iOS

scroll effect

Scroll effect from MustBeSimo/web-design-studio.

From prompt to live site

  1. 01

    Copy the code

    Grab every file as one text, each carrying its licence notice.

  2. 02

    Paste into your AI

    Drop it into Claude, Cursor or v0 — or straight into your own project.

  3. 03

    Launch it live

    Adapt the copy, wire your data, and ship — with the notice kept alongside.

Read the full code
ScrubVideo - A scroll-scrubbed <video> whose currentTime is driven by scroll progress (rAF, requestVideoFrameCallback when available) inside a pinned stage, degrading on iOS
MustBeSimo/web-design-studio@c8b99b171c7c · 2 code file(s)

Licence notice — required by the licence; keep it with this code:

MIT License

Copyright (c) 2026 Simone Leonelli

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

--- mode-a/scrub-video.html ---
<!doctype html>
<!--
MIT License

Copyright (c) 2026 Simone Leonelli

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ScrubVideo — cinematic-scroll component</title>
<!--
  ScrubVideo — a <video> whose currentTime is driven by scroll progress.
  Desktop: pin the stage (sticky), map track scroll → currentTime, seek on rAF
  (requestVideoFrameCallback when the browser exposes it, for frame-accurate scrub).
  Touch / iOS: scrubbing a <video> is blocked — Safari gates currentTime seeks
  behind a user gesture and won't decode off-gesture — so we degrade to the poster
  still. For a true scrubbed look on touch, swap the <video> for a canvas
  image-sequence (drawImage of /frames/NNNN.webp, preloaded) — see note in script.
  Token-driven, transform/opacity only, reduced-motion safe, no dependencies.
-->
<style>
  /* --- design tokens (subset of tokens/build/variables.css; only what we use) --- */
  :root{
    --bg:#F7F5F1; --surface:#EFEBE4; --fg:#161310; --fg-dim:#574F45; --accent:#C9A96E;
    --font-display:"Cormorant Garamond",Georgia,serif;
    --font-body:"EB Garamond",Georgia,"Times New Roman",serif;
    --font-ui:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
    --fluid-h1:clamp(2.37rem, 1.8rem + 2.52vw, 3.815rem);
    --size-caption:.78rem;
    --space-md:16px; --space-lg:24px;
    --ease-reveal:cubic-bezier(.16,1,.3,1);
    --ease-exit:cubic-bezier(.7,0,.84,0);
    --dur-slow:720ms;
    --lh-tight:1.1; --lh-relaxed:1.7;
  }
  *{box-sizing:border-box;margin:0}
  body{background:var(--bg);color:var(--fg);font-family:var(--font-body);line-height:var(--lh-relaxed)}
  .spacer{height:60vh;display:grid;place-items:center;color:var(--fg-dim);font-family:var(--font-ui);
    font-size:var(--size-caption);letter-spacing:.2em;text-transform:uppercase}

  /* the scroll "track" is tall; the stage pins inside it via sticky */
  .scrub{position:relative;height:320vh}
  .stage{position:sticky;top:0;height:100vh;display:grid;place-items:center;overflow:clip;background:var(--surface)}
  .frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
  /* poster sits over the video; we cross-fade it out (exit curve) once the video can paint */
  .poster{opacity:1;transition:opacity var(--dur-slow) var(--ease-exit)}
  .scrub[data-ready] .poster{opacity:0}

  .caption{position:relative;z-index:1;max-width:42rem;padding:var(--space-lg);text-align:center;
    color:var(--bg);text-shadow:0 1px 24px #16131066}
  .eyebrow{font-family:var(--font-ui);font-size:var(--size-caption);letter-spacing:.22em;
    text-transform:uppercase;opacity:.86}
  .title{font-family:var(--font-display);font-size:var(--fluid-h1);line-height:var(--lh-tight);
    font-weight:600;margin-top:var(--space-md)}
  .accent{color:var(--accent)}
  :focus-visible{outline:2px solid var(--accent);outline-offset:3px}

  /* caption rises in once — transform + opacity only, reveal curve */
  .reveal{opacity:0;transform:translateY(40px);
    transition:opacity var(--dur-slow) var(--ease-reveal),transform var(--dur-slow) var(--ease-reveal)}
  .is-in .reveal{opacity:1;transform:none}
  .is-in .reveal:nth-child(2){transition-delay:90ms}

  @media (max-width: 768px){
    .title{font-size:2rem}
  }
  /* touch / iOS: the scrub is dead here — show the poster, collapse the tall track */
  @media (hover:none) and (pointer:coarse){
    .scrub{height:auto}                 /* no scroll track — nothing to scrub */
    .stage{position:static;height:auto;aspect-ratio:16/9}
    video.frame{display:none}           /* the poster <img> carries the visual */
  }
  @media (prefers-reduced-motion: reduce){
    .reveal{opacity:1;transform:none;transition:none}
    .poster{transition:none}
  }
</style>
</head>
<body>
  <div class="spacer">scroll ↓</div>

  <main>
    <!-- aria-labelledby points the section at its visible title -->
    <section class="scrub" id="scrub" aria-labelledby="sv-title">
      <div class="stage">
        <!-- poster still: the stable fallback on touch/iOS and before the video paints -->
        <img class="frame poster" id="sv-poster"
             src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='9'/%3E"
             alt="Atelier chronometer, dial open, mid-assembly.">
        <!-- scrubbed video: muted + playsinline + poster + aria-label (never autoplays) -->
        <video class="frame" id="sv-video" preload="auto" muted playsinline
               poster="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='9'/%3E"
               aria-label="Chronometer assembly, scrubbed by scroll position.">
          <!-- <source src="/footage/assembly.mp4" type="video/mp4"> -->
        </video>
        <div class="caption">
          <p class="eyebrow reveal">Movement 03</p>
          <h2 class="title reveal" id="sv-title">Scroll drives the <span class="accent">frame</span>.</h2>
        </div>
      </div>
    </section>
  </main>

  <div class="spacer">end</div>

<script>
  // Reveal the caption once the stage enters view (IntersectionObserver — no scroll handler for this).
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); }
  }, { threshold: 0.4 });
  document.querySelectorAll(".stage").forEach((s) => io.observe(s));

  const track = document.getElementById("scrub");
  const video = document.getElementById("sv-video");

  // Degrade contract: touch / coarse-pointer (iOS Safari) can't scrub a <video>
  // off-gesture — bail to the poster. (Image-sequence canvas is the touch upgrade:
  // preload /frames/NNNN.webp, then drawImage(frames[Math.round(p*(n-1))]) on rAF.)
  const isTouch = window.matchMedia("(hover:none) and (pointer:coarse)").matches;
  const reduce  = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  if (!isTouch && !reduce && video) {
    let duration = 0, target = 0, ticking = false;
    const onMeta = () => { duration = video.duration || 0; track.setAttribute("data-ready", ""); };
    video.addEventListener("loadedmetadata", onMeta);
    if (video.readyState >= 1) onMeta();

    // scroll progress 0→1 across the track — rAF-throttled, never seek in the handler (§1.8)
    const progress = () => {
      const r = track.getBoundingClientRect();
      const span = r.height - window.innerHeight;
      return span <= 0 ? 0 : Math.min(1, Math.max(0, -r.top / span));
    };
    const seek = () => {
      ticking = false;
      if (duration) video.currentTime = target * duration;
    };
    const useRVFC = "requestVideoFrameCallback" in video;   // frame-accurate when available
    const onScroll = () => {
      target = progress();
      if (ticking) return;
      ticking = true;
      (useRVFC ? video.requestVideoFrameCallback(seek) : requestAnimationFrame(seek));
    };
    addEventListener("scroll", onScroll, { passive: true });
    onScroll();
  }
</script>
</body>
</html>

--- mode-b/scrub-video.tsx ---
/*!
MIT License

Copyright (c) 2026 Simone Leonelli

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
'use client';

/**
 * ScrubVideo — a <video> whose currentTime is driven by scroll progress.
 *
 * Desktop: pin the stage (sticky), map track scroll → currentTime, seek on
 * requestVideoFrameCallback (frame-accurate) with a rAF fallback.
 * Touch / iOS: Safari blocks off-gesture currentTime seeks, so we degrade to
 * the poster still. For a true scrubbed look on touch, swap the <video> for a
 * canvas image-sequence (preloaded /frames/NNNN.webp + drawImage on rAF).
 *
 * Token-driven (tokens/build/variables.css), transform/opacity only, and
 * reduced-motion / touch safe via gsap.matchMedia.
 */

import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger);

export interface ScrubVideoProps {
  /** Scrubbed clip (muted, playsInline). Hidden on touch — the poster carries it. */
  src: string;
  /** Poster still: the stable fallback on touch/iOS and before the video paints. */
  poster: string;
  /** Drives both the video aria-label and the poster alt — required for a11y. */
  label: string;
  eyebrow?: string;
  title?: string;
  /** Track height as a multiple of the viewport — longer = slower scrub. Default 3.2. */
  trackVh?: number;
}

export default function ScrubVideo({
  src,
  poster,
  label,
  eyebrow = 'Movement 03',
  title = 'Scroll drives the frame.',
  trackVh = 3.2,
}: ScrubVideoProps) {
  const root = useRef<HTMLElement>(null);
  const video = useRef<HTMLVideoElement>(null);

  useGSAP(
    () => {
      const mm = gsap.matchMedia();

      // Desktop, fine pointer, motion allowed → scrub. Everything else stays on the poster.
      mm.add(
        '(hover:hover) and (pointer:fine) and (prefers-reduced-motion: no-preference)',
        () => {
          const v = video.current;
          const track = root.current;
          if (!v || !track) return;

          let duration = v.duration || 0;
          let target = 0;
          let ticking = false;
          const useRVFC = 'requestVideoFrameCallback' in v;

          const onMeta = () => {
            duration = v.duration || 0;
            track.dataset.ready = ''; // cross-fades the poster out via CSS
          };
          v.addEventListener('loadedmetadata', onMeta);
          if (v.readyState >= 1) onMeta();

          // scroll progress 0→1 across the track; rAF/rVFC-throttled, never seek in the handler (§1.8)
          const progress = () => {
            const r = track.getBoundingClientRect();
            const span = r.height - window.innerHeight;
            return span <= 0 ? 0 : Math.min(1, Math.max(0, -r.top / span));
          };
          const seek = () => {
            ticking = false;
            if (duration) v.currentTime = target * duration;
          };
          const onScroll = () => {
            target = progress();
            if (ticking) return;
            ticking = true;
            useRVFC ? v.requestVideoFrameCallback(seek) : requestAnimationFrame(seek);
          };

          window.addEventListener('scroll', onScroll, { passive: true });
          onScroll();
          return () => {
            window.removeEventListener('scroll', onScroll);
            v.removeEventListener('loadedmetadata', onMeta);
          };
        },
      );

      // Caption rise — once, transform/opacity only, reveal curve. Skipped under reduced-motion.
      mm.add('(prefers-reduced-motion: no-preference)', () => {
        gsap.from('.sv-reveal', {
          y: 40,
          autoAlpha: 0,
          duration: 0.72,
          ease: 'power3.out', // --ease-reveal
          stagger: 0.09,
          scrollTrigger: { trigger: root.current, start: 'top 60%' },
        });
      });
    },
    { scope: root },
  );

  return (
    <section
      ref={root}
      className="sv-track"
      aria-labelledby="sv-title"
      style={{ position: 'relative', height: `${trackVh * 100}vh` }}
    >
      <div className="sv-stage">
        {/* poster still — fallback on touch/iOS and before the video can paint */}
        <img className="sv-frame sv-poster" src={poster} alt={label} />
        {/* scrubbed video — muted + playsInline + poster + aria-label; never autoplays */}
        <video
          ref={video}
          className="sv-frame sv-video"
          poster={poster}
          muted
          playsInline
          preload="auto"
          aria-label={label}
        >
          <source src={src} type="video/mp4" />
        </video>
        <div className="sv-caption">
          <p className="sv-eyebrow sv-reveal">{eyebrow}</p>
          <h2 className="sv-title sv-reveal" id="sv-title">
            {title}
          </h2>
        </div>
      </div>

      {/* Co-located token CSS — transform/opacity only, touch degrade, reduced-motion safe. */}
      <style>{`
        .sv-stage{position:sticky;top:0;height:100vh;display:grid;place-items:center;overflow:clip;background:var(--surface)}
        .sv-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
        .sv-poster{opacity:1;transition:opacity var(--dur-slow) var(--ease-exit)}
        .sv-track[data-ready] .sv-poster{opacity:0}
        .sv-caption{position:relative;z-index:1;max-width:42rem;padding:var(--space-lg);text-align:center;
          color:var(--bg);text-shadow:0 1px 24px #16131066}
        .sv-eyebrow{font-family:var(--font-ui);font-size:var(--size-caption);letter-spacing:.22em;
          text-transform:uppercase;opacity:.86}
        .sv-title{font-family:var(--font-display);font-size:var(--fluid-h1);line-height:var(--lh-tight);
          font-weight:600;margin-top:var(--space-md)}
        .sv-track :focus-visible{outline:2px solid var(--accent);outline-offset:3px}
        @media (max-width:768px){ .sv-title{font-size:2rem} }
        @media (hover:none) and (pointer:coarse){
          .sv-track{height:auto !important}
          .sv-stage{position:static;height:auto;aspect-ratio:16/9}
          .sv-video{display:none}            /* poster carries the visual on touch */
        }
        @media (prefers-reduced-motion: reduce){
          .sv-poster{transition:none}
        }
      `}</style>
    </section>
  );
}
Source and licence

Source: MustBeSimo/web-design-studio@c8b99b171c7c

Author: 2026 Simone Leonelli

Entry: components/mode-a/scrub-video.html

  • Mode B (.tsx) imports gsap, gsap/ScrollTrigger, @gsap/react, react
  • expects a user video at /footage/assembly.mp4 (not shipped)
MIT License

Copyright (c) 2026 Simone Leonelli

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Files (2)
  • mode-a/scrub-video.html (7,145 bytes)
  • mode-b/scrub-video.tsx (6,393 bytes)

Want the whole library?

Every item's prompt, code and instructions, with a CreativePower subscription.

See plans

More scroll effect templates