Appearance
Shadow DOM and rendering
A Matrix component renders into its shadow DOM. There are two rendering modes — imperative (one-shot template) and reactive (render(state) re-evaluated on state change). The framework picks the right mode based on whether you implement render. This page covers both modes, theming through CSS custom properties, and how event delegation lets morphdom diff freely without orphaning listeners.
The shadow root
The constructor (MatrixActorHtmlElement.ts:361-367) attaches an open shadow root in every instance. this.shadowRoot is available the moment the element is constructed. It is empty until _renderTemplate() runs during context receive.
Note: Open shadow roots (vs closed) are deliberate — they let the framework introspect children for the
$joinhandshake and let test code reach in to query the DOM. Don't change toclosed; the framework will not work.
Mode 1 — imperative static template
If your component does not implement a render(state) method, the framework treats static template as a one-shot string parsed once into the shadow root. This is the path most components in the tree use today.
ts
class MyButton extends MatrixActorHtmlElement {
static template = `
<style>
:host { display: inline-block; }
button { font: inherit; padding: 0.5rem 1rem; }
</style>
<button data-action="onClick">
<slot>Click</slot>
</button>
`;
onClick(payload: { event: Event }) {
this.emit('button.clicked', { ts: Date.now() });
}
}_renderTemplate() (MatrixActorHtmlElement.ts:907-938) does this:
- Resolve the template (element class, then actor class as fallback for Pattern B).
- Parse the string with a
<template>element. - Wait for nested custom elements to be defined (
customElements.whenDefined). - Replace the shadow root's children with the parsed nodes.
Updates to the DOM after that are your responsibility — query, assign, append. Override _onContextUpdated() to run code after every (re)mount.
Mode 2 — reactive render(state)
If your component does implement a render(state) method, the framework runs it on every state change and diffs the result with morphdom. The shadow DOM is laid out as [<style data-mx-style>][<div data-mx-root>][child host zone].
ts
class Counter extends MatrixActorHtmlElement {
static styles = `
:host { display: inline-block; }
button { font: inherit; }
.count { font-weight: bold; }
`;
static template = ``; // optional; render() drives content
protected async onBootstrap() {
this.setState({ count: 0 });
}
render(state: Readonly<{ count: number }>) {
return `
<button data-action="onIncrement">+</button>
<span class="count">${state.count}</span>
`;
}
onIncrement() {
this.setState({ count: (this.state.count as number) + 1 });
}
}_scheduleRender() (MatrixActorHtmlElement.ts:964-992) batches calls within a microtask, then:
- Calls
render(this.state). - Builds a wrapper
<div>with the returned HTML. morphdom(renderRoot, wrapper, { childrenOnly: true, onBeforeElUpdated })diffs the two.- The
onBeforeElUpdatedcallback skips elements that are the active element (preserves focus) or already equal. _renderCanvas()runs if you implementrenderCanvas(ctx, state)— it grabs the<canvas>from the shadow root and hands you a 2D context.
setState and emit both trigger _scheduleRender() via _shellUpdateCallback (MatrixActorHtmlElement.ts:720). Multiple state changes within the same synchronous turn collapse to a single DOM diff.
data-action event delegation
Both modes use the same delegation: _wireDelegatedEvents (MatrixActorHtmlElement.ts:1010-1083) attaches a single set of listeners on the shadow root for click, input, change, submit, focusin, focusout. The dispatcher walks the composed path, finds the closest [data-action], and calls on{toHandlerCase(action)} on the element.
html
<button data-action="onSubmit">Send</button>
<input data-action="onSearchChange" type="text">Handler signatures receive a single payload:
ts
onSubmit(payload: { event: Event; target: Element; value?: string }): void {
// payload.value is auto-extracted for input/textarea/select.
}This delegation is the reason morphdom is safe — listeners are not on the diffed elements, so re-creating elements does not orphan listeners. Errors thrown from a data-action handler that mention "capability" (case-insensitive) are auto-wrapped in mx-capability-request CustomEvents and bubbled up.
Static styles
Two ways to ship CSS:
- Inline in
static template. Works in both modes. The<style>becomes part of the parsed template. static styles(reactive mode only). Goes into the dedicated<style data-mx-style>element so reactive re-renders never touch it.
For Pattern B, the framework also reads template/styles from the actor class as a fallback (MatrixActorHtmlElement.ts:1096-1110). You can set them in either place.
Theming via tokens
Components advertise theme tokens through static tokens:
ts
class StockTicker extends MatrixActorHtmlElement {
static tokens = {
'price-up': { default: '#10b981', description: 'Color used for price increases' },
'price-down': { default: '#ef4444', description: 'Color used for price decreases' },
};
static styles = `
.up { color: var(--mx-price-up, #10b981); }
.down { color: var(--mx-price-down, #ef4444); }
`;
}The MxTheme primitive element (@open-matrix/core/framework/components/MxTheme) walks the actor tree, gathers declared tokens, and lets shells override values. The --mx- prefix is canonical.
:host and slots
:host selectors style the element from inside its shadow root. :host([attr]) and :host(.class) reflect external attributes. <slot></slot> projects light-DOM content from outside.
html
<style>
:host { display: block; padding: 1rem; }
:host([disabled]) { opacity: 0.5; pointer-events: none; }
</style>
<slot name="header"></slot>
<slot></slot>These are standard Web Components mechanics. The framework does not constrain them.
Picking imperative vs reactive
| Use imperative when | Use reactive when |
|---|---|
| The DOM mostly does not change after mount. | The DOM changes frequently with state. |
| You manually query and update specific nodes. | You'd rather declare what the output should look like for a given state. |
| You ship a complex template with custom elements as children that you don't want re-evaluated. | The component is data-driven (lists, charts, time-series). |
| You're porting a Pattern A component from before reactive rendering was added. | You're starting fresh and want batched diff updates. |
Tip: Don't mix the two modes by implementing
render(state)and also imperatively poking nodes thatrenderproduces —morphdomwill fight you. If you need partial reactivity, scoperender(state)to the dynamic part and use slotted children for the static structure.
See also
- MatrixActorHtmlElement — the static template/styles surface.
- UI component lifecycle — when
_renderTemplateruns. - Web Components — what custom-element lifecycle gives us.
Source:
projects/matrix-3/packages/core/src/framework/MatrixActorHtmlElement.ts:907-958(_renderTemplate),:964-992(_scheduleRender+ morphdom),:1010-1083(_wireDelegatedEvents),:33(morphdom import).morphdomdeclared inprojects/matrix-3/packages/core/package.json:344.