Enhancing the Video element — A practical example for Web components!

I recently added a page to showcase our different worlds of my startup Dibulo.com. For the best representation and for the users to understand what a world is about we play a short video. Having many different videos playing at the same time on a website can, as you may guess, lead to a pretty messy user experience. I am gonna show you how I used native Web Components in Javascript (look ma, no JS framework!) to make your video elements more “lean”.
What are we gonna build?
In the above video you see that whenever a video partially leaves the viewport, we pause it. In a nutshell we want that the video:
- Pauses and plays whenever it is (partially) visible
- Toggle sound on user click
- Only load the video when it is visible (lazy loading)
If you can’t wait or you prefer reading code than poesy: the whole code is at the end (scroll faster faster!).
Can I use it?
First things first, if you are an old dinosaur like me who has been coding websites since Internet Explorer 5, then your first question is probably if you can use those Web Components people started talking about in 2011 reliably:

The HTML
Let’s start with the easiest part: the html code. Our custom element tag is gonna be called <video-lean> (you may come up with a more descriptive name like VideoHero) and is gonna look like this:
<video-lean
data-video-src="/assets/myvideo.mp4"
data-video-poster="/assets/myvideo-poster.webp">
<video
src=""
poster="/assets/generic-poster.webp"
aria-hidden="true"
playsinline
muted
autoplay
loop></video>
</video-lean>
If you have written a Web Component before, you may notice that I don’t write my HTML in JS (call me old fashioned, but I prefer dinosaur). Web Components should not replace elements, but enhance them. Also it makes things easier, such as translating content when you render your page server sided.
React encouraged a mindset of replacement: “forgot what browsers can do; do everything in a React component instead, even if you’re reinventing the wheel.” [a Jeremy]

