back

Portable HTML web components – 12 Days of Web Components

December 16, 2023 – @hawkticehurst

Welcome back to 12 Days of Web Components! A series of interactive blog posts that introduces and explores the world of web components.

This is the post for day four, if you missed the previous posts I encourage you to go back to day one and start there.

Today you get a two-for-one post! We start having some fun covering what I think is one of the most compelling benefits of using web components –– it pairs amazingly with markdown.

We will then identify and discuss yet another web component architecture that sits somewhere in the middle of HTML and JavaScript Web Components. This doesn’t yet have a name, so I’m proposing “Portable HTML Web Components.”

Tieing this all together we will also look at our first properly complex web component: an <audio-player>.

Please note: This blog post includes interactive code examples. If you’re reading this from an RSS feed, I highly recommend you open the original post.

Browser-native MDX

MDX is a piece of technology that allows you to use interactive components inside your markdown. It is one of my favorite methods for building websites that I’ve come across.

It combines a seamless way to embed pockets of interactivity into your web pages with all the convenience of authoring content in markdown.

The one downside is that MDX is (more precisely) a way of using JSX inside of markdown. To be clear, I do not view JSX as the downside (for those who are unaware JSX is a historically polarizing technology due to its origins as a React/Meta project), but rather that it’s a limiting technology to build on top of.

While you can author components in JSX using most modern frontend frameworks these days there are no guarantees that JSX (or even MDX for that matter) will be a well-supported method of embedding components into your markdown in the future. If and when that day comes I will have to re-author all of my content if I’m tied to MDX.

But you know what technology does have a very strong guarantee of sticking around long into the future? HTML. And you know what markdown conveniently supports natively? HTML. And (as we’ve established the last few days) do you remember what web components are, definitionally? Ding ding, HTML.

Said without all the Q&A, markdown and web components can be used as a browser-native MDX.

# Some example markdown

Author your content using normal markdown syntax.

But when you need a component...

<my-component></my-component>

...you literally just add it.

And remember, don't forget to add a script tag.

<script type="module" scr="/my-element.js"></script>

Voilà! You embedded an interactive component into your markdown. It’s so simple, it’s honestly why I didn’t make this topic a full blog post.

With that said, I will leave you with a few tips/insights I’ve gathered along the way.

Component definitions right inside markdown

You can also author the component code right inside your markdown file if you desire.

# Markdown content

Blah blah blah...

<my-component></my-component>

<script type="module">
	class MyComponent extends HTMLElement {
		// ... component behavior ...
	}
	customElements.define('my-component', MyComponent);
</script>

HTML web components too!

Again, it’s all HTML so markdown doesn’t mind at all.

# More markdown content

Yada yada yada...

<my-component>
	<p>Some markup that will be hydrated.</p>
	<button>Click me!</button>
</my-component>

<script type="module">
	class MyComponent extends HTMLElement {
		// ... component behavior ...
	}
	customElements.define('my-component', MyComponent);
</script>

Or you can still link to an external file.

<script type="module" scr="/my-element.js"></script>

You can even define component styles!

<style>
	my-component p {
		/* ... styles ... */
	}
	my-component button {
		/* ... styles ... */
	}
</style>

Watch out for how markdown parsers handle script tags

Depending on the markdown parser you use to generate HTML, be wary of parsers that will escape your JavaScript code. In those cases, just default back to linking to an external JavaScript file as shown above.

An example and a segue

Markdown and web components are so flexible/enjoyable it’s what I’ve been using to author this entire series! Here’s an example of the actual HTML I’m writing inside my markdown files to render these posts.

This also introduces our component of the day, the <audio-player>.

<section class="render-container">
	<div class="render-container-border">
		<audio-player src="/audio/requiem-in-d-minor.mp3" title="Requiem in D Minor"></audio-player>
	</div>
</section>

Portable HTML web components

At this point, I’ve discussed across multiple days what HTML and JavaScript web components are and how I like to think of them as living on a spectrum rather than as discrete entities. You can mix and match the browser APIs you use to build web components that find a balance between the capabilities you need while not taking on extra development complexity where you don’t need it.

