Adding front end interactivity to sites not written in JS

You don't need a framework
As two adults talk in a back room, a child attempts to add to a painting on its easel, while two other children look on. Wood engraving
As two adults talk in a back room, a child attempts to add to a painting on its easel, while two other children look on. Wood engraving after J.G. Meyer von Bremen. Meyer, Johann Georg, 1813-1886. Public Domain Mark. Source: Wellcome Collection.

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?

Add a module loader

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]).

src/index.js
js
(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

html
<body>
  <!-- … -->

  <script src="/src/index.js"></script>
</body>

Add and call modules

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.

src/components/my-component.js
js
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.

Toggle to show/hide an example
src/components/my-component.js
js
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.

html
<div data-js-components="my-component">
  <!-- … -->
</div>

An example

Let’s get specific. We’ll add support for revealing elements with JS, and for displaying an alert.

src/components/unhide.js
js
export default class Unhide {
  constructor(element) {
    this.style.display = "block";
  }
}
src/components/alerter.js
js
export default class Alerter {
  constructor(element) {
    const message = element.dataset.alerterText;

    element.addEventListener("click", () => {
      alert(message);
    });
  }
}
src/index.js
js
// loader…
html
<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.

Embed of the StackBlitz adding-interactivity-to-sites-not-written-in-js. Safari does not support the embed's preview pane (docs).

Web components are a small step away

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].

src/components/my-component.js
js
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].

src/index.js
js
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.

    Expand for one approach to registering selectively
    src/index.js
    js
    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);
    }
html
<my-component>
  <!-- … -->
</my-component>

Examples

That covers the “unhide” example:

js/components/un-hide.js
js
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

src/index.js
js
import UnHide from "./components/un-hide.ts";
customElements.define("un-hide", UnHide);
html
<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.

js/components/my-component.js
js
export default class MyComponent extends HTMLElement {
export default class MyComponent extends ReplaceMeWithTheDesiredHTMLElementClass { // e.g. extends HTMLButtonElement
  // …
src/index.js
js
// …
customElements.define("my-component", MyComponent);
customElements.define("my-component", MyComponent, {
  extends: "replace-me-with-the-native-element", // e.g. extends: "button"
});
// …
html
<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.

  1. 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>:

    html
    <script src="https://unpkg.com/@ungap/custom-elements/es.js"></script>

    Now the extending a native element just works.

  2. 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.

    src/components/alerter.js
    js
    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.

Embed of the StackBlitz adding-interactivity-to-non-js-sites-web-components. Safari does not support the embed's preview pane (docs).

Footnotes

  1. Make iCab smile https://en.wikipedia.org/wiki/ICab#Features ↩︎

  2. Eleventy template languages https://www.11ty.dev/docs/languages/ ↩︎

  3. IIFE https://developer.mozilla.org/en-US/docs/Glossary/IIFE ↩︎

  4. Set https://devdocs.io/javascript/global_objects/set ↩︎

  5. Set.forEach() https://devdocs.io/javascript/global_objects/set/foreach ↩︎

  6. Classes https://devdocs.io/javascript/classes ↩︎

  7. export default https://devdocs.io/javascript/statements/export#description ↩︎

  8. constructor https://devdocs.io/javascript/classes/constructor ↩︎

  9. 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. ↩︎

  10. CustomElementRegistry.define https://devdocs.io/dom/customelementregistry/define ↩︎

  11. Prescribe custom element’s registered name in the class https://mayank.co/blog/defining-custom-elements/ ↩︎

  12. 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 ↩︎

On the web

Links

Also In This Series

  • From Stimulus to web components

Articles You Might Enjoy

Or Go To All Articles