The Javascript:
The bare minimum of any Web Components looks like this:
class VideoLean extends HTMLElement {
connectedCallback() {
// Called when the element is attached to the DOM
}
}
// Tell everyone our video-lean element exists and how to use it:
customElements.define('video-lean', VideoLean);
We define our raw VideoLean class and tell the browser “If you find <video-lean>, use that VideoLean class”.
Let’s set the video source:
class VideoLean extends HTMLElement {
constructor() {
super();
this.videoEle = this.querySelector("video");
}
connectedCallback() {
this.setVideoSrc();
}
setVideoSrc() {
const videoSrc = this.dataset.videoSrc;
this.videoEle.src = videoSrc;
}
}
customElements.define('video-lean', VideoLean);
Why don’t I just set the video ‘src’ right in the beginning? Because you may want to change it or set it only if the video-lean element is visible. But later more about this.
Toggle play state
To toggle the play and pause state of the video whenever our video is partially visible, we will need the practical IntersectionObserver API. That one calls a function whenever an element is partially visible in the viewport. But let me show you in code:
class VideoLean extends HTMLElement {
constructor() {
super();
this.videoEle = null;
}
connectedCallback() {
this.videoEle = this.querySelector("video");
// ... other code
// Register our observer when the element has been added to the DOM
this.addIntersectionObserver();
}
addIntersectionObserver() {
// Function to set up the Intersection Observer
function setupIntersectionObserver(videoElement) {
const options = {
root: null, // Using the viewport as the root
threshold: 0.6, // Trigger when x% of the video is visible
};
const observer = new IntersectionObserver(handleIntersection, options);
observer.observe(videoElement);
}
// Function to handle the intersection changes
// Pause or play if portion of the video element
// is in the viewport
function handleIntersection(entries, observer) {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Video is in the viewport, play it
entry.target.play();
} else {
// Video is out of the viewport, pause it
entry.target.pause();
}
});
}
// Only setup the IntersectionObserver if supported:
if (window['IntersectionObserver']) {
setupIntersectionObserver(this.videoEle);
}
}
// ...
}
customElements.define('video-lean', VideoLean);
After setting up the IntersectionObserver, we listen to any visibility changes. If the video scrolls into the viewport and is 60% visible, the video plays. Otherwise it pauses. And not only that, but pausing has the nice side-effect that you also mute the video.
Toggling sound on click
Right now the video is muted by default. This is because unmuted videos do not autoplay in the browser (bye bye wild internet times) and we set the muted attribute on the <video>. We can let the user decide if they want to listen to the video’s audio by clicking on it:
connectedCallback() {
// ...
this.addEventListener('click', this.toggleSound);
}
toggleSound() {
this.videoEle.muted = !this.videoEle.muted;
}
Because toggling the audio is not obvious at all to a user, I suggest that you add some kind of audio icon / button and change it based on the videoEle.muted property.
Lazy loading — only set video source if visible
You could even go further and only set the video src if the video scrolls into the viewport for the first time. Imagine your video is at the end of your page and most users may not even scroll that far. You would load the video only if the video appears. Save bandwidth and performance!
Losing window’s focus
Another step you could take is to pause the video if the user e.g. changed tabs using the browser’s PageVisibility API.
connectedCallback() {
// ...
// Listen to the page visibility change:
document.addEventListener('visibilitychange',
this.onPageVisbilityChange.bind(this));
}
onPageVisbilityChange() {
if (document.hidden) {
this.videoEle.pause();
} else {
this.videoEle.play();
}
}
// Don't forget to unbind events:
disconnectedCallback() {
document.removeEventListener('visibilitychange',
this.onPageVisbilityChange.bind(this));
}
Now if a user switches to another browser tab or other program, all of our video-leans will pause & mute. Aren’t we generous coding gods?!
Changing video src dynamically:
One cool thing about Web Components is that is has built-in reactivity on its attributes.
class VideoLean extends HTMLElement {
static observedAttributes = ['data-video-src'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data-video-src' && oldValue !== newValue) {
this.setVideoSrc(newValue);
}
}
}
If we would now change the data-video-src attribute, the attributeChangedCallback would be triggered, and we would set a new video src by calling the setVideoSrc with the new value.
Attributes as flags
Maybe you don’t want all of your video-leans to have the same features. What if your video is playing music and you don’t want to pause the video if the user switches tabs to look at puppy pictures? Or what if you want to change the visibility percentage for the Intersection Observer? You don’t want to write a new class for that.
We can use custom attributes to influence the traits a video-lean has:
<video-lean
allow-toggle-click
allow-page-visibility
data-visibility-threshold="0.8"
data-video-src="/assets/myvideo.mp4">
<!-- your video html -->
</video-lean>
class VideoLean extends HTMLElement {
connectedCallback() {
// ... other code we had
// Only toggle mute on-click if the attribute is present:
if (this.hasAttribute('allow-toggle-click')) {
// add click event-listener
}
const threshold = parseInt(this.dataset.visibilityThreshold, 10);
// and pass it to the threshold argument ...
}
}
Interacting with the video-lean element:
In case you want to interact with your video-lean element you can do it exactly as you have been doing it in any other of your other choco JS projects before:
const videoLeanEle = document.getElementById('myVideoLean');
// Some other button in my page:
videoLeanEle.toggleSound();
Events!
What if you want to know on the outside that something happened in your video-lean element? Like, we want to track whenever hmmmm the user toggles the sound … in short we want something like this:
// HTML:
// <video-lean id="myVideoLean">...</video-lean>
const videoLeanEle = document.getElementById('myVideoLean');
videoLeanEle.addEventListener('onToggleSound', (e) => {
trackEvent('sound', e.detail.state);
});
Custom Events can help here:
class VideoLean extends HTMLElement {
toggleSound() {
this.videoEle.muted = !this.videoEle.muted;
// Build and emit the event:
const customEvent = new CustomEvent('onToggleSound', {
bubbles: true,
cancelable: false,
composed: true,
// Append custom parameters (optional)
detail: {
state: this.videoEle.muted,
},
});
this.dispatchEvent(customEvent);
}
}
Now we can use addEventListener to listen to the onToggleSound event as we wanted.
Result
Now we have a custom video element which:
- Pauses and plays only when it is 60% visible
- Pauses and plays whenever the page visibility changes
- Mutes or unmutes on user click
And because it is a native Web Component you can copy and pasta it to any of your web projects and it will just work out of the box!
The whole JS:
Copy, paste, extend & use:
/**
* A custom html element which wraps a video element
*/
class VideoLean extends HTMLElement {
static observedAttributes = ['data-video-src'];
constructor() {
super();
this.videoEle = null;
}
connectedCallback() {
this.videoEle = this.querySelector("video");
this.addEventListener('click', this.toggleSound.bind(this));
this.addIntersectionObserver();
// Listen to the page visibility change:
document.addEventListener('visibilitychange',
this.onPageVisibilityChange.bind(this));
this.setVideoSrc();
}
// Don't forget to unbind events:
disconnectedCallback() {
document.removeEventListener('visibilitychange',
this.onPageVisibilityChange.bind(this));
}
// Listen for attributes to change
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data-video-src' && oldValue !== newValue) {
this.setVideoSrc(newValue);
}
}
// ---------------- Custom Methods ---------------- //
setVideoSrc() {
const videoSrc = this.dataset.videoSrc;
if (videoSrc) {
this.videoEle.src = videoSrc;
}
}
toggleSound() {
this.videoEle.muted = !this.videoEle.muted;
}
onPageVisibilityChange() {
if (document.hidden) {
this.videoEle.pause();
} else {
this.videoEle.play();
}
}
addIntersectionObserver() {
// Function to set up the Intersection Observer
function setupIntersectionObserver(videoElement) {
const options = {
root: null, // Using the viewport as the root
threshold: 0.6, // Trigger when x% of the video is visible
};
const observer = new IntersectionObserver(handleIntersection, options);
observer.observe(videoElement);
}
// Function to handle the intersection changes
// Pause or play if portion of the video element
// is in the viewport
function handleIntersection(entries, observer) {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Video is in the viewport, play it
entry.target.play();
} else {
// Video is out of the viewport, pause it
entry.target.pause();
}
});
}
// Only setup the IntersectionObserver if supported:
if (window['IntersectionObserver']) {
setupIntersectionObserver(this.videoEle);
}
}
}
customElements.define('video-lean', VideoLean);
<video-lean
data-video-src="/assets/myvideo.mp4"
data-video-poster="/assets/myvideo-poster.webp">
<video
src=""
poster="/assets/generic-poster.webp"
aria-hidden="true"
playsinline
muted
autoplay
loop></video>
</video-lean>