Skip to main content Accessibility Feedback

Progressively enhancing a Web Component

This week, we’ve looked at how Web Components are different from React, how to create your first Web Component, and how to add options to a Web Component.

Today, we’re going to look at one of my favorite Web Component features: progressive enhancement.

Let’s dig in!

A few different approaches

There are two different ways to progressively enhance a Web Component (there’s probably more, but these are the two I use most often)…

  1. If the content only works with JavaScript, hide it, and only show it after the Web Component instantiates.
  2. If the content works without JavaScript, show a default HTML experience, and then layer in additional HTML and JavaScript-based interactivity after the Web Component instantiates.

In this article, we’ll look at both approaches, and when and why to choose one approach over the other.

Approach 1. Hide the content

For this approach, let’s look at the example we’ve been using all week, our <wc-count> component that creates a counter button.

<wc-count>
	<button>Clicked 0 Times</button>
</wc-count>

Here, the button does absolutely nothing without JavaScript. As a result, it probably makes sense to hide it entirely until the Web Component is ready.

There are a few ways to do that…

Using the [hidden] attribute

One way to do that is by slapping a [hidden] attribute on the element.

<wc-count hidden>
	<button>Clicked 0 Times</button>
</wc-count>

Then, in our constructor() method, we can remove the attribute when instantiating the component.

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

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

	// ...

	// Show the element
	this.removeAttribute('hidden');

}

Using CSS

The :defined pseudo-class provides another way to detect when a custom element has been defined using just CSS instead of JavaScript.

We can combine it with the :not() pseudo-class to hide our custom element until it’s defined.

/* Hide the <wc-count> element until it's defined */
wc-count:not(:defined) {
	display: none;
}

With a fallback message

It might be a good idea to load a fallback message while the content is loading.

Here, I’ve wrapped that content in a <wc-count-loading> custom element, and added the [hidden] attribute to the <button> element instead.

<wc-count>
	<button hidden>Clicked 0 Times</button>
	<wc-count-loading>Loading...</wc-count-loading>
</wc-count>

Then, in the constructor() method, I remove the [hidden] attribute from this.button. I also use the Element.querySelector() method to get the <wc-count-loading> element, and the Element.remove() method to remove it once the content is ready.

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

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

	// Instance properties
	this.button = this.querySelector('button');
	// ...

	// Show the element
	this.button.removeAttribute('hidden');
	this.querySelector('wc-count-loading')?.remove();

}

Approach 2. Layering in HTML and interactivity

This approach works great when you have content that stands on its own, but benefits from some added flourish or interactivity.

For example, let’s imagine you’re creating an accordion group, with different expand/collapse sections.

You can start this off with HTML that includes headings and content, like this…

<accordion-group>
	<h2>Why is Lil' Wayne hip-hop top 5 list?</h2>
	<div>
		<p>...</p>
	</div>

	<h2>How many spells could Merlin cast in a day?</h2>
	<div>
		<p>...</p>
	</div>
</accordion-group>

When you instantiate your Web Component in the constructor() method, you can then update the UI to hide or show elements as needed, inject additional HTML, add required attributes, and so on.

/**
 * Instantiate the Web Component
 */
constructor () {

	// Get all accordion headings
	let headings = this.querySelectorAll('h2');

	// Update content
	for (let heading of headings) {

		// Get the matching content
		let content = heading.nextElementSibling;
		if (!content) continue;

		// Create a button, and copy heading content into it
		let btn = document.createElement('button');
		btn.innerHTML = heading.innerHTML;

		// Wipe the heading content, and replace it with the button
		heading.innerHTML = '';
		heading.append(btn);

		// Hide the content
		content.setAttribute('hidden', '');

		// Add ARIA
		btn.setAttribute('aria-expanded', false);

	}

}

You end up with HTML that looks something like this…

<accordion-group>
	<h2><button>Why is Lil' Wayne hip-hop top 5 list?</button></h2>
	<div hidden>
		<p>...</p>
	</div>

	<h2><button>How many spells could Merlin cast in a day?</button></h2>
	<div hidden>
		<p>...</p>
	</div>
</accordion-group>

Which approach should you use?

I favor starting with base HTML and layering in interactivity whenever you can.

If I have content that simply does not work with HTML and has no base-level experience, I’ll try to include a fallback message until the Web Component loads, but otherwise hide the content.