Expandable video block with GSAP

Bogdan Bendziukov
8 min readFeb 23, 2023

--

I enjoy using GSAP to create awesome animations for websites. This JS library is very powerful, easy to use and provides very high performance for animations, even on mobile devices with sluggish processors. Recently, I had a request from one of my clients to create a video block, which expands on scroll, so after I achieved that, I decided to share how you can build a similar one for your website.

Photo by Johannes Plenio on Unsplash

The goal

This is what we want to achieve:

Expandable video block with GSAP

As you can see, the video is paused on start. Once a user scrolls to it, the video expands to fit the whole page and starts playing. Also the video’s title appears. When a user scrolls further, the video pauses again, the title hides and the video scales down to its normal size. This animation works in both scroll directions.

The markup

Let’s start from HTML and CSS.

<!DOCTYPE html>
<html lang="en" >
<head>
<meta charset="UTF-8">
<title>CodePen - Expandable Video Block with GSAP</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>

<main class="content">
<div class="container">
<section class="video-block video-block_full-width">
<div class="video-block__container">
<figure class="video">
<video autoplay loop muted playsinline src="https://joy.videvo.net/videvo_files/video/premium/video0001/large_watermarked/dji_forest_drive4k00_preview.mp4"></video>
<figcaption class="video__caption">
<span>A journey of a thousand miles begins with a single step</span>
</figcaption>
</figure>
</div>
</section>
</div>
</main>

<script src='https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.4/gsap.min.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.4/ScrollTrigger.min.js'></script>
<script src="./script.js"></script>

</body>
</html>

You might have noticed, the video section is inside the container block. So it needs to be stretched to the full width of the page. If your page’s structure is different and your sections aren’t wrapped by the container block and already have full width you can remove the video-block_full-width class from the video-block section.

Also, don’t forget to include both gsap.min.js and ScrollTrigger.min.js scripts to your page.

Now let’s add some styles:

:root {
--container-width: 600px; /* variable for the maximum width of page’s content */
}

.container {
max-width: var(--container-width);
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}

figure.video {
margin: 0;
}

figure.video .video__caption {
overflow: hidden;
position: absolute;
left: 24px;
bottom: 24px;
font-family: 'Dela Gothic One', cursive;
color: #fff;
}

figure.video .video__caption span {
transform: translateY(100%);
display: inline-block;
}

.video-block__container {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
height: calc(var(--vh) * 100);
}

.video-block figure.video {
height: 100%;
width: 100%;
display: flex;
align-items: center;
}

.video-block video {
height: 100%;
object-fit: cover;
}

.video-block_full-width {
max-width: calc(var(--vw) * 100);
margin-left: calc(50% - (var(--vw) * 50));
margin-right: calc(50% - (var(--vw) * 50));
width: auto;
overflow: hidden;
}

The css variables --vw and --vh will be added via JS, so they have the actual value of 1% from viewport width and height respectively.

The code

Now let’s code!

I’m gonna show you the full JS code first and then we will go through it step by step.

First, register the ScrollTrigger plugin:

gsap.registerPlugin(ScrollTrigger);

This GSAP plugin allows us to run animations while user scrolling the page, not only once the page was loaded.

Then, calculate and set the real VW and VH variables to the :root element (this will be an <html> element):

const setVwVh = () => {
let vw = document.documentElement.clientWidth / 100;
let vh = document.documentElement.clientHeight / 100;
document.documentElement.style.setProperty('--vw', `${vw}px`);
document.documentElement.style.setProperty('--vh', `${vh}px`);
}

All set, we can write the main function for the expandable video block!

Let’s set up a new function and provides some constants and variables:

const expandableVideoBlock = () => {

const maxWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--container-width'));
const gap = 24;
const element = document.querySelector(".video-block");
const container = element.querySelector(".video-block__container");
const figure = element.querySelector("figure.video");
const video = element.querySelector("video");
const caption = figure.querySelector("figcaption span");

// get values to animate clipPath property
const getClipPath = () => {
let insetX = (window.innerWidth - maxWidth - gap) / 2;
let insetY = (window.innerHeight - maxWidth - gap) / 2;

insetX = insetX > 0 ? insetX : gap;
insetY = insetY > 0 ? insetY : gap;

return `inset(${insetY}px ${insetX}px)`;
}

// … further code will be here

}

The maxWidth constant contains the value of the css variable --container-width. You can provide your own value of desirable width of your video.

The getClipPath function calculates insetX and insetY values, which are for empty space around the video (we will use them a bit later). This function returns a string for clipPath property. It is important to use function to calculate dynamic values, so ScrollTrigger plugin can use it on refresh (when user resize the screen, for example).

To calculate these spaces we use a custom getClipPath() function

Ok, now let’s handle playing and pausing the video.

First, we set up the initial state of the video (which is paused):

let isPlaying = false;

Then, toggle isPlaying variable on video playing and when it’s paused:

video.onplaying = function() {
isPlaying = true;
};

video.onpause = function() {
isPlaying = false;
};

Now we can set up the function to pause the video:

