Appearance
Declarative Element Behavior
Bind JavaScript behavior to DOM elements through CSS selectors and HTML attributes instead of manual wiring. Elements declare what they need; JavaScript fulfills it automatically.
Two implementation flavors: a custom runtime (widget/presenter/init) or the platform's Custom Elements API. Same idea, different trade-offs.
When to use
Use declarative behavior when HTML should drive initialization instead of imperative JavaScript — particularly in multi-page apps where scripts load in unpredictable order or pages with dynamically inserted HTML that needs automatic initialization.
Custom runtime
Widget registration
A widget is a function bound to a CSS selector. When the runtime encounters a matching element, it calls the function with that element.
Register a widget by passing a CSS selector and an initialization function. When the runtime walks the DOM, every element matching that selector gets the function called on it — no manual query-and-bind wiring needed:
javascript
widget("title", (el) => {
document.title = el.textContent;
});
widget("redirect[to]", (el) => {
window.location.replace(el.getAttribute("to"));
});
widget("include[src]", async (el) => {
const response = await fetch(el.getAttribute("src"));
el.innerHTML = await response.text();
});The HTML side is pure markup — elements declare what they are, and the registered widgets handle the rest:
html
<title>My Page</title>
<include src="/partials/header.html"></include>Implementing widget()
Store each selector-function pair in a registry. At initialization time, test each element against every registered selector:
javascript
const widgets = [];
function widget(selector, fn) {
widgets.push({ selector, fn });
}
function initWidgets(el) {
for (const { selector, fn } of widgets) {
if (el.matches?.(selector)) {
fn.call(el, el);
}
}
}A more advanced widget can handle nested includes with path resolution and re-initialize the new subtree:
javascript
widget("include[src]", async (el) => {
const src = el.getAttribute("src");
const response = await fetch(src);
const html = await response.text();
el.innerHTML = html;
// Resolve relative paths in nested includes
for (const nested of el.querySelectorAll("include[src]")) {
const relative = nested.getAttribute("src");
nested.setAttribute("src", new URL(relative, src).href);
}
// Initialize the new subtree
await init(el);
});Presenter pattern
A presenter is a named function referenced by an HTML attribute. Elements declare which presenter they need; the runtime pairs them up.
Elements reference presenters by name in an HTML attribute, and the corresponding script registers the function that fulfills that name — the two can load in any order:
html
<div presenter="chart">...</div>
<div presenter="sidebar search">...</div>javascript
presenter("chart", (el) => {
renderChart(el, el.dataset);
});Multiple presenters on one element are space-separated and run independently. The queue ensures order-independence — elements can appear before or after their presenter script loads.
Implementing presenter()
Store presenters by name in an object. When a presenter registers, flush any elements that were queued waiting for it:
javascript
const presenters = {};
const queue = new Map();
function presenter(name, fn) {
presenters[name] = fn;
// Flush any elements that were waiting
const waiting = queue.get(name) ?? [];
for (const el of waiting) {
fn.call(el, el);
}
queue.delete(name);
}
function initPresenter(el) {
const names = (el.getAttribute("presenter") ?? "").trim().split(/\s+/);
for (const name of names) {
if (!name) continue;
const fn = presenters[name];
if (fn) {
fn.call(el, el);
} else {
// Queue the element until the presenter loads
if (!queue.has(name)) queue.set(name, []);
queue.get(name).push(el);
}
}
}Because the queue buffers elements until their presenter loads, scripts can arrive in any order. A presenter can even lazy-load its dependencies:
html
<div presenter="map" data-lat="40.7" data-lng="-74">
Loading map...
</div>
<script type="module">
const { presenter } = await import("./behavior.js");
presenter("map", async (el) => {
const { initMap } = await import("./map.js");
initMap(el, {
lat: Number(el.dataset.lat),
lng: Number(el.dataset.lng),
});
});
</script>Tree initialization
Call init(document.body) on page load to walk the DOM and initialize every element. Call it again on a newly inserted subtree to initialize dynamic content:
javascript
// On page load
await init(document.body);
// After inserting new HTML
container.innerHTML = responseHtml;
await init(container);Implementing init()
Recurse through the tree, checking each node against widgets first, then presenters:
javascript
async function init(el) {
initWidgets(el);
for (const child of el.children) {
await init(child);
}
initPresenter(el);
}Web Components flavor
The same examples as above, but using the platform's Custom Elements API instead of a hand-rolled runtime. connectedCallback replaces init().
Title sync
html
<page-title>My Page</page-title>javascript
class PageTitle extends HTMLElement {
connectedCallback() {
document.title = this.textContent;
}
}
customElements.define("page-title", PageTitle);Redirect
html
<page-redirect to="/dashboard"></page-redirect>javascript
class PageRedirect extends HTMLElement {
connectedCallback() {
const to = this.getAttribute("to");
if (to) window.location.replace(to);
}
}
customElements.define("page-redirect", PageRedirect);HTML include
Nested includes resolve automatically because the browser upgrades them as they enter the DOM — no manual init() call needed:
html
<html-include src="/partials/header.html">
</html-include>javascript
class HtmlInclude extends HTMLElement {
async connectedCallback() {
const src = this.#resolve();
const response = await fetch(src);
this.innerHTML = await response.text();
}
#resolve() {
const raw = this.getAttribute("src");
const ancestor = this.parentElement?.closest("html-include");
const base = ancestor?.getAttribute("src") ?? location.href;
return new URL(raw, base).href;
}
}
customElements.define("html-include", HtmlInclude);Each include resolves its own src by walking up the tree to find the nearest ancestor <html-include> and using that ancestor's src as the base URL. Nested includes are fully self-sufficient.
Chart with cleanup
disconnectedCallback handles cleanup — something the custom runtime has no equivalent for:
html
<data-chart data-type="bar" data-src="/api/stats">
Loading chart...
</data-chart>javascript
class DataChart extends HTMLElement {
async connectedCallback() {
const { renderChart } = await import("./chart.js");
this.chart = renderChart(this, this.dataset);
}
disconnectedCallback() {
this.chart?.destroy();
}
}
customElements.define("data-chart", DataChart);Map with lazy loading
html
<lazy-map data-lat="40.7" data-lng="-74">
Loading map...
</lazy-map>javascript
class LazyMap extends HTMLElement {
async connectedCallback() {
const { initMap } = await import("./map.js");
this.map = initMap(this, {
lat: Number(this.dataset.lat),
lng: Number(this.dataset.lng),
});
}
disconnectedCallback() {
this.map?.destroy();
}
}
customElements.define("lazy-map", LazyMap);Dynamic content
Custom elements inside new HTML get upgraded automatically — no init() call:
javascript
container.innerHTML = responseHtml;
// Any <html-include>, <data-chart>, etc.
// inside responseHtml initialize themselves.Multiple behaviors on one element
The presenter pattern supports space-separated names on a single element. Web Components cannot — each element has exactly one tag name. Nest or compose instead:
html
<!-- Presenter pattern: two behaviors, one element -->
<div presenter="sidebar search">...</div>
<!-- Web Components: nest or compose -->
<app-sidebar>
<search-widget></search-widget>
</app-sidebar>Choosing between flavors
Custom runtime gains: selector-based matching (any CSS selector, not just hyphenated tag names), multiple behaviors per element, brevity (a widget is three lines vs. a class + define call).
Web Components gains: no custom runtime to ship, automatic upgrade of dynamic content (no init() call), built-in lifecycle (disconnectedCallback for cleanup, attributeChangedCallback for reacting to changes), order independence for free (the browser queues elements until their class registers).
Trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Custom runtime | Terse, multi-behavior | Custom runtime to ship |
| Web Components | Platform-native, stable | Verbose, one-behavior |
querySelector | No abstraction | Brittle, order-dependent |
| Framework components | Full reactivity | Framework dependency |
Use the custom runtime for lightweight bindings where a three-line function is all you need. Use Web Components when behaviors are complex enough to justify a class, need lifecycle hooks beyond init, or when you want to avoid shipping a custom runtime. Both sit between raw DOM scripting and a full framework.