Video playback with scrolling

Hi

I want a video to be played in relation to scrolling: scrolling fastly downward: Video plays quickly. Scrolling slowly upward: Video plays backward. Whenever scrolling stops the video should also stop. So the video is in sync with scrolling. You can see it here (laptop and smartphone video): https://www.100pro.ch

How can I achieve this in BS?

That’s certainly a cool effect!

You’ll need to write some custom JavaScript to do this, here’s some quick info on the Video API

If you need some help writing the code, you can try AI tools, or I’m sure someone else on the forum shall get back to you and be able to help with a full solution!

Here’s some JS that does what you are looking for!

To make videos play as you scroll, add the ID scroll-video to the video element.

(function () {
    const videos = document.querySelectorAll("video");
    const targetVideo = document.getElementById("scroll-video");

    // If no videos at all, silently do nothing
    if (videos.length === 0) return;

    // If videos exist but none have the right ID
    if (!targetVideo) {
        console.log(`Found ${videos.length} video element(s), none with the ID "scroll-video".`);
        return;
    }

    let lastScrollY = window.scrollY;
    let playing = false;

    // Ensure video is muted and ready for programmatic play
    targetVideo.muted = true;

    // Function to step the video frame by frame
    function stepVideo() {
        if (!playing) return;
        const direction = (window.scrollY > lastScrollY) ? 1 : -1;
        const frameRate = 1 / 30; // ~30fps step size

        // Adjust time forward or backward
        targetVideo.currentTime = Math.max(
            0,
            Math.min(targetVideo.duration, targetVideo.currentTime + direction * frameRate)
        );

        lastScrollY = window.scrollY;
        requestAnimationFrame(stepVideo);
    }

    // Observe when video is in viewport
    const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                if (!playing) {
                    playing = true;
                    requestAnimationFrame(stepVideo);
                }
            } else {
                playing = false;
            }
        });
    }, { threshold: 0.1 });

    observer.observe(targetVideo);
})();
1 Like

@catkin thanks a lot. Based on your input I could solve it nicely with ChatGPT.

Here’s the solution that worked perfectly for me:

<!doctype html>
<html lang="de">
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<title>Scroll-gekoppeltes Video (Bootstrap 5)</title>

	<!-- Bootstrap 5 CSS (CDN) -->
	<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">

	<style>
		/* Für Demo: Platzhalter-Abschnitte, damit Scrollen möglich ist */
		.spacer { height: 80vh; }

		/* Optional: Sichtbare Trigger-Linie zur Kalibrierung/Debugging */
		.trigger-line {
			position: fixed;
			left: 0; right: 0;
			height: 0;
			border-top: 1px dashed rgba(220, 53, 69, 0.7); /* Bootstrap danger */
			pointer-events: none;
			z-index: 9999;
		}

		/* Responsive Video ĂĽber Bootstrap Ratio */
		.video-wrapper {
			/* Nichts weiter nötig, .ratio kümmert sich um das Seitenverhältnis */
		}
	</style>
