SJ (Scott Jehl)

Extending Responsive Video with HTML Web Components

photo of scott jehl profile headshot

By Scott Jehl

Responsive HTML video is a web standard again, and with recent patches to Firefox (oh hey!) and Chrome that match Safari's preexisting support, it now works across all modern browsers! That means you can use media queries for delivering your videos and potentially save your users some precious bytes.

As I've noted in prior articles, this is great news for web performance fans, as video is by far the heaviest type of media used on websites (the median weight of just the video files on pages that use video is almost 5 megabytes per page on mobile devices), and that weight has a huge impact on performance, users' data costs, sites' hosting costs, and overall energy usage. Using an HTML video element to display a static video file is one of the most common and portable ways to drop a video into a webpage today, so it's great we now have options to do it more responsibly.

If you haven't yet caught up on how responsive video works, I'd recommend reading How to Use Responsive Video (...and Audio!) first as a primer, because it's loaded with examples of how to use it and also when to consider alternatives, like HTTP Live Streaming.

What You'll Learn Today...

In this post, I'm going to talk briefly about responsive video, but most of the post will be about using HTML web components to extend native video behavior in very helpful ways. But even if you're not particularly interested in video development, stick around as I'll demonstrate how to build an HTML Web Component to progressively enhance anything you need.

How Responsive Video Works (...and doesn't)

To start, let's recap what responsive video is. I'll start with some example HTML and then explain what we're looking at...

<video>
	<source src="/sandbox/video-media/small.mp4" media="(max-width: 599px)">
	<source src="/sandbox/video-media/large.mp4">
</video>

That's a basic responsive video element. It's responsive because one of the source elements has a media attribute on it that includes a media query, making its source selection conditional to the browser's viewport size. If you've used the picture element before, that media attribute is going to look familiar, because picture was actually designed based on how this works in HTML video (back when responsive video was supported the first time around, which is a long story).

Here's that video element live in the page. The latest versions of common browsers will see a video that says "small video source" at small viewport sizes and a video that says "large video source" at wider viewport sizes. Unsupporting, older browsers will simply select the first source suitable they find (small.mp4 in this case) because those browsers ignore the media attribute and pick the first source that matches, ignoring any that come after it.

So that's the basics of how a responsive video element works.

How It Doesn't Work

One thing that folks (me included!) tend to find surprising about responsive video is that unlike other "responsive" features in web design–CSS Media Queries or the picture element for example–responsive video sources are only assessed once, when the video (and typically the entire page) first loads.

That means that while you'll still enjoy the performance benefits of an appropriately selected source at page load, it won't be reassessed after page load when media conditions change (when, for example, when the user resizes their browser window). It's entirely possible that may change one day, but there are a number of reasons this behavior was not included in the initial implementation, such as complications involved in syncing up the timecode after a source is swapped, as well as the reasonable goal of matching the behavior of the existing implementation in Safari.

Regardless of all that, folks are right to find this part of the feature a little... unexpected.

Which got me thinking, what would it take to make that behavior work on top of what we already have in browsers today?

Let's find out.

Extending Video with an HTML Web Component

Web Components are having a moment right now, and particularly, "HTML Web Components", which are being used to describe web components that add behavior to regular old HTML by wrapping it in a custom element and applying a little JavaScript to enhance that markup further.

An HTML Web Component is an ideal tool for extending the functionality of standard HTML, like say, a video element for example! I'm going to walk through how I built one that'll make responsive video even responsiver, so it reacts to media changes after page load, should they occur.

First, a Custom Element

To start, we will need a custom element, which is just an HTML element that you invent that has at least one dash in its name. I'll go with responsive-video, like this:

<responsive-video></responsive-video>

Nice. So that's a custom element, and with a little added JavaScript it could become a web component. But in order to make it an "HTML Web Component," we'll want to wrap that component around some HTML that is already meaningful and functional on its own, such as our video element above.

Here's that video element wrapped in a responsive-video custom element.

<responsive-video>
	<video controls autoplay loop>
		<source src="/sandbox/video-media/small.mp4" media="(max-width: 599px)">
		<source src="/sandbox/video-media/large.mp4">
	</video>
</responsive-video>

That’s about it for the HTML part of this article. Based on what I’ve done so far, browsers will just ignore that custom element, (though you can style it already if you want to).

To get browsers to recognize this as an element with its own unique properties and behavior, you'll need to write a web component. Let's do that.

Adding the JavaScript

To enhance this HTML, we’ll create and load a JavaScript module that will contain our web component code. That code will live in a new file called responsivevideo.js.

Please note the last line of the code below to see how we can import that script into our page:

<responsive-video>
	<video controls autoplay loop>
		<source src="/sandbox/video-media/small.mp4" media="(max-width: 599px)">
		<source src="/sandbox/video-media/large.mp4">
	</video>
</responsive-video>
<script type="module" src="responsivevideo.js"></script>

Now let’s look at the code we’ll put inside that file.

Writing the Web Component Logic

Web Components are designed for Progressive Enhancement. They come with a series of lifecycle events that are incredibly helpful for applying behavior to elements at moments such as when they're created or when they're appended to the DOM, and also for removing behavior and event handlers when an element is removed from the DOM or destroyed altogether.

Before we get ahead of ourselves on that, let's start with some boilerplate code for our responsive-video component.

class ResponsiveVideo extends HTMLElement {}
customElements.define("responsive-video", ResponsiveVideo);

That’s not a lot of code, but it is already doing a lot of work. To start, it defines a class called ResponsiveVideo, which extends the HTMLElement class, which is the same class that all standard HTML elements–you know, <p>, <select>, <input>, etc.–use for their own behavior in the browser. After that, we're using customElements.define to actually define a new HTML element (the tag part) called <responsive-video> that will inherit the class's logic and behavior.

So now we have a Web Component, but it’s still not doing anything. Inside our class, we can start to use those lifecycle events I had mentioned to add (and remove) behavior and event handlers to the HTML at appropriate moments. I recommend reading up on all of the features of web components, but for the purpose of this article I'll cover just a few lifecycle events that are relevant to adding the behavior we want:

  • constructor
  • connectedCallback
  • disconnectedCallback

The Constructor

The constructor of a web component is called when an instance of its HTML element is created. It's useful for establishing some upfront variables within the element that'll be useful to later events and internal logic. It's often not the best place for all of our initialization code though because when the constructor is called, the element may not be appended to the DOM quite yet, so the many conditions relating to the page around it won't yet be accessible.

Here's our code with a constructor call added:

class ResponsiveVideo extends HTMLElement {
	constructor() {
		super();
		console.log(this);
	}
}
customElements.define("responsive-video", ResponsiveVideo);

Inside the constructor, I started by calling super(). Generally, it's advised that we include this step first because it allows the component to gain access to the base class's (that is, HTMLElement) properties. Just after the super(), I've logged this to the browser console, which refers to the HTML element we're enhancing.

If you were to check the browser console at this point, you would see that the HTML element itself was logged: "responsive-video"

For now, I won’t use the constructor for anything, but in my final component I am going to use it to define some upfront variables that other events and methods will use.

Using connectedCallback & disconnectedCallback

Next, I'll want to apply behavior to any responsive-video element when it is added to the DOM, and remove behavior when/if it's ever removed from the DOM. It's worth pointing out here that in contrast to the days when we used to wait for "DOM Ready" to query the DOM for elements to make our enhancements, these events are a major upgrade!

I know I’m going to use these two events, so first I’ll add them with some console logs.

class ResponsiveVideo extends HTMLElement {
	constructor() {
		super();
		// upfront variables will go here
	}
	connectedCallback(){
		console.log(`${this} is in the DOM`);
	}
	disconnectedCallback(){
		console.log(`${this} has been removed from the DOM`);
	}
}
customElements.define("responsive-video", ResponsiveVideo);

Above, we're logging to the browser console whenever a responsive-video element shows up in the DOM (which includes when the page first loads), and whenever one is removed from the DOM.

connectedCallback is a good place to create useful references to elements inside my component. For example, many of my methods will reference the video element inside the component, so I can make a reference to it inside connectedCallback like so, using this.querySelector to search within the scope of the component itself instead of the entire document.

connectedCallback() {
	this.video = this.querySelector("video");
}

With these handy methods, we now have the tools we need to add some logic.

Adding Custom Behavior

The rest of our component’s code will serve one purpose: instructing the video element to select and load a new, appropriate source for the video element whenever/if media conditions change in a way that's relevant to its source elements.

To do that, we’ll write some code that applies this general flow:

  • Find all of the source elements inside the video that have media attributes, and pass those media attribute values (which will be media queries or types) to matchMedia, which we can use to start listening to the media queries for relevant changes. Once listening, matchMedia's "change" event will fire whenever those media queries start or stop matching the media conditions of the browser, which can happen when a user resizes their browser to a different size, for example.
  • When any of the media queries inside the video fire a change event, check if the currently playing video source is coming from a source element prior to the source element whose media just changed. If it is, then we can ignore that particular media change because any sources after the source that's currently playing are irrelevant to selection. On the other hand, if the currently playing source of the video happens to be the source whose media did just change (presumably by no longer matching), then we should instruct the video element to load again (using video.load()) using its native selection to find the best source.

That's mostly it!

Here's how that logic will be structured in the code in our component, with our connectedCallback and disconnectedCallback events applying code at opportune moments. In addition to those lifecycle events, I've also added 4 custom methods for my own code to use: bindMediaListeners, unbindMediaListeners, previousSiblingIsPlaying, and reloadVideo.

I've replaced some of the code in those methods below with comments that describe how it flows.

class ResponsiveVideo extends HTMLElement {
	constructor() {
		super();
		// upfront variables will go here
	}
	connectedCallback() {
		this.video = this.querySelector('video');
		this.bindMediaListeners();
	}
	disconnectedCallback() {
		this.unbindMediaListeners();
	}
	bindMediaListeners(){
		// loop through the source elements inside this.video and set up listeners for their media queries
		// when any of those listeners fire a change event,
		// make sure the source element of the event is relevant using previousSiblingIsPlaying
		// if it's relevant, call reloadVideo to find a new source
	}
	unbindMediaListeners(){
		// unbind the media query listeners
	}
	previousSiblingIsPlaying(elem, src) {
		// a helper function that will return true or false for whether a previous source element is currently selected
	}
	reloadVideo(){
		// capture the currentTime of the video
		// reload the video, let the browser choose the best current source
		// set the current time of the video to where it last was
	}
}
customElements.define("responsive-video", ResponsiveVideo);

That was a big chunk of code, but it should give you an idea of the flow for how it works.

Completing the Enhancements

Next, let’s populate that scaffolding with the actual logic. Here’s our full web component code.

class ResponsiveVideo extends HTMLElement {
	constructor() {
		super();
		this.listenedMedia = [];
		this.reloadQueued = false;
	}
	connectedCallback() {
		this.video = this.querySelector('video');
		this.bindMediaListeners();
	}
	disconnectedCallback() {
		this.unbindMediaListeners();
	}
	bindMediaListeners(){
		this.querySelectorAll('source').forEach(source => {
			if (source.media) {
				const mqListener = () => {
					if (source.media === this.video.currentSrc || !this.previousSiblingIsPlaying(source, this.video.currentSrc) && !this.reloadQueued) {
						this.reloadVideo();
					}
				};
				this.listenedMedia.push({ media: source.media, handler: mqListener });
				window.matchMedia(source.media).addEventListener("change", mqListener);
			}
		});
	}
	unbindMediaListeners(){
		this.listenedMedia.forEach(listener => {
			window.matchMedia(listener.media).removeEventListener("change", listener.handler);
		});
	}
	previousSiblingIsPlaying(elem, src) {
		let prevSibling = elem;
		while (elem.previousElementSibling) {
			if (prevSibling.src === src) {
				return true;
			}
		}
		return false;
	}
	reloadVideo(){
		this.reloadQueued = true;
		const currentTime = this.video.currentTime;
		const playState = this.video.playState;
		this.video.load();
		const videoLoaded = () => {
			this.video.playState = playState;
			this.video.currentTime = currentTime.toString();
			this.reloadQueued = false;
			this.video.removeEventListener("loadeddata", videoLoaded);
		};
		this.video.addEventListener("loadeddata", videoLoaded);
	}
}

customElements.define("responsive-video", ResponsiveVideo);

And it works! In about 50 lines of code, we’ve used a web component to extend HTML video to make it do much more.

Check out the Demo Page

You can try a demo of this responsive video web component here.

Even Responsiver Video Demo Page

But hey! Not so fast....

I still have another important thing to cover before this thing is ready to ship.

Enhancing Reponsibly

As I mentioned early in the post, the behavior we added with this component is something that many folks expect to already work when they start using responsive video. While there is no current proposal or plans for implementing this in web standards and browsers (that I know of, at least), this does seem like the sort of feature that could possibly become natively supported in browsers one day. I hope it does actually!

If that does ever happen, I really wouldn't want this web component to be duplicating, or worse interfering with the behavior that a browser handles itself. I'd like this web component to act as a polyfill for this behavior if it's not already supported, but if it is supported I want this web component to make itself obsolete.

So just to be safe, we should check if the browser handles this behavior natively before we add it ourselves.

It's time for a feature test!

Writing a Video Media Change Feature Test

I created the following feature test to check whether a browser performs this media switching behavior natively. It's somewhat similar to the test that I recently patched in the Web Platform Test suite which modern browsers run as part of their builds to ensure they support standard responsive video behavior. Except that this one tests for behavior that is not currently standard, of course.

The test returns a promise that will quickly resolve to either true or false to describe if a browser supports changing video sources after page load, when source media changes.

// feature test for native video media switching media
const videoMediaChangeSupport = async () => {
	return new Promise(resolve => {
		const iframe = document.createElement("iframe");
		const video = document.createElement("video");
		const source = document.createElement("source");
		const mediaSource = new MediaSource();
		mediaSource.addEventListener("sourceopen", () => resolve(true));
		source.src = URL.createObjectURL(mediaSource);
		source.media = "(min-width:10px)";
		video.append(source);
		iframe.width = "5";
		iframe.style.cssText = `position: absolute; visibility: hidden;`;
		document.documentElement.append(iframe);
		iframe.contentDocument.body.append(video);
		setTimeout(() => { iframe.width = "15"; });
		setTimeout(() => {
			iframe.remove();
			resolve(false);
		}, 1000);
	});
};

In case you're curious, the test works by creating a video element with a child source element that has a media query, and appends that video to a generated iframe which is too narrow for the media query to match. Then, it resizes the iframe so that the relevant media query will match. If the video requests the source after the iframe is resized, we can trust that the browser is already handling media switching in video, so we don't need to apply our web component at all!

Conditional Web Components

Lastly, it's time to re-define our web component ONLY if that feature test happens to fail. I'm calling this pattern a Conditional Web Component, because everything seems to have cool names now.

To make it conditional, the last line of our component will now look like this:

if( await videoMediaChangeSupport() === false ){
	customElements.define("responsive-video", ResponsiveVideo);
}

And again, that demo page has this latest logic baked in:

Even Responsiver Video Demo page

Using ResponsiveVideo.js

The script I've described is on Github, licensed open source under MIT. Feel free to use it, fork it, or send me issues to consider! And I'll plan publish it to NPM soon as well.

Github Repo for ResponsiveVideo

Thanks!

Thank you for reading. Feel free to reach out on Mastodon or via the contact form if you have ideas or questions.

This article was also posted on The Web Performance Calendar so feel free to add to the discussion over there!

By the way... I am currently exploring work opportunities for 2024! If you think I might make a good addition to your team, whether full-time or as an independent contractor, please reach out.