Web Components
mx and Web Components are not rivals - mx is built to live inside them. mx(), dom(), and render() are platform-level tools: render() is patched onto Element.prototype, so it works on any element you create, including a hand-written customElements.define'd class. You get the full platform component model - native lifecycle, attributes, Shadow DOM, slots, form association, framework-agnostic reuse - and mx as the tiny engine that paints the inside.
The mental model: the custom element is the public contract; mx is the private renderer. Consumers see a real <user-card> they can drop into React, Vue, Angular, or plain HTML. Inside, you render with mx instead of hand-writing createElement/appendChild - and you keep mx's reconciler and attribute cache for cheap updates.
Two routes, one toolkit
You can build mx components two ways. define() (used across the rest of these docs) is the terse, mx-managed route - great for app-internal components. Registering a class extends HTMLElement is the platform route - reach for it when you want the standards contract: a component HTML can author, any framework can consume, that participates in forms or uses Shadow DOM. Both render with the same mx/dom/render. This page is about the second route.
render() works on every element
Because mx augments the prototype rather than wrapping a component class, there is nothing to extend and nothing to import. Every custom element already has render():
// mx patches Element.prototype, so render() exists on EVERY element -
// including a hand-written custom element. No define(), no base class, no import.
class MyWidget extends HTMLElement {
connectedCallback() {
this.render( // <- Element.prototype.render
mx.h3('Hello'),
mx.p('Rendered by mx, inside a standard custom element.')
);
}
}
customElements.define('my-widget', MyWidget);class FlipCounter extends HTMLElement {
constructor() { super(); this._n = 0; } // inert - no DOM touched
connectedCallback() { this.paint(); }
paint() {
this.render(
mx.button({ onclick: () => { this._n--; this.paint(); } }, '\u2212'),
mx.span(this._n),
mx.button({ onclick: () => { this._n++; this.paint(); } }, '+')
);
}
}
customElements.define('flip-counter', FlipCounter);
document.body.append(document.createElement('flip-counter'));That <flip-counter> is a fully standard custom element - inspectable as itself in devtools, usable from any framework, registerable once - and its entire view layer is three lines of mx.
The inert-description trick
Here is where mx genuinely removes ceremony. The custom-element spec is strict about the constructor: it must not inspect attributes or add children. Create the element via document.createElement and a constructor that appends DOM throws. So classic components leave the constructor nearly empty and build everything imperatively in connectedCallback:
// Classic custom element: the spec FORBIDS touching the DOM in the
// constructor (no children, no attribute reads - it throws when created
// via createElement). So you build everything imperatively in connectedCallback:
class UserCard extends HTMLElement {
connectedCallback() {
let h = document.createElement('h3');
h.textContent = this.getAttribute('name');
let p = document.createElement('p');
p.textContent = this.getAttribute('role');
this.append(h, p); // ceremony, every time
}
}But an mx() call returns a description - plain data, not DOM. Building it touches nothing on the element (verified: zero children added), so it's legal in the constructor. You can define the view up front and let connectedCallback be a single render():
// With mx, the view is a DESCRIPTION - inert data, not DOM. You can build
// it in the constructor (where DOM mutation is illegal) because it adds
// nothing to the element. connectedCallback becomes a one-liner: render it.
class UserCard extends HTMLElement {
constructor() {
super();
this._view = ({ name, role }) => // pure data, zero DOM touched
[mx.h3(name), mx.p(role)];
}
connectedCallback() {
this.render(...this._view({ name: this.getAttribute('name'), role: this.getAttribute('role') }));
}
}
customElements.define('user-card', UserCard);Why this matters
The constructor's "you may not touch the DOM" rule is the single most awkward thing about authoring custom elements by hand. mx sidesteps it for free: descriptions are inert, so "what this element looks like" can be ordinary data computed whenever you like, and the only place that actually mutates the DOM is one render() call - which is also re-runnable, so updates are the same one-liner. You stop hand-writing createElement trees and stop guarding against the constructor rules.
Reacting to attributes
The platform's own reactivity for custom elements is observedAttributes + attributeChangedCallback. Bridge it to mx by re-rendering when an attribute changes - now the element is driven by HTML, with no JavaScript at the call site:
class StatusDot extends HTMLElement {
static get observedAttributes() { return ['state']; }
attributeChangedCallback() { if (this.isConnected) this.paint(); }
connectedCallback() { this.paint(); }
paint() {
let state = this.getAttribute('state') || 'idle';
this.render(mx.span({ class: 'dot ' + state, 'aria-label': state }));
}
}
customElements.define('status-dot', StatusDot);
// Now it's live from HTML, driven by attributes - no JS to wire it:
// <status-dot state="online"></status-dot>
// el.setAttribute('state', 'offline') // attributeChangedCallback -> re-renderThis is the standards-native counterpart to props. For app-internal components the $state / .$({...}) model is terser; reach for attributeChangedCallback when the component must be authorable and controllable as plain HTML - the case for a shared, framework-agnostic element.
Attributes are strings
Attributes only carry strings. For rich data (objects, arrays, callbacks) expose a property or method on the element and call it - el.data = [...], el.$({...}) - exactly as you would with any custom element. attributeChangedCallback is for the declarative, stringly-typed surface; properties are for the rich one.
Shadow DOM and slots
Want true style and markup encapsulation? Attach a shadow root and render into it. render() is defined on Element.prototype, and a ShadowRoot is not an Element - but it is a node container with firstChild/append, so the reconciler runs against it unchanged. Call it explicitly:
class FancyBox extends HTMLElement {
connectedCallback() {
let root = this.attachShadow({ mode: 'open' });
// render() is an Element method; call it against the shadow root:
Element.prototype.render.call(root,
mx.style(':host{display:block;border:1px solid #4285f4;padding:12px}'),
mx.slot() // light-DOM children project here
);
}
}
customElements.define('fancy-box', FancyBox);
// Or stash the method once for ergonomics:
// root.render = Element.prototype.render; then root.render(mx.div(...))Light-DOM children the consumer passes project through mx.slot() as usual - mx renders the shadow tree; the browser does the slotting. Most apps don't need Shadow DOM (global CSS is simpler, and the light DOM is easier to style and test), but when you're shipping a widget into unknown pages, encapsulation is exactly what custom elements are for, and mx renders into it without special-casing.
Light DOM is the default for a reason
Rendering into the host's light DOM (just this.render(...)) keeps your component's styles, queries, and tests simple and lets app CSS reach in. Reach for Shadow DOM only when you specifically need isolation - distributing a widget, or guarding against host-page CSS. It's a tool, not a default.
Persistent references with dom()
Inside a custom element, dom() is your tool for elements you keep and mutate directly - a list you append to, a node you hand to a third-party library, anything you'll touch after creation (the same mx() vs dom() distinction as everywhere else):
class LogView extends HTMLElement {
connectedCallback() {
this._list = dom.ul({ class: 'log' }); // persistent real element
this.append(this._list);
}
add(line) {
this._list.append(dom.li(line)); // keep a ref, mutate it directly
}
}
customElements.define('log-view', LogView);Build the view with mx() inside render(); reach for dom() when you need the live node as a value. They compose freely inside a custom element exactly as they do outside one.
Interop: the two routes meeting
The one seam worth understanding: define() attaches $/$state when mx creates the element (via mx.tag() or dom()). An element created by the parser or by document.createElement - i.e. authored in HTML or server-rendered - does not pass through mx's creation path, so it won't have those methods unless you arrange it:
// A custom element written in HTML (or server-rendered) is created by the
// PARSER, not by mx - so it doesn't get $/$state from define(). Two choices:
// 1. Put the logic in the class (above) - works for any creation path.
// 2. Or keep using define(), and run a one-time upgrade pass after your
// define() calls, to attach mx's methods to parser-created elements:
document.querySelectorAll(Object.keys(components)).forEach(el =>
!el.$ && Object.assign(el, components[el.localName]));
// (See Architecture - Upgrading server-rendered custom elements.)So: if a component must be authorable as HTML, either put its logic in a registered class (the platform route on this page, which the browser instantiates correctly no matter who creates it), or keep define() and run the one-line upgrade pass. And the two compose - you can customElements.define a class and define() the same tag, getting native lifecycle plus the $() render model on one element (the hybrid on the Lifecycle page).
How it scales
This isn't a toy integration - it's a sound way to build a large application. A sizable production codebase can be hundreds of registered custom elements, each rendering its internals with mx, and it holds up because the division of labor is clean:
- The element is the contract. Every component is a real DOM element with a tag name - greppable, inspectable in devtools as itself, addressable by
querySelector, reusable from any framework or none. - Lifecycle is the platform's.
connectedCallback/disconnectedCallback/attributeChangedCallback are battle-tested browser machinery, not a framework reimplementation - so setup, teardown, and attribute reactivity behave identically everywhere. - Rendering is mx's. Each element's internals diff through the same tiny reconciler with its97%-hit attribute cache - so a thousand components share one tiny, allocation-light engine instead of each carrying its own.
- Server rendering is free. Real elements with real tags pre-render to HTML and upgrade in place on load - no hydration markers, no client/server contract.
- No build step joins them. Classes,
define() components, and plain functions concatenate and ship as-is; there's no compiler weaving a runtime into each component.
The payoff is that you pay the platform's well-understood costs for structure and lifecycle, and mx's near-zero cost for rendering - rather than a framework tax on both. The custom element gives you the boundary and the interop; mx makes the inside cheap to write and cheap to run.
When to take this route
| Want | Route |
|---|
| A fast app-internal component | define() or a plain function |
| A component authorable in plain HTML | Registered class (or define() + upgrade pass) |
| Reuse from React / Vue / Angular | Registered class - it's just a DOM element |
| Style/markup encapsulation | Registered class + Shadow DOM |
| Native attach/detach lifecycle | Registered class (see Lifecycle) |
| Form participation | Registered class + formAssociated + ElementInternals |
| Everything else | define() - less ceremony |
The throughline
mx doesn't replace Web Components or compete with them - it makes them pleasant to write. Register the element when you want the platform's contract (HTML-authorable, framework-agnostic, encapsulated, form-aware, lifecycle-driven); render its insides with mx to skip the createElement ceremony and keep updates cheap. The standards give you the boundary; mx gives you the brush.
Related: Lifecycle (connect/disconnect, and when you actually need them), upgrading server-rendered custom elements, mx() vs dom(), and methods beyond $.