Web Components Can Be Simple:
Adding Interactivity

Published

Now that we've got a basic custom element going, let's add some interactivity to it.

Let's take a look at our Prideraiser.org site header again. This time we'll add the markup for the nav links and a button to toggle those links on narrow screens.

<pr-the-header role="banner">
  <a 
    class="site-brand" 
    href="/"
  >
    <img
      alt="Prideraiser"
      class="icon-mark"
      height="46"
      src="/brand/mark.svg"
      width="46"
    >
  </a>
  <button
    aria-controls="site-nav"
    aria-expanded="false"
    aria-label="Toggle site menu"
    class="nav-toggle"
    type="button"
  >
    <svg class="icon icon--bars icon--bars--sharp-solid"><use href="#icon--bars--sharp-solid"></use></svg>
  </button>
  <nav
    aria-label="Primary navigation"
    class="site-nav"
    id="site-nav"
  >
    <a href="/store/">Merch store</a>
    <a href="/login/">Log in</a>
  </nav>
</pr-the-header>

Progressive enhancment of interactive elements #

Before we add any JavaScript here, let's talk about how to progressively enhance this setup. We don't want to hide our links if the user has JavaScript disabled and we can use the :defined psuedo-class to help us with that. Because interactive Web Components have to be registered with the Custom Element Registry via JavaScript, any custom element that hasn't yet been registered will not match to :defined. Let's take a look:

pr-the-header {
  position: relative;
  z-index: 1;
}

/* here's the styling for the button generally */
pr-the-header .nav-toggle {
  align-items: center;
  background: transparent;
  border: 0;
  display: flex;
  justify-content: center;
  padding: 1em;
}

/* now when the element isn't defined, we hide this button */
pr-the-header:not(:defined) .nav-toggle {
  display: none;
}

/* setup the styles for the nav */
pr-the-header .site-nav {
  display: grid;
  gap: 1em;
  inset-block-start: 100%;
  inset-inline-end: 0;
  min-width: min(100vw, 20ch);
  opacity: 0;
  position: absolute;
  transition-property: translate, opacity;
  transition-timing-function: ease-in-out;
  translate: 0 100%;
  z-index: 1;
}

/* we only set transition duration if the element is defined. this prevents a flash of unstyled content */
pr-the-header:defined .site-nav {
  transition-duration: 200ms;
}

/* for accessibility we disable the duration for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  pr-the-header:defined .site-nav {
    transition-duration: 0;
  }
}

/* now if the element isn't defined, show the nav all the time */
pr-the-header:not(:defined) .site-nav {
  opacity: 1;
  position: static;
  translate: 0;
}

This gives us an simple, built-in way to provide a good experience for users with JavaScript disabled, or when a JavaScript error prevents our element from being registered.

Making the Web Component interactive #

For our site header, we want to handle clicks on our .nav-toggle button and change some aria- attributes that will trigger our nav to show. To do this we need to register our custom element's tag name (pr-the-header in our example) with the Custom Element Registry. Let's start by setting up the custom Class we'll use for our component:

class PrideraiserSiteHeader extends HTMLElement {
  connectedCallback() {
    // "this" is the custom element tag `pr-the-header`
    const toggle = this.querySelector('.nav-toggle');

    // add a handler for the toggle button
    toggle.addEventListener('click', (event) => {
      toggle.setAttribute('aria-expanded', toggle.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
    });
  }
}

There's lots of ways to handle this toggle, but I like the approach of relying on changing aria- attributes so that we're always addressing accessibility correctly. connectedCallback is one of the Web Component lifecyle events. It is fired every time the matching custom element is appended to the DOM. This means that if you have multiple instances of your custom element on a page, each one will get it's own connectedCallback call. This is a great place to add event listeners and other setup that needs to happen when the element is added to the DOM.

But only writing the class won't do anything. We need to register it with the Custom Element Registry:

class PrideraiserSiteHeader extends HTMLElement {
  connectedCallback() {
    // "this" is the custom element tag `pr-the-header`
    const toggle = this.querySelector('.nav-toggle');

    // add a handler for the toggle button
    toggle.addEventListener('click', (event) => {
      toggle.setAttribute('aria-expanded', toggle.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
    });
  }
}
// register the custom element
customElements.define('pr-the-header', PrideraiserSiteHeader);

Now we're all set and our CSS for :not(:defined) won't be applied. Here's the additional CSS we need to show the nav when the toggle is clicked:

/* for keyboard users, be sure to show the nav if they tab into it */
pr-the-header .site-nav:focus-within,
pr-the-header .nav-toggle[aria-expanded="true"] ~ .site-nav {
  opacity: 1;
  translate: 0 0;
}

That's all we need! Now we have a fully functional site header that's progressively enhanced and accessible. There's a full demo of this component on CodePen.

Coming up #

The next article in this series will explain how we use Django middleware to detect custom elements and serve up their CSS and JavaScript only when needed.