
Stimulus experience translates to native custom elements. What does it take to make a custom element with Stimulus features?
You’re making a website. You want to add some JS interactivity. If it were a JS-based website, you’d be coding the views in JS already and could just slap on interactivity following the site’s idiom. But it isn’t a JS-based website. Maybe you’re twenty-something years into writing HTML that makes iCab smile [1] with BBEdit. Maybe you’re building an Eleventy with Handlebars, Liquid, Mustache, Nunjucks, or Pug [2]; or a Craft CMS site with Twig. You don’t want to stuff the body
full of <script>
s. What to do?
One solution is a little module loader which instantiates classes on page load based on a simple DOM API.
Take care when scaling
Always measure performance, and endeavor to optimize. I have used the solutions in this article on sites with a handful of JS components per page, but not on more software-like website projects.
The below self-executing anonymous function [3] finds all the DOM elements on the page which have the attribute data-js-components
, gets from the attribute value deduped (with Set
[4]) list of modules to initialize with the element, and iterates over them and initializes each (with Set.forEach()
[5]).
(function () {
const els = Array.from(document.querySelectorAll("[data-js-components]"));
for (const el of els) {
new Set(el.dataset.jsComponents.split(" ").filter(Boolean)).forEach(
(componentName) => {
import(`./components/${componentName}.js`)
.then((module) => {
new module.default(el);
})
.catch((error) => console.warn(error));
},
);
}
})();
Load that file on your page
<body>
<!-- … -->
<script src="/src/index.js"></script>
</body>
In the directory specified in the above module loader’s import()
, create a new file for each interactive feature. From the file, create a Class [6] and export it as default [7]. The class’s constructor [8] should take a single parameter, the HTML element passed to it by the module loader (above). Properties added to the top level of the class are available under this.
; there’s no restriction on convention here, but I find it helpful to at least declare the class property element
and bind it to the constructor’s element
parameter.
export default class MyComponent {
element;
constructor(element) {
// do some work
}
}
Real world patterns
In practice, that “do some work” is typically enough to split into multiple methods.
export default class MyComponent {
ui = {
element: null,
button: null,
cards: [],
messageInput: null,
};
constructor(element) {
this.ui.element = element;
this.initialize();
}
initialize() {
this.ui.button = this.element.querySelector("button");
this.ui.messageInput = this.element.querySelector(
"input[data-my-component-message]",
);
this.ui.cards = Array.from(
this.element.querySelectorAll("data-my-component-card"),
);
this.element.addEventListener("click", (e) => this.handleClick);
}
handleClick(e) {
console.log(this.message);
// do something with this.ui.cards
}
get message() {
return this.ui.messageInput.value;
}
}
The module loader will create a new instance of the class once, on page load, for every element with that JS component’s name in its comma-separated data-js-components
attribute.
<div data-js-components="my-component">
<!-- … -->
</div>
Let’s get specific. We’ll add support for revealing elements with JS, and for displaying an alert.
export default class Unhide {
constructor(element) {
this.style.display = "block";
}
}
export default class Alerter {
constructor(element) {
const message = element.dataset.alerterText;
element.addEventListener("click", () => {
alert(message);
});
}
}
// loader…
<div data-js-components="unhide" style="display: none">
JS-only
<div data-alerter-message="hello world" data-js-components="alerter">
Alerts "hello world" on click.
</div>
</div>
<script src="/src/index.js"></script>
Here that is, interactively. To highlight the system’s flexibility, this version is written in TS and uses a slightly different convention in the classes.
What we’ve done so far is very close to using light DOM web components. With some superficial changes to the classes, you can open up the door to using the hot newish thing.
Custom element classes extend a native element class.
They aren’t initialized on a DOM element, so drop the constructor’s parameter.
The constructor has to call super()
before anything else. The DOM element is this
, no binding to this.element
necessary.
Most initialization is conventionally done in the connectedCallback()
method [9].
export default class MyComponent extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
// …
this.someMethod();
// …
}
someMethod() {
// the component root element is `this`
}
}
customElements.define()
registers the custom element [10].
import MyComponent from "./components/my-component.ts";
customElements.define("my-component", MyComponent);
Other registration patterns
You could prescribe the custom element’s registered name in the class. [11]
You could prescribe the custom element’s registered name, and automatically register on import. [12]
You could only register those custom elements which appear on the page.
import MyComponent from "./components/my-component.ts";
// …
const els = [
{
constructor: MyComponent,
name: "my-component",
},
];
for (const el of els) {
if (document.querySelector(el.name) === null) {
continue;
}
customElements.define(el.name, el.constructor);
}
<my-component>
<!-- … -->
</my-component>
That covers the “unhide” example:
export default class UnHide extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.style.display = "block";
}
}
Note the hyphen
Custom element’s names must include a hyphen. There are other requirements - read about them in the valid custom element name spec
import UnHide from "./components/un-hide.ts";
customElements.define("un-hide", UnHide);
<un-hide style="display: none"> JS-only </un-hide>
The “alerter” example is a little more involved. The MyComponent extends HTMLElement
approach, by definition, extends HTMLElement
. Think of it roughly as making a special kind of <div>
. For accessibility, the alerter example should use a <button>
. That makes it focusable, makes assistive tech announce as being an interactive button, and makes it respond as expected to input devices "click"s.
The theoretical solution is to extend some class other than HTMLElement
- in this case, Alerter
would extend HTMLButtonElement
. You can do that today in the major browsers ⚠️ except for Safari ⚠️. It looks like this:
Not cross-browser compatible
Safari does not plan to support this.
export default class MyComponent extends HTMLElement {
export default class MyComponent extends ReplaceMeWithTheDesiredHTMLElementClass { // e.g. extends HTMLButtonElement
// …
// …
customElements.define("my-component", MyComponent);
customElements.define("my-component", MyComponent, {
extends: "replace-me-with-the-native-element", // e.g. extends: "button"
});
// …
<my-component>
<replace-me-with-the-native-element is="my-component"> <!-- e.g. <button is="my-component">…</button> -->
<!-- … -->
</my-component>
</replace-me-with-the-native-element is="my-component">
But a “solution” which doesn’t support Safari isn’t a solution.
Cross-browser compatible
Here are some stop-gap solutions while we wait on the browsers to sort out a cross-browser solution to extending native elements.
There’s a simple bandaid: add a polyfill. For example @ungap/custom-elements
, which is about 6KB. On a JS-based site, add the dependency and then import '@ungap/custom-elements'
in your root JS file (in this article’s examples, src/index.js
). If your site isn’t based on JS and doesn’t have JS bundling set up, add this to your <head>
:
<script src="https://unpkg.com/@ungap/custom-elements/es.js"></script>
Now the extending a native element just works.
To not rely on a third-party polyfill, refactor the custom element to wrap the native element. There are two standard paradigms:
Add the native element to the custom element’s shadow DOM. The provided markup will be <my-component></my-component>
, and use JS to inject a <button>
shadow DOM child.
Make the custom element a (light DOM) wrapper. The provided markup will be <my-component><button>…</button></my-component>
.
Custom element shadow DOM is its own topic with gotchas and conventions and special properties. Stick with what I’ve seen called HTML web components, the paradigm which is a close refactor from this article’s first “not ‘web component’ component” examples.
export default class Alerter extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const button = this.querySelector("button[data-alerter-text]");
if (button === null) {
return;
}
const message = button.dataset.alerterText;
button.addEventListener("click", () => alert(message));
}
}
Here are the web component “unhide” and the two cross-browser "alerter"s, interactively.
Make iCab smile https://en.wikipedia.org/wiki/ICab#Features ↩︎
Eleventy template languages https://www.11ty.dev/docs/languages/ ↩︎
IIFE https://developer.mozilla.org/en-US/docs/Glossary/IIFE ↩︎
Set.forEach()
https://devdocs.io/javascript/global_objects/set/foreach ↩︎
export default
https://devdocs.io/javascript/statements/export#description ↩︎
constructor
https://devdocs.io/javascript/classes/constructor ↩︎
Some people argue for initializing in connectedCallback()
rather than in constructor()
only when there is a compelling reason — read for example You’re (probably) using connectedCallback wrong. IMO there’s value in using the pattern that works everywhere; that’s why, in the absence of linting/auto-format configurations, I use double quotes everywhere following the conclusions of Rubyists: Just use double-quoted strings. ↩︎
CustomElementRegistry.define
https://devdocs.io/dom/customelementregistry/define ↩︎
Prescribe custom element’s registered name in the class https://mayank.co/blog/defining-custom-elements/ ↩︎
Prescribe custom element’s registered name, and automatically register on import https://github.com/murtuzaalisurti/back-to-top/blob/v3.0.4/main.js#L177 ↩︎
From Stimulus to web components
Stimulus experience translates to native custom elements. What does it take to make a custom element with Stimulus features?
Accessible CSS-Only Light/Dark Toggles (with JS persistence as progressive enhancement)
CSS’s :has()
can obviate JS
SSG Astro with Headless Craft CMS Content Fetched At Build Time Or Cached In Advance
Astro on the front, Craft on the back. Craft can be local only, and the build environment doesn't have to connect to the CMS.
Watch for specific added nodes with MutationObserver
MutationObserver makes it easy to watch for the addition of specific nodes, if you know where to drill.