I have to thank Jeremy Keith and his wonderfully insightful article from late last year that introduced me to the concept of HTML Web Components. This was the “a-ha!” moment for me:
When you wrap some existing markup in a custom element and then apply some new behaviour with JavaScript, technically you’re not doing anything you couldn’t have done before with some DOM traversal and event handling. But it’s less fragile to do it with a web component. It’s portable. It obeys the single responsibility principle. It only does one thing but it does it well.
Until then, I’d been under the false assumption that all web components rely solely on the presence of JavaScript in conjunction with the rather scary-sounding Shadow DOM. While it is indeed possible to author web components this way, there is yet another way. A better way, perhaps? Especially if you, like me, advocate for progressive enhancement. HTML Web Components are, after all, just HTML.
While it’s outside the exact scope of what we’re discussing here, Andy Bell has a recent write-up that offers his (excellent) take on what progressive enhancement means.
Let’s look at three specific examples that show off what I think are the key features of HTML Web Components — CSS style encapsulation and opportunities for progressive enhancement — without being forced to depend on JavaScript to work out of the box. We will most definitely use JavaScript, but the components ought to work without it.
The examples can all be found in my Web UI Boilerplate component library (built using Storybook), along with the associated source code in GitHub.
Example 1: Live demoI really like how Chris Ferdinandi teaches building a web component from scratch, using a disclosure (show/hide) pattern as an example. This first example extends his demo.
Let’s start with the first-class citizen, HTML. Web components allow us to establish custom elements with our own naming, which is the case in this example with a tag we’re using to hold a designed to show/hide a block of text and a that holds the of text we want to show and hide.
Show / Hide
Content to be shown/hidden. If JavaScript is disabled or doesn’t execute (for any number of possible reasons), the button is hidden by default — thanks to the hidden attribute on it— and the content inside of the div is simply displayed by default.
Nice. That’s a really simple example of progressive enhancement at work. A visitor can view the content with or without the .I mentioned that this example extends Chris Ferdinandi’s initial demo. The key difference is that you can close the element either by clicking the keyboard’s ESC key or clicking anywhere outside the element. That’s what the two [data-attribute]s on the tag are for.
We start by defining the custom element so that the browser knows what to do with our made-up tag name:
`customElements.define('webui-disclosure', WebUIDisclosure);`
Custom elements must be named with a dashed-ident, such as or whatever, but as Jim Neilsen notes, by way of Scott Jehl, that doesn’t exactly mean that the dash *has* to go between two words.
I typically prefer using TypeScript for writing JavaScript to help eliminate stupid errors and enforce some degree of “defensive” programming. But for the sake of simplicity, the structure of the web component’s ES Module looks like this in plain JavaScript:
`default class WebUIDisclosure extends HTMLElement { constructor() { super(); this.trigger = this.querySelector('[data-trigger]'); this.content = this.querySelector('[data-content]'); this.bindEscapeKey = this.hasAttribute('data-bind-escape-key'); this.bindClickOutside = this.hasAttribute('data-bind-click-outside'); if (!this.trigger || !this.content) return; this.setupA11y(); this.trigger?.addEventListener('click', this); } setupA11y() { // Add ARIA props/state to button. } // Handle constructor() event listeners. handleEvent(e) { // 1. Toggle visibility of content. // 2. Toggle ARIA expanded state on button. } // Handle event listeners which are not part of this Web Component. connectedCallback() { document.addEventListener('keyup', (e) => { // Handle ESC key. }); document.addEventListener('click', (e) => { // Handle clicking outside. }); } disconnectedCallback() { // Remove event listeners. }}`
Are you wondering about those event listeners? The first one is defined in theconstructor()function, while the rest are in theconnectedCallback()function. Hawk Ticehurst explains the rationale much more eloquently than I can.
This JavaScript isn’t required for the web component to “work” but it does sprinkle in some nice functionality, not to mention accessibility considerations, to help with the progressive enhancement that allows the
to show and hide the content. For example, JavaScript injects the appropriatearia-expandedandaria-controls` attributes enabling those who rely on screen readers to understand the button’s purpose.That’s the progressive enhancement piece to this example.
For simplicity, I have not written any additional CSS for this component. The styling you see is simply inherited from existing global scope or component styles (e.g., typography and button).
However, the next example does have some extra scoped CSS.
Example 2: That first example lays out the progressive enhancement benefits of HTML Web Components. Another benefit we get is that CSS styles are encapsulated, which is a fancy way of saying the CSS doesn’t leak out of the component. The styles are scoped purely to the web component and those styles will not conflict with other styles applied to the current page.
Let’s turn to a second example, this time demonstrating the style encapsulating powers of web components and how they support progressive enhancement in user experiences. We’ll be using a tabbed component for organizing content in “panels” that are revealed when a panel’s corresponding tab is clicked — the same sort of thing you’ll find in many component libraries.
Live demoStarting with the HTML structure:
``` Tab 1 Tab 2 Tab 3 1 - Lorem ipsum dolor sit amet consectetur.
2 - Lorem ipsum dolor sit amet consectetur.
3 - Lorem ipsum dolor sit amet consectetur.
``
You get the idea: three links styled as tabs that, when clicked, open a tab panel holding content. Note that each[data-tab]in the tab list targets an anchor link matching a tab panel ID, e.g.,#tab1,#tab2`, etc.
We’ll look at the style encapsulation stuff first since we didn’t go there in the last example. Let’s say the CSS is organized like this:
webui-tabs { [data-tablist] { /* Default styles without JavaScript */ } [data-tab] { /* Default styles without JavaScript */ } [role='tablist'] { /* Style role added by JavaScript */ } [role='tab'] { /* Style role added by JavaScript */ } [role='tabpanel'] { /* Style role added by JavaScript */ }}
See what’s happening here? We have two style rules — [data-tablist] and [data-tab] — that contain the web component’s default styles. In other words, these styles are there regardless of whether JavaScript loads or not. Meanwhile, the other three style rules are selectors that are injected into the component as long as JavaScript is enabled and supported. This way, the last three style rules are only applied if JavaScript plops the **role** attribute on those elements in the HTML. Right there, we’re already supplying a touch of progressive enhancement by setting styles only when JavasScript is needed.
All these styles are fully encapsulated, or scoped, to the web component. There is no “leakage” so to speak that would bleed into the styles of other web components, or even to anything else on the page within the global scope. We can even choose to forego classnames, complex selectors, and methodologies like BEM in favour of simple descendent selectors for the component’s children, allowing us to write styles more declaratively on semantic elements.
Quickly: “Light” DOM versus Shadow DOMFor most web projects, I generally prefer to bundle CSS (including the web component Sass partials) into a single CSS file so that the component’s default styles are available in the global scope, even if the JavaScript doesn’t execute.
However, it is possible to import a stylesheet via JavaScript that is only consumed by this web component if JavaScript is available:
import styles from './styles.css';class WebUITabs extends HTMLElement { constructor() { super(); this.adoptedStyleSheets = [styles]; }}customElements.define('webui-tabs', WebUITabs);
Alternatively, we could inject a // etc.; }} customElements.define('webui-tabs', WebUITabs);`
Whichever method you choose, these styles are scoped directly to the web component, preventing component styles from leaking out, but allowing global styles to be inherited.
Now consider this simple example. Everything we write in between the component’s opening and closing tags is considered part of the “Light” DOM.
``` Some content... styles are inherited from the global scope
----------- Shadow DOM Boundary ------------- | | --------------------------------------------- ``` Dave Rupert has an excellent write-up that makes it really easy to see how external styles are able to “pierce” the Shadow DOM and select an element in the Light DOM. Notice how the
element that is written in between the custom element’s tags receives the button selector’s styles in the global CSS, while the injected via JavaScript is left untouched.If we want to style the Shadow DOM
we’d have to do that with internal styles like the examples above for importing a stylesheet or injecting an inline`