Skip to content

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

ApproachProsCons
Custom runtimeTerse, multi-behaviorCustom runtime to ship
Web ComponentsPlatform-native, stableVerbose, one-behavior
querySelectorNo abstractionBrittle, order-dependent
Framework componentsFull reactivityFramework 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.