Skip to content

Instantly share code, notes, and snippets.

@chrishow
Last active November 16, 2023 13:45
Show Gist options
  • Save chrishow/6a4f6b5170de17fa16bb68fb7b421be3 to your computer and use it in GitHub Desktop.
Save chrishow/6a4f6b5170de17fa16bb68fb7b421be3 to your computer and use it in GitHub Desktop.
Scrub on scroll video demo, https://codepen.io/chrishow/pen/BaMmyoM
<!DOCTYPE html>
<html lang="en" >
<head>
<meta charset="UTF-8">
<title>CodePen - Sticky zoom video on scroll intersection observer with scrubbing</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="./style.css">
</head>
<body>
<!-- partial:index.partial.html -->
<h2>
Keep scrolling down...
</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<p>
Proin facilisis velit arcu, nec dictum mauris sagittis fringilla. Suspendisse potenti.
</p>
<p>
Aenean non nibh dictum, finibus urna accumsan, vulputate mauris.
</p>
<p>
Fusce vehicula augue nec leo placerat vestibulum.
</p>
<p>
Nam in quam arcu. Nam nunc mauris, tincidunt ac gravida quis, pellentesque luctus nunc. Nunc interdum sem sed sapien aliquam tempus.
</p>
<p>
Phasellus sit amet venenatis quam. Sed condimentum laoreet mauris.
</p>
<p>
Proin facilisis velit arcu, nec dictum mauris sagittis fringilla. Suspendisse potenti.
</p>
<p>
Aenean non nibh dictum, finibus urna accumsan, vulputate mauris.
</p>
<p>
Fusce vehicula augue nec leo placerat vestibulum.
</p>
<p>
Nam in quam arcu. Nam nunc mauris, tincidunt ac gravida quis, pellentesque luctus nunc. Nunc interdum sem sed sapien aliquam tempus.
</p>
<p>
Phasellus sit amet venenatis quam. Sed condimentum laoreet mauris.
</p>
<p>
Aenean non nibh dictum, finibus urna accumsan, vulputate mauris.
</p>
<p>
Fusce vehicula augue nec leo placerat vestibulum.
</p>
<p>
Nam in quam arcu. Nam nunc mauris, tincidunt ac gravida quis, pellentesque luctus nunc. Nunc interdum sem sed sapien aliquam tempus.
</p>
<div class='scrub-video-wrapper'>
<div class='scrub-video-container'>
<video src='https://crushed.co.uk/video-scrub-tests/plane-landing-original-ffmpeg-720p-crf18-g2.mp4' muted loop playsinline />
</div>
</div>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vitae nisi et velit iaculis faucibus.
</p>
<p>
Ut vel sem eget orci ullamcorper porta. Sed nisi tellus, convallis eu sollicitudin at, feugiat gravida sapien. Aliquam erat volutpat.
</p>
<p>
Nullam nec facilisis dolor.
</p>
<p>
Etiam tempus suscipit velit sit amet interdum. Donec nec auctor ante. Nulla molestie tortor odio, sed egestas arcu auctor in.
</p>
<p>
Ut sed lacus blandit, lacinia ex eu, posuere enim.
</p>
<p>
Proin facilisis velit arcu, nec dictum mauris sagittis fringilla. Suspendisse potenti.
</p>
<p>
Aenean non nibh dictum, finibus urna accumsan, vulputate mauris.
</p>
<p>
Fusce vehicula augue nec leo placerat vestibulum.
</p>
<p>
Nam in quam arcu. Nam nunc mauris, tincidunt ac gravida quis, pellentesque luctus nunc. Nunc interdum sem sed sapien aliquam tempus.
</p>
<p>
Phasellus sit amet venenatis quam. Sed condimentum laoreet mauris.
</p>
<p>
Etiam tempus suscipit velit sit amet interdum. Donec nec auctor ante. Nulla molestie tortor odio, sed egestas arcu auctor in.
</p>
<p>
Ut sed lacus blandit, lacinia ex eu, posuere enim.
</p>
<p>
Proin facilisis velit arcu, nec dictum mauris sagittis fringilla. Suspendisse potenti.
</p>
<p>
Aenean non nibh dictum, finibus urna accumsan, vulputate mauris.
</p>
<!-- partial -->
<script src="./script.js"></script>
</body>
</html>
document.addEventListener("DOMContentLoaded", function () {
new ScrubVideoManager();
});
class ScrubVideoManager {
constructor() {
// Get a list of all scrub videos wrappers
this.scrubVideoWrappers = document.querySelectorAll(".scrub-video-wrapper");
// Create the intersectionObserver
const observer = new IntersectionObserver(
this.intersectionObserverCallback,
{ threshold: 1 }
);
// Add a pointer to the manager class so we can refer to it later
observer.context = this;
// Initialise empty cache of wrapper data
this.scrubVideoWrappersData = [];
this.scrubVideoWrappers.forEach((wrapper, index) => {
// Attach observer
observer.observe(wrapper.querySelector(".scrub-video-container"));
// Give numerical index
wrapper.setAttribute("data-scrub-video-index", index);
// Store reference to video DOM element
const video = wrapper.querySelector("video");
this.scrubVideoWrappersData[index] = {
video: video
};
// Force load video
this.fetchVideo(video);
});
// Store positions of all the wrapper elements
this.updateWrapperPositions();
document.addEventListener("scroll", (event) => {
this.handleScrollEvent(event);
});
window.addEventListener("resize", () => {
this.updateWrapperPositions();
});
}
fetchVideo(videoElement) {
const src = videoElement.getAttribute("src");
// Get the video
fetch(src)
.then((response) => response.blob())
.then((response) => {
// Create a data url containing the video raw data
const objectURL = URL.createObjectURL(response);
// Attach the
videoElement.setAttribute("src", objectURL);
console.log("Finished loading " + src);
});
}
updateWrapperPositions() {
// Get new positions of video wrappers
this.scrubVideoWrappers.forEach((wrapper, index) => {
const clientRect = wrapper.getBoundingClientRect();
const top = clientRect.y + window.scrollY;
const bottom = clientRect.bottom - window.innerHeight + window.scrollY;
this.scrubVideoWrappersData[index].top = top;
this.scrubVideoWrappersData[index].bottom = bottom;
});
}
intersectionObserverCallback(entries, observer) {
entries.forEach((entry) => {
const isWithinViewport = entry.intersectionRatio === 1;
// Add class 'in-view' to element if
// it is within the viewport
entry.target.classList.toggle("in-view", isWithinViewport);
if (isWithinViewport) {
observer.context.activeVideoWrapper = entry.target.parentNode.getAttribute(
"data-scrub-video-index"
);
} else {
observer.context.activeVideoWrapper = null;
}
});
}
handleScrollEvent = function (event) {
// Is there are currently active video wrapper?
if (this.activeVideoWrapper) {
const activeWrapperData = this.scrubVideoWrappersData[
this.activeVideoWrapper
];
const top = activeWrapperData.top;
const bottom = activeWrapperData.bottom;
const video = activeWrapperData.video;
const progress = Math.max(
Math.min((window.scrollY - top) / (bottom - top), 0.998),
0
);
const seekTime = progress * video.duration;
// console.log(`${lower} > ${window.scrollY} (${progress}) [${seekTime}] > ${upper}`);
video.currentTime = seekTime;
}
};
}
:root {
--side-margin: 5rem;
}
@media screen and (max-width: 600px) {
:root {
--side-margin: 2rem;
}
}
body {
font-family: Inter, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
body {
margin: 0;
}
h2,
p {
margin-left: var(--side-margin);
margin-right: var(--side-margin);
}
.scrub-video-container {
margin-left: 5rem;
margin-right: 5rem;
position: sticky;
top: 0px;
height: 100vh;
transition: all 0.2s;
}
.scrub-video-container.in-view {
margin: 0;
}
.scrub-video-container.in-view video {
top: 0;
height: 100%;
}
.scrub-video-wrapper {
height: 600vh;
}
video {
position: absolute;
top: 3em;
left: 0;
width: 100%;
height: calc(100% - 6em);
object-fit: cover;
transition: all 0.2s;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment