Skip to main content Accessibility Feedback

An example of an HTML Web Component

Recently, I wrote about how Web Components really clicked for me once I started using them to progressively enhancing existing HTML.

The emerging term for this approach is HTML Web Component.

I got asked by a lot of folks for a working example. Today, I wanted to share an example of an HTML Web Component I built for a project I’m working on for NASA.

👋 Quick aside: if you need help shipping code more quickly or reducing the complexity of your web projects, I have one consulting spot left. I’d love to work with you!

Let’s dig in!

The task: mutually exclusive <select> elements

The app I’m building has two <select> elements with the same exact items in them.

When an item is selected in one <select> menu, it should be [disabled] and unavailable in the other. Imagine something like picking a starting location and destination in an airline app.

As I mentioned, this is an app for NASA, so let’s use space-based examples (it’s not actually a space travel app).

<label for="origin">Starting Location</label>
<select id="origin" name="origin">
	<option value=""></option>
	<option value="Earth">Earth</option>
	<option value="Luna">Luna</option>
	<option value="Mars">Mars</option>
	<option value="Ganymede">Ganymede</option>
</select>

<label for="destination">Destination</label>
<select id="destination" name="destination">
	<option value=""></option>
	<option value="Earth">Earth</option>
	<option value="Luna">Luna</option>
	<option value="Mars">Mars</option>
	<option value="Ganymede">Ganymede</option>
</select>

You can’t return to the same place you started from on the same flight. If someone selects Earth as their starting locations, they shouldn’t be able to select it for their destination.

Creating an HTML Web Component

This is a great use case for an HTML Web Component!

We’ll wrap our <select> elements an <exclusive-select> element, and assign it a [target] attribute. The attribute value will be a string selector for the other <select> element to mutually exclude.

<label for="origin">Starting Location</label>
<exclusive-select target="#destination">
	<select id="origin" name="origin">
		<option value=""></option>
		<option value="Earth">Earth</option>
		<option value="Luna">Luna</option>
		<option value="Mars">Mars</option>
		<option value="Ganymede">Ganymede</option>
	</select>
</exclusive-select>

<label for="destination">Destination</label>
<exclusive-select target="#origin">
	<select id="destination" name="destination">
		<option value=""></option>
		<option value="Earth">Earth</option>
		<option value="Luna">Luna</option>
		<option value="Mars">Mars</option>
		<option value="Ganymede">Ganymede</option>
	</select>
</exclusive-select>

Before our Web Component JavaScript runs (or if it never does), we have perfectly usable HTML.

We’ll need to add a server-side check to make sure the same location isn’t picked for both, of course, but the app will still work just fine.

Once/if JavaScript is available to us, though, we can progressively enhance the HTML that’s already there.

Defining the Web Component

We’ll use the customElements.define() method to define our Web Component, which we’ll call exclusive-select (the name of the custom HTML element we used).

We’ll also pass in a class that extends the base HTMLElement class.

// JavaScript
customElements.define('exclusive-select', class extends HTMLElement {
	// ...
});

Inside the class, we’ll include a constructor() method. This automatically instantiates a new instance of our Web Component class on every custom element that matches the name we defined.

We need to run the super() method to ensure the class has access to all of the HTMLElement properties, then we can define some of our own.

We’ll use the Element.getAttribute() method to get the [target] attribute value, and save it to this.target. We’ll also using the Element.querySelector() method to get the nested <select> element, since we’re going to listen to events on it.

// JavaScript
customElements.define('exclusive-select', class extends HTMLElement {

	/**
	 * The class constructor object
	 */
	constructor () {

		// Always call super first in constructor
		super();

		// Define properties
		this.target = this.getAttribute('target');
		this.select = this.querySelector('select');

	}

});

Handling events

In a Web Component, the “best” place to add event listeners is in the connectedCallback() method.

This runs when the element is actually attached to the DOM. There’s another method, disconnectedCallback(), that runs when its removed. These provide useful hooks for adding and removing event listeners without overloading the browser’s memory on elements that aren’t in the DOM.

To make it easier to add and remove our listener, let’s create a createInputHandler() function that will generate the actual callback function we’re going to use for our event listener.

// JavaScript
customElements.define('exclusive-select', class extends HTMLElement {

	/**
	 * The class constructor object
	 */
	constructor () {
		// ...
	}

	/**
	 * Create an input handler with the instance bound to the callback
	 * @return {Function} The callback function
	 */
	createInputHandler () {
		// ...
	}

});

In the createInputHandler() method, we’ll return our callback function. I’m going to use an arrow function here so that we’ll continue to have access to this, which represent the Web Component element in our class.

Inside the callback function, I’ll pass this.target into the document.querySelector() method to get the partner <select> element.

/**
 * Create an input handler with the instance bound to the callback
 * @return {Function} The callback function
 */
createInputHandler () {
	return (event) => {
		
		// Get the target element
		let target = document.querySelector(this.target);
		if (!target) return;

	};
}

On a <select> element, the .options property returns all of the child <options> elements inside it. We’ll use a for...of loop to loop over each one.

If the option.value is the same as the event.target element’s .value (and it’s not empty), we’ll use the Element.setAttribute() method to add the [disabled] attribute. Otherwise, we’ll remove it.

/**
 * Create an input handler with the instance bound to the callback
 * @return {Function} The callback function
 */
createInputHandler () {
	return (event) => {
		
		// Get the target element
		let target = document.querySelector(this.target);
		if (!target) return;

		// Toggle disabled attribute on each option
		for (let option of target.options) {
			if (option.value === event.target.value && event.target.value) {
				option.setAttribute('disabled', '');
			} else {
				option.removeAttribute('disabled');
			}
		}

	};
}

Now, we’re ready to actually listen to events.

Adding listeners to the Web Component

Back in our constructor() method, we’ll run this.createInputHandler(), and assign the returned function to a this.handler property.

This gives us a method reference we can use to add and remove our listener as needed.

// JavaScript
customElements.define('exclusive-select', class extends HTMLElement {

	/**
	 * The class constructor object
	 */
	constructor () {

		// Always call super first in constructor
		super();

		// Define properties
		this.target = this.getAttribute('target');
		this.select = this.querySelector('select');
		this.handler = this.createInputHandler();

	}

	/**
	 * Create an input handler with the instance bound to the callback
	 * @return {Function} The callback function
	 */
	createInputHandler () {
		// ...
	}

});

Next, we’ll add connectedCallback() and disconnectedCallback() method. These run when the Web Component is added or removed from the DOM, respectively.

In the connectedCallback() method, we’ll add an input listener on the this.select element, and run this.handler in response. In the disconnectedCallback() method, we’ll remove that same listener.

/**
 * Listen for form submissions when the form is attached in the DOM
 */
connectedCallback () {
	this.select.addEventListener('input', this.handler);
}

/**
 * Stop listening for form submissions when the form is attached in the DOM
 */
disconnectedCallback () {
	this.select.removeEventListener('input', this.handler);
}

And that’s it! Now, we’ve progressively added interactivity to our HTML.

Here’s a demo.

Why I like this approach

If you’re used to traditional DOM manipulation, rewiring your brain for Web Components can take a little getting used to.

But Web Components give you a lot of nice things right out of the box!

🏴‍☠️ Quick note! If you want to really dig into how to work with Web Components, I have 17 lessons, a workshop, and a handful of projects over at the Lean Web Club. And today through Monday, you can join for less than $5/month.

I love that I don’t need to explicitly instantiate anything. I define my component, use my custom HTML elements in the UI, and things just work. It’s magical!

I also love that all of my properties, listeners, and so on are scoped to the component. It makes data and state management much, much easier. Similarly, Web Components provide built-in methods for running code when an element is loaded into the DOM or removed from it, which makes setup and cleanup easier.

Web Components are rapidly becoming my preferred way to add progressive enhancement to HTML elements.