I created code for a video animation effect that scrolls and centers the screen on an embedded video and then enlarges it to fill the screen. My goal is to emulate the same effect shown on this site here:
https://www.themarshallproject.org/2019/09/24/detained
Here's a code sample showing the basic effect with the issues I mentioned:
/////////////////////////////
// Helper Functions
//////////////////////////////
/**
* Triggers a callback function when the specified element scrolls into range. The callback function is called again once the element scrolls outside of the given range.
*
* Example usage:
*
* scrollTriggeredCallback(".triggerElement", 50, function(triggered) {
* if (triggered === true)
* // Do something here
* else
* // Do something else here
* });
*
* NOTE: This version of the function uses the viewport to calculate the scroll position of the element within the screen.
* Scroll position = 0% when the element's top boundary is at the bottom of the viewport
* Scroll position = 100% when the element's bottom boundary is at the top of the viewport
*
* @param {*} triggerElements The element(s) to trigger the callback on
* @param {*} percentage Percentage value to trigger the animation
* @param {*} callback Function to call (with single parameter specfying whether animation was triggered or not) once animation trigger state changes
* If animation is triggered, input parameter is set to true and false otherwise.
*/
function scrollTriggeredCallback(triggerElements, percentage, callback) {
// Get references to the HTML element to work with
triggerElements = getDOMObject(triggerElements, true);
triggerElements.forEach(triggerElement => {
var triggerElementRect = triggerElement.getBoundingClientRect();
// Calculate the scroll position of the element within the viewport
let scrollPosition = triggerElementRect.top + triggerElementRect.height; // Sticky height >= viewport height
let scrollHeight = window.innerHeight + triggerElementRect.height;
let percentScrolled = 100 - (scrollPosition / scrollHeight) * 100;
// Limit the scroll range to 0-100%
if (percentScrolled > 100)
percentScrolled = 100;
else if (percentScrolled < 0)
percentScrolled = 0;
// Add the animation CSS selector to the given element if the percentScrolled value is within the given percentage range, and remove it otherwise
if (percentScrolled >= percentage) {
if(!triggerElement.classList.contains("triggered")) {
triggerElement.classList.add("triggered");
callback(true, triggerElement);
}
}
else if (percentScrolled <= 0) {
if (triggerElement.classList.contains("triggered")) {
triggerElement.classList.remove('triggered');
callback(false, triggerElement);
}
}
});
}
/**
* Animates the given element to scale it from it's current position to fill the full viewport, creating a full-screen, zoom-in/lightbox effect
*
* @param {string} element The element to trigger the callback on
* @param {string} placeholder Percentage value to trigger the animation
* @param {integer} duration Full time for the animation to complete in milliseconds
* @param {string} easingCurve (Optional) - Characteristic easing curve to use when animating (see easingFunction documentation for details)
* Defaults to linear if no curve type is specified
* @param {function} callback Optional callback to execute after the animation completes
*/
function scaleToFullScreen(element, placeholder, duration, reverse, easingCurve, callback) {
element = getDOMObject(element);
placeholder = getDOMObject(placeholder);
let topDistance, leftDistance, heightDifference, widthDifference = 0;
// Calculate relative distances to translate top/left position and height/width from current position to fill viewport
let elementRect = element.getBoundingClientRect()
let placeholderRect = placeholder.getBoundingClientRect();
if (reverse === false) {
topDistance = -placeholderRect.top;
leftDistance = -placeholderRect.left;
heightDifference = window.innerHeight - placeholderRect.height;
widthDifference = window.innerWidth - placeholderRect.width;
} else {
[leftDistance, topDistance] = getCSSTranslationOffset(element);
heightDifference = window.innerHeight - placeholderRect.height;
widthDifference = window.innerWidth - placeholderRect.width;
}
let startTime, previousTimeStamp;
function stepAnimate(timestamp) {
if (startTime === undefined) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
if (previousTimeStamp !== timestamp) {
let deltaX, deltaY, height, width = 0.0;
// Calculate the new top, left, width, and height values based on the elapsed time
if (reverse === false) {
deltaX = incrementOverRange(startTime + elapsed, startTime, startTime + duration, 0, leftDistance);
deltaY = incrementOverRange(startTime + elapsed, startTime, startTime + duration, 0, topDistance);
height = incrementOverRange(startTime + elapsed, startTime, startTime + duration, placeholderRect.height, placeholderRect.height + heightDifference);
width = incrementOverRange(startTime + elapsed, startTime, startTime + duration, placeholderRect.width, placeholderRect.width + widthDifference);
} else {
deltaX = incrementOverRange(startTime + elapsed, startTime, startTime + duration, leftDistance, 0);
deltaY = incrementOverRange(startTime + elapsed, startTime, startTime + duration, topDistance, 0);
height = incrementOverRange(startTime + elapsed, startTime, startTime + duration, placeholderRect.height + heightDifference, placeholderRect.height);
width = incrementOverRange(startTime + elapsed, startTime, startTime + duration, placeholderRect.width + widthDifference, placeholderRect.width);
}
// Apply the easing curve to each value
//deltaX = -easingFunction(-deltaX, 0, Math.abs(leftDistance), easingCurve);
//deltaY = -easingFunction(-deltaY, 0, Math.abs(topDistance), easingCurve);
//height = easingFunction(height, placeholderRect.height, placeholderRect.height + heightDifference, easingCurve);
//width = easingFunction(width, placeholderRect.width, placeholderRect.width + widthDifference, easingCurve);
// Update the element top, left, height and width through CSS
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
element.style.height = `${height}px`;
element.style.width = `${width}px`;
// Calculate the current scroll position between startPosition and middle of the element to show based on the time elapsed
//doc.scrollTop = easingFunction(currentPosition, startPosition, middle, easingCurve);
}
// Stop the animation after the duration has elaopsed
if (elapsed < duration) {
previousTimeStamp = timestamp;
window.requestAnimationFrame(stepAnimate);
} else {
if (callback) {
callback(element, placeholder);
}
}
}
// Begin the animation
window.requestAnimationFrame(stepAnimate);
}
/**
* Scrolls the given element into the center of the viewport using the given duration and easing curve. Once the animation completes, the optional callback function can be called
* @param {string} elememt Selector for the element to scroll into view
* @param {int} duration Time (in ms) for the animation to run
* @param {string} easingCurve Easing curve function to use
* @param {func} callback (Optional) - Characteristic easing curve to use when animating (see easingFunction documentation for details)
* Defaults to linear if no curve type is specified
*/
function scrollToElement(element, duration, easingCurve, callback) {
element = getDOMObject(element);
const elementRect = element.getBoundingClientRect();
let doc = document.documentElement;
if(doc.scrollTop === 0){
var t = doc.scrollTop;
++doc.scrollTop;
doc = (t + 1 === doc.scrollTop-- ? doc : document.body);
}
const startPosition = doc.scrollTop;
const absoluteElementTop = elementRect.top + window.pageYOffset;
const middle = absoluteElementTop - (window.innerHeight / 2) + (elementRect.height / 2);
let startTime, previousTimeStamp;
function stepScroll(timestamp) {
if (startTime === undefined) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
if (previousTimeStamp !== timestamp) {
// Calculate the current scroll position between startPosition and middle of the element to show based on the time elapsed
let currentPosition = incrementOverRange(startTime + elapsed, startTime, startTime + duration, startPosition, middle);
doc.scrollTop = currentPosition; //easingFunction(currentPosition, startPosition, middle, easingCurve);
}
// Stop the animation after the duration has elaopsed
if (elapsed < duration) {
previousTimeStamp = timestamp;
window.requestAnimationFrame(stepScroll);
} else {
if (callback) {
callback(element);
}
}
}
// Begin the animation
window.requestAnimationFrame(stepScroll);
}
/**
* Returns the X and Y offsets of an element due to it being shifted using the CSS Transform translate method
* @param {*} element Element reference (CSS selector string or DOMObject)
* @returns An array containing the X and Y offset coordinates
*/
function getCSSTranslationOffset(element) {
element = getDOMObject(element);
const computedStyle = window.getComputedStyle(element);
const transformMatrix = new DOMMatrix(computedStyle.transform);
const translationX = transformMatrix.m41;
const translationY = transformMatrix.m42;
return [transformMatrix.m41, transformMatrix.m42]
}
/**
* Returns the object reference for an element or passes along the element object(s) if an object or array was provided
* @param {*} element Element reference (can be either CSS selector string or DOMObject)
* @param {bool} isArray Set to true if the selector is expected to return more than one result
*/
function getDOMObject(element, isArray) {
if (Object.prototype.toString.call(element) === "[object String]") {
if (isArray === true) {
return document.querySelectorAll(element);
} else {
return document.querySelector(element);
}
} else {
return element;
}
}
/**
* Returns a value within a custom range based on the input percent scrolled value
* @param {*} percentValue Value to be transformed from the start/end percent range to the min/max value range
* @param {*} startPercent Starting percentage value to begin incrementing the value range
* @param {*} endPercent Ending percentage value to end incrementing the value range
* @param {*} minValue Starting value of the value range
* @param {*} maxValue Ending value of the value range
* @returns The corresponding value within the value range
*/
function incrementOverRange(percentValue, startPercent, endPercent, minValue, maxValue) {
// Limit input range to start/end Percent range
if (percentValue < startPercent)
percentValue = startPercent;
else if (percentValue > endPercent)
percentValue = endPercent;
// NOTE: Formula borrowed from Arduino map() function
return ((percentValue - startPercent) * (maxValue - minValue) / (endPercent - startPercent) + minValue);
}
/**
* Transforme the linear input value between min and max according to the easing function type specified by easingType
* NOTE: Easing function reference: https://easings.net/
*
* @param {*} value Input value to transform
* @param {*} min Min value in range
* @param {*} max Max value in range
* @param {*} easingType Easing curve type. Current values are:
* - sine, easeOutQuint, exponential
*/
function easingFunction(value, min, max, easingType) {
// Sanity Checks
if (max <= min)
console.warn("Invalid input min/max values to easingFunction()");
if (value > max)
value = max;
if (value < min)
value = min;
// Pre-calculations
let range = max - min;
let transformedValule = 0;
// Convert input value to unit range
x = (value - min) / range;
switch (easingType) {
case "sine":
transformedValue = 0.5 * Math.sin(Math.PI * x - Math.PI / 2) + 0.5;
break;
case "easeInOutQuint":
transformedValue = x < 0.5 ? 16 * x * x * x * x * x : 1 - Math.pow(-2 * x + 2, 5) / 2;
break;
case "easeOutQuint":
transformedValue = 1 - Math.pow(1 - x, 5);
break;
case "exponential":
transformedValue = x === 0 ? 0 : x === 1 ? 1 : x < 0.5 ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2;
break;
default: // "linear"
return value;
}
// Convert value back to original range
return (range * transformedValue + min);
}
/////////////////////////////
// Main scroll event listener
//////////////////////////////
var scrollOffset = null;
var currentPlaceholder = null;
var currentVideo = null;
window.addEventListener("scroll", () => {
//--------------------------------------
// Video effects related animation code
//--------------------------------------
const scrollDetentThreshold = 5;
const videoEasingCurve = "linear"
const videoScrollSpeed = 1000;
const videoZoomSpeed = 1000;
const videoScrollTriggerPercentage = 40;
const placeholders = document.querySelectorAll(".placeholder");
const body = document.querySelector("body");
// Video Animation effect code
scrollTriggeredCallback(placeholders, videoScrollTriggerPercentage, function(trigger, triggerElement){
if (trigger === true) {
scrollToElement(triggerElement, videoScrollSpeed, videoEasingCurve, function(placeholder) {
let video = placeholder.firstChild.nextElementSibling;
scaleToFullScreen(video, placeholder, videoZoomSpeed, false, videoEasingCurve, function (element, placeholder) {
scrollOffset = body.scrollTop;
currentPlaceholder = placeholder;
currentVideo = element;
});
});
}
});
// Full-screen video detent code
if (scrollOffset !== null && scrollOffset > -1 && currentVideo !== null) {
if (Math.abs(body.scrollTop - scrollOffset) >= scrollDetentThreshold) {
scrollOffset = null;
scaleToFullScreen(currentVideo, currentPlaceholder, videoZoomSpeed, true, videoEasingCurve);
currentVideo = null;
console.log("strink")
} else {
body.scrollTop = scrollOffset;
}
}
});
// Manually set the height of the videos and placeholders relative to their width when the window is resized
window.addEventListener("resize", resizeEvent);
window.onload = function() {
resizeEvent();
};
function resizeEvent() {
let placeholders = document.querySelectorAll(".placeholder");
placeholders.forEach(placeholder => {
let video = placeholder.childNodes[1];
let aspectRatio = Number(placeholder.getAttribute("data-aspectRatio"));
let height = placeholder.getBoundingClientRect().width / aspectRatio;
placeholder.style.height = height + "px";
video.style.height = height + "px";
});
}
// Used with videos which have thumbnail images to start the video
function playButtonClicked(playButton) {
let video = playButton.previousElementSibling;
updatePlayButtonState(video, playButton);
}
function videoClicked(video) {
let playButton = video.nextElementSibling;
updatePlayButtonState(video, playButton);
}
var hoverTimer = null;
function updatePlayButtonState(video, playButton) {
// Set the visibility of the play button according to the video's play state
if (video.paused) {
video.play();
playButton.firstElementChild.innerText = "⏸︎";
playButton.classList.add('fadeOut');
} else {
video.pause();
playButton.firstElementChild.innerText = "⏵";
playButton.classList.remove('fadeOut');
if (hoverTimer !== null) {
window.clearTimeout(hoverTimer);
}
}
}
function videoHovered(video) {
if (video.playing) {
let playButton = video.nextElementSibling;
playButton.classList.remove('fadeOut');
if (hoverTimer !== null) {
window.clearTimeout(hoverTimer);
}
hoverTimer = window.setTimeout(function(video, playButton){
if (video.playing) {
playButton.classList.add('fadeOut');
}
}, 1500, video, playButton);
}
}
function playButtonHovered(playButton) {
let video = playButton.previousElementSibling;
videoHovered(this);
}
#content {
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
.video_container {
position: absolute;
height: 100%;
width: 100%;
}
.placeholder {
position: relative;
background: darkgray;
}
.video {
height: 100% !important;
width: 100% !important;
display: block;
cursor: pointer;
}
.video-thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.play-button {
position: absolute;
top: 50%;
left: 50%;
height: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
--play-button-size: 15vmin;
height: var(--play-button-size);
width: var(--play-button-size);
border-style: solid;
border-color: white;
border-width: calc(1/30 * var(--play-button-size));
border-radius: calc(0.5 * var(--play-button-size));
opacity: 0.75;
transition: opacity 0.5s ease-in-out !important;
-moz-transition: opacity 0.5s ease-in-out !important;
-webkit-transition: opacity 0.5s ease-in-out !important;
}
.play-button-icon {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--play-button-size);
margin-bottom: 0;
line-height: 1;
color: white;
}
.fadableElement {
opacity: 1;
transition: opacity 2s ease-in-out;
-moz-transition: opacity 2s ease-in-out;
-webkit-transition: opacity 2s ease-in-out;
}
.fadeOut {
opacity: 0;
transition: opacity 2s ease-in-out;
-moz-transition: opacity 2s ease-in-out;
-webkit-transition: opacity 2s ease-in-out;
}
<html>
<head>
<link rel="stylesheet" href="style.css">
<!--<script defer src="../../Templates/Code Library/Animation Code Library.js"></script>-->
<script defer src="script.js"></script>
</head>
<body>
<div id="content">
<p>Lorem ipsum dolor sit amet. Et dolorem blanditiis est cupiditate libero id rerum magnam a facilis aperiam sed nisi nobis aut explicabo quod. Ad mollitia doloribus sit architecto delectus et reiciendis voluptatibus sed consequuntur perspiciatis et itaque sint. Ut illum iste vel doloribus omnis non saepe eius ut fuga perferendis aut sunt numquam aut voluptate eligendi. Qui enim placeat et omnis fugiat qui porro culpa non facere obcaecati quo modi laboriosam. </p>
<p>Et sunt labore est velit deserunt aut sunt culpa id aliquam ipsam aut molestiae iure eos delectus libero ab ipsum ipsum! In sint voluptatibus aut aperiam incidunt a quia galisum qui autem vitae ea doloremque facilis aut corrupti commodi est dolorum iusto. </p>
<p>Vel aspernatur quos sit debitis molestiae rem galisum distinctio. Ab tenetur dolores aut natus temporibus est veritatis velit sit laboriosam sit quae perspiciatis cum eveniet sapiente.</p>
<p>Lorem ipsum dolor sit amet. Et dolorem blanditiis est cupiditate libero id rerum magnam a facilis aperiam sed nisi nozbis aut explicabo quod. Ad mollitia doloribus sit architecto delectus et reiciendis voluptatibus sed consequuntur perspiciatis et itaque sint. Ut illum iste vel doloribus omnis non saepe eius ut fuga perferendis aut sunt numquam aut voluptate eligendi. Qui enim placeat et omnis fugiat qui porro culpa non facere obcaecati quo modi laboriosam. </p>
<p>Et sunt labore est velit deserunt aut sunt culpa id aliquam ipsam aut molestiae iure eos delectus libero ab ipsum ipsum! In sint voluptatibus aut aperiam incidunt a quia galisum qui autem vitae ea doloremque facilis aut corrupti commodi est dolorum iusto. </p>
<p>Vel aspernatur quos sit debitis molestiae rem galisum distinctio. Ab tenetur dolores aut natus temporibus est veritatis velit sit laboriosam sit quae perspiciatis cum eveniet sapiente.</p>
<!-- NOTE: Source for Font Awesome Icons for buttons: https://stackoverflow.com/questions/22885702/html-for-the-pause-symbol-in-audio-and-video-control -->
<div class="placeholder" data-aspectRatio="1.7777">
<div class="video_container" align="center">
<video class="video" onclick="videoClicked(this)" onmouseover="videoHovered(this)" loading="lazy" style="object-fit: cover" poster="https://www.staging7.midstory.org/wp-content/uploads/2023/03/animated-background.gif">
<source data-src="https://www.staging7.midstory.org/wp-content/uploads/2022/01/Video-of-green-foliage.mp4" type="video/mp4" src="https://www.staging7.midstory.org/wp-content/uploads/2022/01/Video-of-green-foliage.mp4">
</video>
<!--<img class="play-button fadableElement" onclick="playButtonClicked(this)" src="https://www.staging7.midstory.org/wp-content/uploads/2023/03/play-icon.png" alt="Play Video">-->
<!--<i class="play-button fadableElement fa fa-play-circle-o" onclick="playButtonClicked(this)"></i>-->
<div class="play-button" onclick="playButtonClicked(this)" onmouseover="playButtonHovered(this)">
<div class="play-button-icon">⏵</div>
</div>
</div>
</div>
<p>Lorem ipsum dolor sit amet. Et dolorem blanditiis est cupiditate libero id rerum magnam a facilis aperiam sed nisi nobis aut explicabo quod. Ad mollitia doloribus sit architecto delectus et reiciendis voluptatibus sed consequuntur perspiciatis et itaque sint. Ut illum iste vel doloribus omnis non saepe eius ut fuga perferendis aut sunt numquam aut voluptate eligendi. Qui enim placeat et omnis fugiat qui porro culpa non facere obcaecati quo modi laboriosam. </p>
<p>Et sunt labore est velit deserunt aut sunt culpa id aliquam ipsam aut molestiae iure eos delectus libero ab ipsum ipsum! In sint voluptatibus aut aperiam incidunt a quia galisum qui autem vitae ea doloremque facilis aut corrupti commodi est dolorum iusto. </p>
<p>Vel aspernatur quos sit debitis molestiae rem galisum distinctio. Ab tenetur dolores aut natus temporibus est veritatis velit sit laboriosam sit quae perspiciatis cum eveniet sapiente.</p>
<p>Lorem ipsum dolor sit amet. Et dolorem blanditiis est cupiditate libero id rerum magnam a facilis aperiam sed nisi nobis aut explicabo quod. Ad mollitia doloribus sit architecto delectus et reiciendis voluptatibus sed consequuntur perspiciatis et itaque sint. Ut illum iste vel doloribus omnis non saepe eius ut fuga perferendis aut sunt numquam aut voluptate eligendi. Qui enim placeat et omnis fugiat qui porro culpa non facere obcaecati quo modi laboriosam. </p>
<p>Et sunt labore est velit deserunt aut sunt culpa id aliquam ipsam aut molestiae iure eos delectus libero ab ipsum ipsum! In sint voluptatibus aut aperiam incidunt a quia galisum qui autem vitae ea doloremque facilis aut corrupti commodi est dolorum iusto. </p>
<p>Vel aspernatur quos sit debitis molestiae rem galisum distinctio. Ab tenetur dolores aut natus temporibus est veritatis velit sit laboriosam sit quae perspiciatis cum eveniet sapiente.</p>
</body>
</html>
It 'works' but it is not nearly as smooth as the original link above, especially when scrolling fast towards the video. The site above simply grabs the scroll and pulls the video in smoothly, whereas in my case, the window often overshoots the video if scrolling too fast when approaching it.
The second bug is how the video itself animates when approaching slowly. It seems to approach the screen slowly and then 'snap' into position instead of moving smoothly. I added an easing function to it so I can control the feel of the animation, but for now, I removed it so I can see how smooth the raw movement is on its own. Any feedback appreciated, as I think this is a really cool effect if it can be done right.