</head>
<body class="bg-light">

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

	<section class="container my-5" data-scroll-video data-trigger="0.5">
		<div class="row justify-content-center">
			<div class="col-12 col-md-10">
				<h2 class="mb-3 text-center">Scroll-gekoppeltes Video</h2>

				<div class="ratio ratio-16x9 video-wrapper">
					<video
						preload="metadata"
						muted
						playsinline
						controlslist="nodownload noplaybackrate"
						aria-label="Demo Video"
						poster="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.jpg"
					>
						<!-- Beispielvideo (ersetzen durch eigenes) -->
						<source src="rotatovideo.mp4" type="video/mp4">
						Ihr Browser unterstĂĽtzt das Video-Tag nicht.
					</video>
				</div>

				<p class="mt-3 text-muted small">
					Tipp: Passe den Startpunkt via data-trigger oder in JS (Konstante TRIGGER) an, z.&nbsp;B. 0.6 = 60%.
				</p>
			</div>
		</div>
	</section>

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

	<!-- Optional: Trigger-Linie einblenden (fĂĽr Debugging); per JS-Flag steuerbar -->
	<div id="triggerLine" class="trigger-line" hidden></div>

	<!-- Bootstrap 5 JS (optional, fĂĽr das Beispiel nicht erforderlich) -->
	<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>

	<script>
		// ========= Einstellungen =========
		// Globaler Fallback-Trigger (wird von data-trigger ĂĽberschrieben, falls vorhanden)
		const TRIGGER = 0.5; // 0.5 = 50% des Viewports
		const DEBUG = false; // true -> zeigt rote Trigger-Linie

		// Reduced-motion respektieren: Bei "prefers-reduced-motion" NICHT scrubbing, nur normales Abspielen/Pausieren.
		const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

		const section = document.querySelector('[data-scroll-video]');
		const video = section?.querySelector('video');

		if (!section || !video) {
			console.warn('Scroll-Video: Markup nicht gefunden.');
		} else {
			// Trigger aus data-Attribut lesen (z. B. data-trigger="0.6"), ansonsten Konstante verwenden
			let trigger = parseFloat(section.getAttribute('data-trigger') || TRIGGER);
			if (Number.isNaN(trigger) || trigger < 0 || trigger > 1) trigger = TRIGGER;

			// Debug-Trigger-Linie positionieren
			const lineEl = document.getElementById('triggerLine');
			if (DEBUG && lineEl) {
				lineEl.hidden = false;
				const placeLine = () => {
					lineEl.style.top = (window.innerHeight * trigger) + 'px';
				};
				placeLine();
				window.addEventListener('resize', placeLine, { passive: true });
			}

			// FĂĽr Mobile/Autoplay: muted + playsinline sind bereits gesetzt
			// Wir deaktivieren das "normale" Abspielen; das Video wird per Scroll-Zeit gesteuert.
			video.controls = false; // optional: keine Controls, um Nutzer nicht zu verwirren
			// Falls du Controls möchtest: video.controls = true; (dann aber Scrubbing-Logik ggf. anpassen)

			let duration = 0;
			let startY = 0; // ScrollY-Position, bei der die obere Kante die Trigger-Linie schneidet
			let endY = 0;   // ScrollY-Position, bei der die untere Kante die Trigger-Linie schneidet

			// rAF-Loop-Steuerung
			let ticking = false;

			function computeBounds() {
				const rect = section.getBoundingClientRect();
				const pageY = window.scrollY || window.pageYOffset;
				const triggerY = window.innerHeight * trigger;
				const elemTop = rect.top + pageY;
				const elemBottom = rect.bottom + pageY;

				startY = elemTop - triggerY;
				endY = elemBottom - triggerY;
			}

			function onMetadataReady() {
				duration = video.duration || 0;
				computeBounds();
				// Beim Start sicherstellen, dass der Frame stimmt:
				syncToScroll(true);
			}

			function progressFromScroll(scrollY) {
				// Mappe Scrollposition -> [0,1] Fortschritt innerhalb des "Durchlaufs" ĂĽber die Trigger-Linie
				const raw = (scrollY - startY) / Math.max(1, (endY - startY));
				return Math.min(1, Math.max(0, raw));
			}

			function syncToScroll(force = false) {
				if (!duration) return; // noch keine Metadaten
				const sY = window.scrollY || window.pageYOffset;

				// Wenn Abschnitt gar nicht in der Nähe ist, Video auf Randzustand setzen und sparen
				const buffer = window.innerHeight; // 1 Viewport als Puffer
				if (sY < startY - buffer) {
					if (!REDUCED_MOTION) {
						video.pause();
						if (video.currentTime !== 0) video.currentTime = 0;
					}
					ticking = false;
					return;
				}
				if (sY > endY + buffer) {
					if (!REDUCED_MOTION) {
						video.pause();
						if (video.currentTime !== duration) video.currentTime = duration;
					}
					ticking = false;
					return;
				}

				// Innerhalb des relevanten Bereichs: scrubbing
				const p = progressFromScroll(sY);
				const targetTime = duration * p;

				if (REDUCED_MOTION) {
					// Bei reduced motion nur play/pause an der Trigger-Linie
					if (p > 0 && p < 1) {
						if (video.paused) video.play().catch(() => {});
					} else {
						if (!video.paused) video.pause();
					}
					return;
				}

				// Scrubben ohne "normales" Abspielen
				if (!video.paused) video.pause();
				// Minimale SprĂĽnge filtern (flĂĽssiger)
				const diff = Math.abs(video.currentTime - targetTime);
				if (force || diff > 0.03) {
					video.currentTime = targetTime;
				}

				ticking = false;
			}

			function onScroll() {
				if (!ticking) {
					ticking = true;
					requestAnimationFrame(syncToScroll);
				}
			}

			function onResize() {
				computeBounds();
				// bei Resize einmal hart synchronisieren
				requestAnimationFrame(() => syncToScroll(true));
			}

			// Metadaten abwarten (Dauer)
			if (video.readyState >= 1) {
				onMetadataReady();
			} else {
				video.addEventListener('loadedmetadata', onMetadataReady, { once: true });
			}

			// Verbesserte Performance: nur nahe der Section “aktiv” sein
			const io = ('IntersectionObserver' in window)
				? new IntersectionObserver((entries) => {
						entries.forEach(entry => {
							if (entry.isIntersecting || entry.intersectionRatio > 0) {
								// aktiv
								window.addEventListener('scroll', onScroll, { passive: true });
								window.addEventListener('resize', onResize, { passive: true });
								// direkte Sync beim Eintreten
								requestAnimationFrame(() => syncToScroll(true));
							} else {
								// inaktiv
								window.removeEventListener('scroll', onScroll);
								window.removeEventListener('resize', onResize);
							}
						});
					}, { root: null, rootMargin: '100% 0px 100% 0px', threshold: 0 })
				: null;

			if (io) {
				io.observe(section);
			} else {
				// Fallback ohne IntersectionObserver: immer lauschen
				window.addEventListener('scroll', onScroll, { passive: true });
				window.addEventListener('resize', onResize, { passive: true });
			}

			// Wenn die Quelle wechselt oder der Nutzer spult (falls Controls aktiv): Bounds und Dauer neu setzen
			video.addEventListener('loadedmetadata', onMetadataReady);
			window.addEventListener('orientationchange', onResize);
		}
	</script>
</body>
</html>
3 Likes