The web Components v1 specification consists of three main technologies that can be used to create reusable custom elements:
- Custom elements
- HTML templates
- Shadow DOM
Web components usually work as atomic units that don't need anything from the outside world. Sometimes, though, they require a certain degree of versatility, only reachable with the help of composition.
In this article, we will see one such scenario by creating a minimal dropdown implementation.
We will leverage what we've done in the previous articles of this series by reusing a lot of code.
Table of contents
- Dropdown structure
- Composing a dropdown
- The project
- The index.html file
- The main.js module
- The style.css file
-
The
my-button
web component -
The
my-dropdown
web component - The
my-dropdown-option
web component - Recap
Dropdown structure
A basic dropdown is made of three main parts:
- A button that toggles the dropdown options.
- A popover that holds the options.
- A collection of selectable options.
Composing a dropdown
By bringing that structure into web component land, we could envision a user API such as:
<my-dropdown placeholder="Select">
<my-dropdown-option value="option-1">
Option 1
<p slot="content" class="option-description">
Option 1 description
</p>
</my-dropdown-option>
(...)
</my-dropdown>
It's similar to the <select>
control but a little more interesting because the options can contain more stuff. And it will look like this:
The project
The code will be all vanilla Js using native modules.
No tooling/bundling is required, just The Web Platform™️.
.
├── package.json
└── src
├── components
│ ├── my-button
│ │ ├── my-button.css
│ │ └── my-button.js
│ ├── my-dropdown
│ │ ├── my-dropdown.css
│ │ └── my-dropdown.js
│ └── my-dropdown-option
│ ├── my-dropdown-option.css
│ └── my-dropdown-option.js
├── index.html
├── main.js
└── style.css
The index.html
file
It makes sense to start with the entry point of our example to see everything that's being loaded.
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script type="module" src="/main.js"></script>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<my-dropdown placeholder="Select">
<my-dropdown-option value="light">
Light theme
<p slot="content" class="option-description">
Your vision sharpens, enabling you to focus better
</p>
</my-dropdown-option>
<my-dropdown-option value="dark">
Dark theme
<p slot="content" class="option-description">
Reduce eye strain in low light conditions
</p>
</my-dropdown-option>
<my-dropdown-option value="sepia">
Sepia theme
<p slot="content" class="option-description">Middle ground</p>
</my-dropdown-option>
</my-dropdown>
</body>
</html>
A couple of remarks.
We initialise all web components by loading the main.js
module and add some logic to toggle the page theme between light, dark and sepia.
<script type="module" src="/main.js"></script>
The style.css
file contains CSS variables that define the aforementioned supported themes and a few custom styles that use fancy selectors that are worth exploring.
<link rel="stylesheet" href="/style.css" />
The main.js
module
As we mentioned earlier, this is a native Javascript module, that's why we can use the import
syntax.
Here we're loading our web components and adding some logic to toggle the page theme.
// main.js
import "/components/my-button/my-button.js";
import "/components/my-dropdown/my-dropdown.js";
import "/components/my-dropdown-option/my-dropdown-option.js";
const main = () => {
const myDropdown = document.querySelector("my-dropdown");
myDropdown.addEventListener("myDropdownChange", (event) => {
console.log(event.type, event.detail);
document.querySelector("html").dataset.theme = event.detail;
});
};
window.addEventListener("DOMContentLoaded", main);
By setting <html data-theme="dark">
and with the help of CSS variables, we can manipulate the look and feel of the entire page!
The style.css
file
This file contains mainly CSS variables, but some interesting selectors are worth exploring too. We'll examine them when we get to the components.
/* style.css */
:root {
--background-color: #fff;
--color-brand: #0b66fa;
--color-white: #fff;
--color-black: #000000de;
--border-radius-sm: 8px;
--gap-sm: 8px;
--font-sm: 18px;
--font-md: 20px;
/* theme */
--background-low-contrast: var(--color-white);
--background-high-contrast: var(--color-black);
--text-low-contrast: var(--color-white);
--text-high-contrast: var(--color-black);
--border-low-contrast: 1px solid var(--color-white);
--border-high-contrast: 1px solid var(--color-black);
}
html[data-theme="dark"] {
--background-color: #212a2e;
--background-low-contrast: var(--color-black);
--background-high-contrast: var(--color-white);
--text-low-contrast: var(--color-black);
--text-high-contrast: var(--color-white);
--border-low-contrast: 1px solid var(--color-black);
--border-high-contrast: 1px solid var(--color-white);
}
html[data-theme="sepia"] {
--background-color: #bfa26b;
--background-low-contrast: #bfa26b;
--background-high-contrast: #594225;
--text-low-contrast: #bfa26b;
--text-high-contrast: #401902;
--border-low-contrast: 1px solid #bfa26b;
--border-high-contrast: 1px solid #401902;
}
body {
background-color: var(--background-color);
font-family: sans-serif;
}
/*
Styling user content placed inside of a slot
*/
my-dropdown-option .option-description {
font-size: small;
color: var(--text-high-contrast);
}
my-dropdown-option:hover .option-description {
color: var(--text-low-contrast);
}
/*
----------------------------
-------- CSS PARTS ---------
-- styling the shadow DOM --
----- from the outside -----
----------------------------
*/
my-dropdown::part(options) {
border-radius: var(--border-radius-sm);
}
my-dropdown-option:not(:first-child)::part(container) {
border-top: var(--border-high-contrast);
}
my-dropdown-option:first-child::part(container) {
border-top-left-radius: var(--border-radius-sm);
border-top-right-radius: var(--border-radius-sm);
}
my-dropdown-option:last-child::part(container) {
border-bottom-left-radius: var(--border-radius-sm);
border-bottom-right-radius: var(--border-radius-sm);
}
The <my-button>
web component
The my-button.js
code is the same we had on previous articles of this series, but with an icon slot, the HTML template inlined, and the CSS loaded from an external file.
// components/my-button/my-button.js
const template = document.createElement("template");
template.innerHTML = /* html */ `
<style>
@import "/components/my-button/my-button.css";
</style>
<button>
<slot></slot>
<slot name="icon"></slot>
</button>
`;
customElements.define(
"my-button",
class extends HTMLElement {
static get observedAttributes() {
return ["variant"];
}
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(template.content.cloneNode(true));
}
}
);
Loading CSS externally is an excellent way to improve the DX and make the implementation more self-contained.
Worth mentioning that the /* html */
comment right before the HTML template tells VScode (es6-string-html extension required) that this block has to be syntax highlighted.
Cool, huh? 😎
Regarding CSS, the only thing that differs from the my-button
implementation in the previous article is the introduction of the icon slot
, which we select with the ::slotted
selector.
/* components/my-button/my-button.css */
:host {
display: inline;
}
:host button {
cursor: pointer;
font-size: 20px;
font-weight: 700;
padding: 12px;
min-width: 180px;
border-radius: 12px;
}
:host([variant="primary"]) button {
background-color: var(--color-brand);
color: var(--color-white);
border: 0;
}
:host([variant="secondary"]) button {
border: var(--border-high-contrast);
background-color: var(--background-low-contrast);
color: var(--text-high-contrast);
}
slot[name="icon"]::slotted(*) {
margin-left: var(--gap-sm);
}
::slotted
::slotted
only works from within the component. You can't use ::slotted
from, let's say, the style.css file. Only styles local to the shadow DOM have access to this selector. Also, it only targets elements, not text nodes.
Here we are saying: Select any element inside the slot
named icon
:
slot[name="icon"]::slotted(*) { /* ... */ }
The <my-dropdown>
web component
// components/my-dropdown/my-dropdown.js
const template = document.createElement("template");
template.innerHTML = /* html */ `
<style>
@import "/components/my-dropdown/my-dropdown.css";
</style>
<my-button variant="secondary"></my-button>
<div part="options">
<slot></slot>
</div>
`;
customElements.define(
"my-dropdown",
class extends HTMLElement {
static get observedAttributes() {
return ["placeholder", "open"];
}
get placeholder() {
return this.getAttribute("placeholder") || "";
}
get open() {
return this.hasAttribute("open") && this.getAttribute("open") !== "false";
}
set open(value) {
value === true
? this.setAttribute("open", "")
: this.removeAttribute("open");
}
constructor() {
super();
this.toggle = this.toggle.bind(this);
this.optionClickHandler = this.optionClickHandler.bind(this);
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.host.setAttribute("exportparts", "options");
shadowRoot.appendChild(template.content.cloneNode(true));
}
attributeChangedCallback() {
this.render();
}
connectedCallback() {
this.shadowRoot
.querySelector("my-button")
.addEventListener("click", this.toggle);
this.addEventListener("myDropdownOptionClick", this.optionClickHandler);
}
optionClickHandler(event) {
event.stopImmediatePropagation();
this.selection = event.detail.label;
this.open = false;
this.dispatchEvent(
new CustomEvent("myDropdownChange", {
detail: event.detail.value,
})
);
this.render();
}
toggle() {
this.open = !this.open;
this.render();
}
render() {
this.shadowRoot.querySelector("my-button").innerHTML = /* html */ `
${this.selection || this.placeholder}
<span slot="icon">${this.open ? "▲" : "▼"}</span>
`;
}
}
);
As expected, this file has some more logic to digest. Let's take a look at the interesting bits.
The part
and exportparts
attributes
In the same way that ::slotted
is used to select elements local to the same template, the part
attribute (and the accompanying exportparts
) is used to enable selecting elements from outside of the template. Or, wording it differently, it effectively exposes a CSS API that end users can consume from the global CSS scope! 🤯
In the context of our dropdown, the CSS API is created by these two lines of code:
<div part="options">
and
shadowRoot.host.setAttribute("exportparts", "options");
::part()
To better understand how this works, we can return to the global CSS located in our style.css file, where we make use of the options
part:
/* style.css (around line 75) */
my-dropdown::part(options) {
border-radius: var(--border-radius-sm);
}
As we can see here, the host component can also use the ::part()
pseudo selector.
/* components/my-dropdown/my-dropdown.css */
:host {
display: block;
}
:host::part(options) {
display: none;
flex-direction: column;
margin-top: var(--gap-sm);
border: var(--border-high-contrast);
}
:host([open])::part(options) {
display: flex;
}
We used the ::part()
pseudo selector to style the component instead of creating a class selector.
-
:host::part(options) {}
: Select the options part. -
:host([open])::part(options) {}
: Select the options part only when the host component has the open attribute set.
The <my-dropdown-option>
web component
And, last but not least, the options web component.
// components/my-dropdown-option/my-dropdown-option.js
const template = document.createElement("template");
template.innerHTML = /* html */ `
<style>
@import "/components/my-dropdown-option/my-dropdown-option.css";
</style>
<div part="container">
<slot></slot>
<slot name="content"></slot>
</div>
`;
customElements.define(
"my-dropdown-option",
class extends HTMLElement {
static get observedAttributes() {
return ["value"];
}
get value() {
return this.getAttribute("value");
}
get label() {
const slot = this.shadowRoot.querySelectorAll("slot")[0];
return slot.assignedNodes().at(0).textContent;
}
constructor() {
super();
this.clickHandler = this.clickHandler.bind(this);
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.host.setAttribute("exportparts", "container");
shadowRoot.appendChild(template.content.cloneNode(true));
}
connectedCallback() {
this.addEventListener("click", this.clickHandler);
}
clickHandler() {
this.dispatchEvent(
new CustomEvent("myDropdownOptionClick", {
detail: {
label: this.label,
value: this.value,
},
bubbles: true,
cancelable: true,
composed: true,
})
);
}
}
);
A couple of remarks here.
The dispatched event uses a CustomEvent
instance with composed
set to true
, which is very important to allow the event to propagate across the shadow DOM boundary into the standard DOM.
We can also take a glimpse at what the Slot API looks like.
get label() {
const slot = this.shadowRoot.querySelectorAll("slot")[0];
return slot.assignedNodes().at(0).textContent;
}
Not a fan, to be honest, but it's what it is!
Now, let's look at the my-dropdown-option
component CSS:
/* components/my-dropdown-option/my-dropdown-option.css */
:host {
display: block;
}
:host::part(container) {
padding: 8px 16px;
background-color: var(--background-low-contrast);
color: var(--text-high-contrast);
font-size: var(--font-sm);
}
:host::part(container):hover {
background-color: var(--background-high-contrast);
color: var(--text-low-contrast);
}
That was nothing we haven't already covered, but two things are worth mentioning. Let's return to the global style.css file for a moment.
/* style.css (around line 79) */
my-dropdown-option:not(:first-child)::part(container) {
border-top: var(--border-high-contrast);
}
my-dropdown-option:first-child::part(container) {
border-top-left-radius: var(--border-radius-sm);
border-top-right-radius: var(--border-radius-sm);
}
my-dropdown-option:last-child::part(container) {
border-bottom-left-radius: var(--border-radius-sm);
border-bottom-right-radius: var(--border-radius-sm);
}
We can see how the end user used the exported parts to tweak the option's default look and feel, round the popover borders, and add separators between options.
Recap
Unless I forgot to mention some obscure API, I'd say that we've covered the whole v1 spec by implementing the dropdown component.
Project code
Github repo with the full project source code.
Related things worth exploring
- lit.dev Web components library.
- stenciljs.com Web components compiler.
- storybook.js.org Frontend workshop for building UI components in isolation.
Top comments (5)
All the oldskool
.bind()
voodoo magic is not required, you get lexical scope for free if you call an event handler (Web Component method) with a fat-arrow function:this.addEventListener("click", (evt) => this.clickHandler(evt) );
instead of referencing it:
this.addEventListener("click", this.clickHandler);
That's true. I will get rid of that extra noise.
I probably used it here because at some point I had removeEventListeners. You only need the bind for that particular case.
Thanks!
For that particular case:
What?! Don't really get it... but curious if you can pass the remove listener function when adding? Can you elaborate please?)
When you add a listener it creates/returns its own remove function
then later
This saves you from having to know/store the
func