Controller Registry: adding behaviour to any HTML elements
𒎏Wii 🏳️‍⚧️

𒎏Wii 🏳️‍⚧️ @darkwiiplayer

About: Blog: https://blog.but.gay Mastodon: @darkwiiplayer@tech.lgbt Pronouns: en.pronouns.page/@darkwiiplayer

Joined:
Dec 4, 2018

Controller Registry: adding behaviour to any HTML elements

Publish Date: Aug 11
3 6

Web Components

Custom elements are cool; I use them for plenty of things, but there is one thing about them that makes them unsuited for many problems: they take full control over an element and correspond to that element's main purpose.

This is perfectly reasonable for actual components, when one wants to define a completely new type of element with one clear functionality.

They don't, however, allow combining more than one custom element in one HTML tag, and while builtin custom elements are technically a thing, in practice they aren't viable (thanks, safari) and so attaching custom behaviours to built-in HTML elements isn't possible in practice either.

Enter Controllers

Or traits, or whatever else one wants to call them.

The idea here is simple: instead of the custom behaviour existing in the tag itself, it exists in an external object (or other entity), which is attached to an HTML tag and controls it.

Controller-Registry

As a prototype to play around with this idea, I implemented controller-registry (repository, github mirror).

The API is similar to custom elements wherever it makes sense, but is still different in many ways due to the differing requirements.

In summary

  1. In HTML, controllers work like classes: one attribute that's a whitespace-separated list
  2. In JavaScript, there is one central registry associating names to behaviours
  3. Adding a controller to an element's attribute attaches a new controller automatically, while defining a new controller automatically upgrades all elements that already have that name in their list.
  4. Unlike custom elements, controllers can also be removed from elements (and re-added, as many times as desired)

Playground

Here's a codepen I prepared from the example file in the repository for anyone who wants to just play around with it before reading on.

Feel free to also open up the dev tools and manually tinker with the controller attribute of the elements; their behaviours should be updated automatically.

Implementation

All things considered, the implementation is surprisingly simple.

Most of what matters happens in the ControllerRegistry class, and its one global instance. Creating new registries is possible, but I won't focus on that here.

The class itself holds a couple of internal data structures to keep track of elements and controllers. Weak maps are really convenient here: they allow associating data with an object but still let the object be garbage-collected if it doesn't have any other references.

The registry also has a MutationObserver that listens for both DOM insertions to keep track of new nodes as well as attribute changes specifically on the controller attribute.

How much optimization happens here is up to the browser, but using the attributeFilter, I've at least given the browser all the information it needs to treat this attribute the same way it does class, and internally hook into it without even considering where in the DOM it happens. That's as much as an implementation in JS can achieve.

The define function is mostly quite simple, in that it just takes its arguments as key and value to insert into a map; but before doing that it also checks if any element is already waiting for a controller of that name to be defined so that can be attached immediately.

A little bit of extra code at the start of the method checks for functions that are constructors, and transforms them into a wrapper function that calls the constructor with new and the element as its argument, then waits for the controller to be detached again and tries calling a detach method on the controller object if it is defined. Technically the constructor only receives a revocable proxy to the element, but I am not sure if I want to keep that in the code.

Some of the functions simply mimic the public API of CustomElementRegistry; keeping things similar to what developers already know reduces friction, after all.

The update and attach methods are where most of the magic happens:

It extracts controller names from the controller attribute of the elements, then looks those up in the controller list and either attaches them or adds the element to the waiting list if a controller by that name is not yet defined.

When a controller is attached, a little bit more magic happens: A new promise is created that fulfills when the controller is detached. An abort controller is also created which will abort on disconnect. Its signal is then saved as a signal property on the promise object. In fact, the abort controller is what resolves the promise in an event listener.

This means that the promise can also be passed as the third argument to element.addEventListener to automatically remove the listener when the controller is detached. Pretty neat, isn't it? You can see this in the codepen example above.

And that is most of the exciting parts. At the top of the module there is also a helper that works a bit like a DomTokenList, so controllers can be manipulated just like classes; but its implementation isn't very complicated.

Conclusion

All in all, the proof of concept took a couple of hours to implement and maybe two or three more of tweaking, documenting and playing around with it.

What do you think? Should this be a native feature of browsers? Are custom elements already enough? They can totally be combined, by the way: you can add controllers to custom elements too :)

Whatever your thought, let me know in the comments, and feel free to pick the code apart.

Comments 6 total

  • Danny Engelman
    Danny EngelmanAug 12, 2025

    isn't this what jQuery did/does.

    • After the DOM is parsed (module script loads "late")
    • look for markers on tags,
    • attach functionality to it.

    The one pre for Custom Elements is that they can run before DOM is parsed.

    • 𒎏Wii 🏳️‍⚧️
      𒎏Wii 🏳️‍⚧️Aug 12, 2025

      This works both ways; it basically tries to get as close to custom elements as it possibly can in JavaScript. Functionality gets attached either when the controller is defined, the attribute is added, or when something is inserted into the document, and removed when the attribute is removed as well.

      Also, jQuery is a bit of a one-library-for-everything; my goal here was to build one very specific feature, align it closely with existing platform features, and experiment with it to see if it might be worth suggesting as an actual platform feature at some point.

      • Danny Engelman
        Danny EngelmanAug 13, 2025

        "tries to get as close to custom elements as it possibly can"

        But only because you don't want to write:

        <input-controller name="filter">

        Which would use 98.43% of the code you now used.

        And/but has all Custom Element advantages

        "They don't, however, allow combining more than one custom element in one HTML tag"

        customElements.define("my-component", class extends customElements.get("your-component"){ })
        
        Enter fullscreen mode Exit fullscreen mode

        And you can add Mixins

        • 𒎏Wii 🏳️‍⚧️
          𒎏Wii 🏳️‍⚧️Aug 13, 2025

          That mixin approach introduces coupling between the two components though; they can't be used independently anymore. And most importantly, it still ties how an element behaves to what it is, which is a great abstraction for proper components, but a very bad one for generic traits.

  • Dario Mannu
    Dario MannuAug 13, 2025

    The idea is very interesting. What real-life use cases can you think of, beyond just removing "scunthorpe", and how do you see this best integrated in current programming paradigms and architectures?

    • 𒎏Wii 🏳️‍⚧️
      𒎏Wii 🏳️‍⚧️Aug 13, 2025

      Any kind of added behaviour; another small example where I often find myself wanting something like this is <time datetime="2025-08-13T12:00+0200" controller="relative-time">Today at noon</time>

      The current best way to fix this is to wrap the time event in a custom element or completely re-create it as a new custom element.

      A few more examples could be like <input type="password" controller="password-strength" data-minimum-strength="20"></input>, <img src="image.png" controller="zoom-on-click">, <h2 controller="scroll-into-view-event">, etc.

      In short, any situation where you want to write generic javascript code and put the HTML in control of what it is applied to, which usually requires a bunch of extra code in charge of searching for those elements, handling dom changes, etc.

Add comment