const videoPause = () => {
if (video && !video.paused && isPlaying) {
video.pause();

gsap.to(caption,
{
y: "100%",
duration: 0.5,
}
)
}
}

In this function we check if the video element exists, if the video is not paused and playing. If all true — pause the video. Also, we use GSAP’s function to(), so the video’s title will appear once the video starts to play.

Now let’s handle the playing function. It’s almost similar to the previous videoPause() function, only we do the code vice versa:

const videoPlay = async () => {
if (video && video.paused && !isPlaying) {

gsap.to(caption,
{
y: "0%",
duration: 0.5,
}
)

return await video.play();
}
}

We use an asynchronous function, because video.play() method returns a Promise when playback has been started.

We can proceed to GSAP and ScrollTrigger magic. Let’s add a parallax effect to the video on scrolling.

gsap.fromTo(figure, 
{
clipPath: getClipPath,
y: "-50%"
},
{
scrollTrigger: {
trigger: element,
start: "top bottom",
end: "top top",
scrub: true,
pin: false,
//markers: true,
},
y: "0%",
duration: 0.5,
onStart: () => {
videoPause();
}
}
)

We use GSAP’s fromTo() function, which lets you define both the starting and ending values for an animation. So we are moving the video vertically from -50% to 0% of its position. Also, we clip the video using our getClipPath() function, so now it has a width of the container element. Using onStart property we pause the video, to prevent from autoplaying.

Let’s take a closer look to the ScrollTrigger’s part:

scrollTrigger: {
trigger: element,
start: "top bottom",
end: "top top",
scrub: true,
//markers: true
}

The property trigger is for the element (or selector text for the element) whose position is used to calculate where the ScrollTrigger starts. In other words when the animation should be started.

The start property determines the starting position of the ScrollTrigger. So when it equals top bottom means “start the animation when the TOP of the trigger’s element reaches the BOTTOM of the screen”. Likewise the end property determines the ending position, in other words when the animation should be finished. In our case it’s “when TOP of the trigger’s element reaches the TOP of the screen”.

To make it more clear, uncomment this line //markers: true, which enables visual markers of starting and ending points of the ScrollTrigger.

ScrollTrigger with markers

The scrub property is to make animation progress along with the scrollbar. You can discover more options, examples and methods of the ScrollTrigger’s plugin in its official documentation.

This is what we achieved so far:

Video (paused) with parallax effect

We almost there, stay with me 😉

We need to expand that block to the full page once a user scrolls to it. We’re gonna use another GSAP’s awesome feature called Timeline. It allows you to create and to control a sequence of animations. And it also works with the ScrollTrigger plugin! So let’s start with initializing the timeline along with scrollTrigger:

let tl = gsap.timeline({
scrollTrigger: {
trigger: container,
start: "top top",
end: () => window.innerHeight * 4,
scrub: true,
pin: container,
}
})

I’ve changed the trigger element to container, because it will be pinned to the screen (means fixed position) while a user keeps scrolling. For the end property we’re gonna use a function that returns a value equals 4 times the height of the user’s screen. Simply put, the timeline will start when the TOP of the container’s element reaches to the TOP of the screen and will last until the user scrolls his screen height 4 times.

Now we can expand the video to the full page width and height. To make sure the video stays paused we use our function videoPause() for the onUpdate property (we need this for reverse animation, when the user scrolls up from bottom). Once the animation is done (onComplete property) we launch videoPlay() function.

tl.fromTo(figure, 
{
clipPath: getClipPath,
},
{
clipPath: `inset(0px 0px)`,
duration: 0.5,
onUpdate: () => {
videoPause();
},
onComplete: () => {
videoPlay();
}
}
)

We don’t want the video to scale back just once it expands, right? So let’s make it stay untouched for a while. We use a little hack, pretend to animate opacity property:

tl.fromTo(figure,
{
opacity: 1,
},
{
opacity: 1,
duration: 1,
onUpdate: () => {
videoPlay();
},
onComplete: () => {
videoPause();
}
}
)

This time, we use the videoPlay() function on each time the animation updates and the videoPause() function when the animation is done.

Finally (yay 🤩) we shrink the video container (clipping it, using clipPath):

tl.fromTo(figure, 
{
clipPath: `inset(0px 0px)`,
},
{
clipPath: getClipPath,
duration: 0.5
}
)

Don’t forget to run the code once DOM is ready (also update VW and VH variables on resize):

// run functions on page load and resize
addEventListener('DOMContentLoaded', setVwVh);
addEventListener('DOMContentLoaded', expandableVideoBlock);
addEventListener("resize", setVwVh);

The result

You can check the final result at CodePen. Feel free to fork it, add or change some animations and play around whatever you like. I’ve added some extra HTML blocks to the page, just to demonstrate how the expandable video block will look inside the page.

If you find this article useful, please clap and leave a comment, I would really appreciate it 😉

Thanks for reading!
Stay safe and peace be with you!

--

--

Bogdan Bendziukov

I'm a web developer from Kyiv 💛💙. A WordPress enthusiast for 10 years. Writing tips and thoughts from my dev experience .