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.