There’s a really interesting grey zone that exists between these two that I want to talk about today that I’ve come to call “Portable HTML Web Components.”

It’s all a bunch of tradeoffs

At the two ends of this spectrum live a bunch of tradeoffs. With HTML web components you get web components that are very server-side rendering friendly, allow for flexible styling, are great for progressive enhancement, and avoid the pitfalls of shadow DOM. However, they come at the cost of portability, scoped styling, and template slots.

To use our <audio-player> component as an example, the markup when built as an HTML web component will look like this:

<audio-player src="/audio/requiem-in-d-minor.mp3" title="Requiem in D Minor">
	<audio></audio>
	<div class="left-container">
		<button id="play-btn" aria-label="Play song">
			<svg id="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="35" height="35" fill="currentColor" aria-hidden="true">
				<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"></path>
			</svg>
			<svg id="pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" width="30" height="30" fill="currentColor" aria-hidden="true" style="display: none;">
				<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
			</svg>
		</button>
	</div>
	<div class="right-container">
		<p id="song-title">Song title</p>
		<div class="audio-bar">
			<span id="current-time">00:00</span>
			<progress value="0" max="1"></progress>
			<span id="total-time">00:00</span>
		</div>
	</div>
</audio-player>

The markup of this component lives in the light DOM, so (as I said above) it’s really easy to style and progressively enhance. What I really dislike about HTML web components, however, is that they are not portable at all. To use this <audio-player> I will need to copy and paste not only the custom element tag but all of the child elements that live inside it.

On the other end of the spectrum, JavaScript web components allow for fantastic portability, native scoped styling, and template slots, but come at the cost of poor server-side rendering capabilities*, are not easy to progressively enhance, and are a more complex development model to work with since more browser APIs are used.

*At the time of writing, Declarative shadow DOM is very close to having cross-browser support, so this reality will likely change soon.

As a JavaScript web component, our <audio-player> markup will instead look like this:

<audio-player src="/audio/requiem-in-d-minor.mp3" title="Requiem in D Minor"></audio-player>

Very portable!

A hybrid model

Browser APIs are flexible building blocks, however. There are no prescribed ways for how you need to use them, so what if we attempted to implement a sort of hybrid between the two models I described above?

I’m very interested in building an audio player that is focused on portability while still maintaining the simpler and more flexible development model of HTML web components. I want to be able to put as many audio players as I want across a project and just insert a simple script tag that points to a single JavaScript file like you would with a JavaScript web component.

<script type="module" src="/audio-player.js"></script>

This means I’ll need to move my component markup out of my custom element tag and (in this case) into a JavaScript-defined template.

const template = document.createElement('template');
template.innerHTML = /* html */ `
	<audio></audio>
	<div class="left-container">
		<button id="play-btn" aria-label="Play song">
			<svg id="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="35" height="35" fill="currentColor" aria-hidden="true">
				<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"></path>
			</svg>
			<svg id="pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" width="30" height="30" fill="currentColor" aria-hidden="true" style="display: none;">
				<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
			</svg>
		</button>
	</div>
	<div class="right-container">
		<p id="song-title">Song title</p>
		<div class="audio-bar">
			<span id="current-time">00:00</span>
			<progress value="0" max="1"></progress>
			<span id="total-time">00:00</span>
		</div>
	</div>
`;

It will then be possible to add a custom element class in the same file that can reference, clone, and append the template as a direct child of the custom element tag.

class AudioPlayer extends HTMLElement {
	constructor() {
		super();
		this.appendChild(template.content.cloneNode(true));
	}
}

From there, all the other behavior to bring this audio player to life can be added. I won’t go over it in detail in this post, but you can find the completed code in the collapsible view below.

One extremely important thing I want to call out, however. In this model, you need to clone the template content before most other component code! If you don’t, getting references to DOM nodes and setting event listeners will fail.

