Skip to content

Web Components

Matrix UI is built on the W3C Web Components standard — customElements.define, shadow DOM, slots, attributes. There is no React, no Vue, no Svelte, no JSX runtime. This page explains why, and what that means for the components you write.

Why Web Components

Three reasons govern this choice:

  1. The browser is a runner. Every tab connects to NATS, instantiates MatrixRuntime, and mounts a tree of actors. The actor model is the architecture; the framework wraps each actor instance in a custom element so the DOM tree IS the actor tree. Custom elements are the cheapest possible binding.
  2. No build-time framework lock-in. A MatrixActorHtmlElement subclass is a TypeScript class that compiles down to standard ES2022. Anyone consuming a Matrix component pays for nothing beyond the standard browser APIs. Components written in this repo can be loaded from any plain HTML page.
  3. Shadow DOM gives us style isolation for free. Each component owns its template and its CSS without leaking. Theming flows through CSS custom properties (the --mx-* token system declared in static tokens).

The trade-off: we do without a virtual-DOM diff, JSX, or component composition syntax sugar. We compensate with morphdom (used by the reactive render(state) path) and by composition through the actor tree, not the template tree.

What every component is

A Matrix UI component is a custom element that:

  • Extends MatrixActorHtmlElement (which extends HTMLElement).
  • Declares static accepts / static emits — same contract as any headless actor.
  • Either supplies its own handler methods (Pattern A) or delegates to a separate MatrixActor subclass (Pattern B). See MatrixActorHtmlElement.
  • Optionally declares a static template: string for shadow DOM, or implements render(state) for reactive rendering.
  • Is registered via customElements.define('my-tag', MyComponent).

The framework provides the rest:

  • The $join handshake with its parent shell.
  • The auto-pull context cascade (child finds parent shell, gets context, derives mount).
  • The mount path computation (DOM nesting → mount path).
  • The shadow DOM attachment in the constructor.
  • Microtask move detection (so a component that detaches and re-attaches in the same tick keeps its actor).

Naming conventions

Custom-element names must contain a hyphen (a Web Components rule). Inside Matrix:

  • Shell-provided primitives: mx-* (e.g. mx-split, mx-theme, mx-topic-tag). These ship from @open-matrix/core/framework/components/.
  • Package-specific components: <package>-* (e.g. director-explorer, chat-conversation, flowpad-canvas). The package owns the prefix.
  • Page-shell composition root: <matrix-dsl-host>MatrixDSLHost is a non-actor wrapper that bootstraps a runtime and propagates the root context to children. Every Matrix page wraps content in this element.

Allowed APIs in components

You write standard browser code. The framework does not constrain you to a subset.

  • attachShadow({ mode: 'open' }) is done by the base class.
  • this.shadowRoot is yours after construction.
  • this.setState(patch) updates observable state and triggers a re-render if you implement render(state).
  • this.emit(event, payload) publishes to your $events topic.
  • this.sendCommand(target, op, payload) (3-arg) sends to a child or relative path.
  • this.sendCommand(op, payload) (2-arg) sends to a tracked-domain actor (CQRS view pattern; see static tracks).

What you do not do (these violate the actor model):

  • innerHTML = '<my-mx-thing>...' to create child Matrix components — that bypasses the $join handshake. Compose children in static template, not via innerHTML.
  • Direct method calls between sibling components. Always go through sendCommand or by emitting an event the other component subscribes to.
  • Cross-package imports of UI components. UI consumes its own backend through the bus, not through an in-process import.

The full ruleset is in projects/matrix-3/.claude/rules/component-development.md.

What the framework forbids in templates

The base class rejects unsafe patterns when it parses dynamic templates (MatrixActorHtmlElement.ts:152-173). The blocked patterns are:

  • Inline event handlers (onClick=, onLoad=, etc.) — use data-action="myHandler" instead.
  • javascript: URIs.
  • eval( calls.
  • new Function( calls.
  • document.cookie access from a template.
  • localStorage / sessionStorage access.
  • Direct fetch( / XMLHttpRequest( / new WebSocket(.
  • <script>, <iframe>, <object>, <embed> tags.
  • srcdoc= attribute.
  • Dynamic import( / require(.

These are enforced for templates supplied through factory.create-from-code and component.save. Static static template strings written by the package author are not parsed against this list — but the same restrictions are best practice.

Composability

Components compose by being placed in each other's static template. The parent shell discovers children via DOM walking (_findParentShell() and pending-children registration), waits for all of them to $join, propagates context, and continues bootstrap.

ts
class ParentShell extends MatrixActorHtmlElement {
  static template = `
    <my-widget id="left" name="left"></my-widget>
    <my-widget id="right" name="right"></my-widget>
  `;
}

The name attribute is mandatory before DOM insertion. It becomes the child's id within the parent's ChildManager and the suffix on its mount path.

See also

Source: projects/matrix-3/packages/core/src/framework/MatrixActorHtmlElement.ts:152-173 (sanitize patterns), :202-447 (class declaration and registration), projects/matrix-3/.claude/rules/component-development.md (full rule set).