Completed audio player code
<audio-player src="/audio/requiem-in-d-minor.mp3" title="Requiem in D Minor"></audio-player>

<script type="module">
	const template = document.createElement('template');
	template.innerHTML = /* html */ `
		<audio></audio>
		<div class="left-container">
			<button id="play-btn" aria-label="Play song">
				<svg id="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="35" height="35" fill="currentColor" aria-hidden="true">
					<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"></path>
				</svg>
				<svg id="pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" width="30" height="30" fill="currentColor" aria-hidden="true" style="display: none;">
					<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
				</svg>
			</button>
		</div>
		<div class="right-container">
			<p id="song-title">Song title</p>
			<div class="audio-bar">
				<span id="current-time">00:00</span>
				<progress value="0" max="1"></progress>
				<span id="total-time">00:00</span>
			</div>
		</div>
	`;

	/**
	 * Custom element based on `audio-tool` by Kevin Logan
	 * https://github.com/kevinlogan94/audio-tool/blob/master/src/audio-tool.js
	 */
	class AudioPlayer extends HTMLElement {
		static get observedAttributes() {
			return ['src', 'title', 'preload', 'auto-play'];
		}
		constructor() {
			super();
			this.appendChild(template.content.cloneNode(true));
			this.audio = this.querySelector('audio');
			this.playIcon = this.querySelector('#play-icon');
			this.pauseIcon = this.querySelector('#pause-icon');
			this.playPauseButton = this.querySelector('#play-btn');
			this.progressBar = this.querySelector('progress');
			this.currentTime = this.querySelector('#current-time');
			this.duration = this.querySelector('#total-time');
			this.songTitle = this.querySelector('#song-title');
			this.playing = false;
		}
		connectedCallback() {
			this.configureAudio();
			this.checkFileTypeSupport();
			this.setSongTitle();
			this.setPlayPauseListener();
			this.setProgressListener();
			this.setTimeListener();
		}
		attributeChangedCallback(name, oldValue, newValue) {
			switch (name) {
				case 'src':
					if (this.audio) {
						this.audio.removeEventListener('timeupdate', this.updateTimeHandler);
						this.audio.removeEventListener('loadedmetadata', this.metadataTimeHandler);
					}
					this.configureAudio();
					this.checkFileTypeSupport();
					if (this.audio && this.playIcon && this.pauseIcon && this.playPauseButton) {
						this.pauseIcon.style.display = 'none';
						this.playIcon.style.display = 'block';
						this.playPauseButton.setAttribute('aria-label', 'Play song');
						this.audio.pause();
						this.playing = false;
						this.dispatchEvent(new Event('audiopaused'));
					}
					if (this.progressBar) {
						this.progressBar.value = 0;
					}
					this.setTimeListener();
					break;
				case 'title':
					this.setSongTitle();
					break;
				default:
					break;
			}
		}
		configureAudio() {
			if (this.audio && this.hasAttribute('src')) {
				this.audio.src = this.getAttribute('src') || '';
			}
			if (this.audio && this.hasAttribute('preload')) {
				const preload = this.getAttribute('preload');
				if (preload === '' || preload === 'none' || preload === 'metadata' || preload === 'auto') {
					this.audio.preload = preload;
				}
			}
			if (this.audio && this.hasAttribute('auto-play')) {
				if (this.getAttribute('auto-play') === 'true') {
					this.audio.autoplay = true;
					this.audio.load();
				}
			}
		}
		checkFileTypeSupport() {
			if (this.audio && this.audio.src) {
				const songFile = this.audio.src;
				const fileType = songFile.substring(songFile.lastIndexOf('.') + 1);
				// https://diveintohtml5.info/everything.html#audio-vorbis
				switch (fileType) {
					case 'mp3':
						if (!(this.audio.canPlayType && this.audio.canPlayType('audio/mpeg;').replace(/no/, ''))) {
							this.setUnsupportedMessage("This browser doesn't support MP3 audio files.");
						}
						break;
					case 'm4a':
						if (!(this.audio.canPlayType && this.audio.canPlayType('audio/mp4; codecs="mp4a.40.2"').replace(/no/, ''))) {
							this.setUnsupportedMessage("This browser doesn't support M4A audio files.");
						}
						break;
					case 'aac':
						if (!(this.audio.canPlayType && this.audio.canPlayType('audio/mp4; codecs="mp4a.40.2"').replace(/no/, ''))) {
							this.setUnsupportedMessage("This browser doesn't support AAC audio files.");
						}
						break;
					case 'wav':
						if (!(this.audio.canPlayType && this.audio.canPlayType('audio/wav; codecs="1"').replace(/no/, ''))) {
							this.setUnsupportedMessage("This browser doesn't support WAV audio files.");
						}
						break;
					case 'ogg':
						if (!(this.audio.canPlayType && this.audio.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, ''))) {
							this.setUnsupportedMessage("This browser doesn't support Vorbis audio files.");
						}
						break;
					default:
						if (!this.audio.canPlayType('audio/' + fileType)) {
							this.setUnsupportedMessage("This browser doesn't support this audio file");
						}
						break;
				}
			}
		}
		setSongTitle() {
			if (this.songTitle && this.hasAttribute('title')) {
				this.songTitle.textContent = this.getAttribute('title');
			} else if (this.songTitle && this.hasAttribute('src')) {
				this.songTitle.textContent = this.getAttribute('src');
			}
		}
		setPlayPauseListener() {
			this.playPauseButton?.addEventListener('click', () => {
				if (this.pauseIcon && this.playIcon && this.playPauseButton && this.audio) {
					if (!this.playing) {
						this.pauseIcon.style.display = 'block';
						this.playIcon.style.display = 'none';
						this.playPauseButton.setAttribute('aria-label', 'Pause song');
						this.audio.play();
						this.playing = true;
						this.dispatchEvent(new Event('audioplaying'));
					} else {
						this.pauseIcon.style.display = 'none';
						this.playIcon.style.display = 'block';
						this.playPauseButton.setAttribute('aria-label', 'Play song');
						this.audio.pause();
						this.playing = false;
						this.dispatchEvent(new Event('audiopaused'));
					}
				}
			});
		}
		setProgressListener() {
			this.progressBar?.addEventListener('click', (event) => {
				if (this.progressBar && this.audio) {
					let percent = event.offsetX / this.progressBar.offsetWidth;
					this.audio.currentTime = percent * this.audio.duration;
					this.progressBar.value = percent;
				}
			});
		}
		playSong() {
			if (this.audio && this.playIcon && this.pauseIcon && this.playPauseButton) {
				this.pauseIcon.style.display = 'block';
				this.playIcon.style.display = 'none';
				this.playPauseButton.setAttribute('aria-label', 'Pause song');
				this.audio.play();
				this.playing = true;
				this.dispatchEvent(new Event('audioplaying'));
			}
		}
		setTimeListener() {
			this.audio?.addEventListener('loadedmetadata', () => {
				this.metadataTimeHandler();
			});
		}
		metadataTimeHandler() {
			if (this.audio && this.currentTime && this.duration) {
				this.currentTime.textContent = `${this.formatTime(
					this.audio.currentTime
				)}`;
				this.duration.textContent = `${this.formatTime(this.audio.duration)}`;
				this.audio.addEventListener('timeupdate', () => {
					this.updateTimeHandler();
				});
			}
		}
		updateTimeHandler() {
			if (this.audio && this.currentTime && this.duration && this.progressBar && this.playIcon && this.pauseIcon && this.playPauseButton) {
				if (!isNaN(this.audio.currentTime / this.audio.duration)) {
					this.progressBar.value = this.audio.currentTime / this.audio.duration;
					this.currentTime.textContent = `${this.formatTime(
						this.audio.currentTime
					)}`;
					this.duration.textContent = `${this.formatTime(this.audio.duration)}`;
					if (this.audio.currentTime === this.audio.duration) {
						this.audio.currentTime = 0;
						this.pauseIcon.style.display = 'none';
						this.playIcon.style.display = 'block';
						this.playPauseButton.setAttribute('aria-label', 'Play song');
						this.playing = false;
						this.dispatchEvent(new Event('audiopaused'));
						this.dispatchEvent(new Event('audiofinished'));
					}
				}
			}
		}
		setUnsupportedMessage(message) {
			if (this.songTitle) {
				this.songTitle.textContent = message;
			}
		}
		formatTime(seconds) {
			const hours = Math.floor(seconds / 3600);
			const minutes = Math.floor((seconds % 3600) / 60);
			const remainingSeconds = Math.floor(seconds % 60);
			let formattedTime = '';
			if (hours > 0) {
				const formattedHours = hours.toString().padStart(2, '0');
				formattedTime = `${formattedHours}:${minutes
					.toString()
					.padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
			} else {
				formattedTime = `${minutes.toString().padStart(2, '0')}:${remainingSeconds
					.toString()
					.padStart(2, '0')}`;
			}
			return formattedTime;
		}
	}
	customElements.define('audio-player', AudioPlayer);
</script>

<style>
	audio-player {
		display: grid;
		grid-template-columns: 15% 85%;
		justify-items: center;
		align-items: center;
		background-color: rgb(95, 85, 236, 0.5);
		border: 2px solid rgb(95, 85, 236);
		border-radius: 8px;
		padding: 0.5rem;
		width: 100%;
	}
	audio-player button {
		color: white;
		background-color: rgb(122 112 255);
		border: none;
		border-radius: 50%;
		cursor: pointer;
		width: 40px;
		height: 40px;
		padding: 0;
		line-height: 0;
		display: inline-flex;
		align-items: center;
		justify-content: center;
	}
	audio-player button:focus-visible {
		outline: solid 2px var(--focus-outline);
		outline-offset: 2px;
	}
	audio-player button svg {
		width: 20px;
		height: 20px;
		margin: 0;
		padding: 0;
	}
	audio-player #play-icon {
		margin-left: 2px;
	}
	audio-player .audio-bar {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 10px;
	}
	audio-player progress {
		grid-column: 3 / span 10;
		cursor: pointer;
		display: block;
		width: 100%;
		height: 6px;
		border: none;
		border-radius: 100px;
		background-color: rgb(190, 190, 190);
	}
	audio-player progress::-webkit-progress-bar {
		background-color: rgb(190, 190, 190);
		border-radius: 100px;
		height: 6px;
	}
	audio-player progress::-webkit-progress-value {
		background-color: rgb(122 112 255);
		border-radius: 100px;
		height: 6px;
	}
	audio-player progress::-moz-progress-bar {
		background-color: rgb(122 112 255);
		border-radius: 100px;
		height: 6px;
	}
	audio-player label {
		display: block;
		font-size: 1rem;
	}
	audio-player #song-title {
		font-size: 1rem;
		font-weight: 600;
		margin: 0;
	}
	audio-player .left-container {
		order: 1;
		height: auto;
		width: auto;
	}
	audio-player .right-container {
		order: 2;
		height: auto;
		width: 95%;
	}
	@media screen and (min-width: 48em) {
		audio-player #song-title {
			font-size: 1rem;
		}
	}
</style>

There are no silver bullets

As a reminder, grey zones do not entail an absence of trade-offs so just like the other models, portable HTML web components come with their own tradeoffs. What I’ve gained in newfound portability, I’ve lost in the form of good SSR-ability and easy progressive enhancement.

But here’s the thing, I think that’s okay! There are no silver bullets in technology (or life for that matter). It’s up to you to pick the correct tool for the task at hand. Portable HTML web components are now just another tool you can add to your toolbox to be used if/when you need it.

As a final thought, I leave you with this question: What other possible combinations of browser APIs and hybrid web component models live in this grey zone?

In the next episode…

Thanks for stopping by! In the next post, we’re pivoting from theory and examples to look at a library of web components in production in the real world.

Follow me on Mastodon for updates and see you tomorrow!