Skip to content

20. Widgets

A widget defines how a module renders on the board. Every module renders through one — when nothing is configured, the viewer’s built-in arch-module element takes over. This chapter covers two scenarios:

  1. Configuring the default — staying with arch-module and tuning its appearance through widget.* fields. Covers the 80% case.
  2. Building a custom widget — registering your own custom element when the default isn’t enough.

Both paths share the same widget: field surface — the parser just dispatches on value kind (identifier vs string).

Configuring the default widget

The default arch-module is configurable through widget.* fields. The full configuration surface:

FieldEffect
widget.iconIcon id (one of the stdlib icons: service, database, browser, cluster, etc.)
widget.colorAccent colour (CSS colour value)
widget.bgBackground colour or CSS background-image value
widget.subheaderPath of a field/label whose resolved value renders under the name (e.g. team, labels.domain)
widget.footerPath of a field/label whose resolved value renders as the footer band
widget.label1, widget.label2Paths whose resolved values render as key: value chips in the body
widget.width, widget.heightCard size in model pixels (default 220 × 140)

The path-style fields (subheader, footer, label1, label2) accept either a bare path (team) or an explicit prefix (labels.X, fields.X). A bare path tries labels first, then fields.

The stdlib bundles a cascade * group at every kind type so authors get sensible defaults out of the box. For example, the stdlib’s service cascades:

type module service {
required cascade team
required labels.domain
cascade * widget: arch-module {
icon: service
color: "#60a5fa"
subheader: team
footer: "labels.domain"
}
}

Every service instance inherits the lot. To tweak a single leaf, override that leaf on the instance:

service Orders {
widget.color: "#22c55e"
}

To replace the widget entirely (swap to a custom element), set the root:

service Orders {
widget: my-orders-card
}

Because the parent declares cascade *, the entire group (including widget.icon, widget.color, etc.) is dropped from the inheritance — no per-leaf drop ceremony needed. The new widget starts fresh. See Chapter 18 for the cascade-group rules.

Building a custom widget

Two reasons to write a custom element:

  • The default widget can’t represent your visual — you want a database cylinder, a stick-figure actor, a table-shaped renderer where each row is an interface, etc.
  • You need behaviour the default doesn’t expose — animation, hover detail, embedded charts, live data fetch.

Skeleton:

class MyWidget extends HTMLElement {
static get observedAttributes() { return ["name"]; }
connectedCallback() {
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>:host { display: block; width: 100%; height: 100%; }</style>
<div class="card"></div>
`;
this.render();
}
attributeChangedCallback() { this.render(); }
// Custom property — the renderer assigns `el.arch = module` AFTER
// connectedCallback fires. Trigger a re-render on assignment.
set arch(value) { this._arch = value; this.render(); }
get arch() { return this._arch; }
render() {
const root = this.shadowRoot;
if (!root) return;
const name = this.getAttribute("name") ?? this._arch?.name ?? "";
root.querySelector(".card").textContent = name;
}
}
customElements.define("my-widget", MyWidget);

Register the element by listing its JS file in your package’s widgets: field — see Chapter 12.

Use it from a module:

service Orders {
widget: my-widget
}

The renderer emits <my-widget name="Orders" ...> for that node.

What the widget receives

SurfaceContents
Attribute nameInstance name
Attribute data-has-subspace"true" when the module has children; widget can branch on this for container chrome
Attribute data-selected"true" when this is the selected module
Attribute data-highlighted"true" when in active blast radius
Attribute data-hovered"true" / "false" driven by the React layer (use instead of :hover to avoid Chrome’s sticky-hover quirks on GPU layers)
Attribute data-module-idThe module’s stable id (when present) — used by the renderer’s DOM lookups
Attribute widget.<…> propsEvery widget.* prop, kebab-cased — value coerced to string
CSS var --arch-projected-widthProjected pixel width (continuous)
CSS var --arch-scaleViewport scale (1 = model space)
CSS var --arch-band-compact, --arch-band-compact-reveal0→1 as the node enters compact-readable zoom (structure vs reveal phase)
CSS var --arch-band-full, --arch-band-full-reveal0→1 as the node enters full-detail zoom
Property el.archFull resolved module: { id, name, kindName, description, fields, labels, facets, interfaces, children, sourceFile, … }
Property el.archZoom{ projectedWidth, scale }
Property el.archState{ selected, highlighted, hovered }
Property el.archConnections (optional){ incoming, outgoing } if set by the renderer

DOM properties are set after connectedCallback. The pattern in the skeleton above (setter triggers re-render) covers this.

Node sizing — the widget owns it

The layout solver does not read widget.width / widget.height from the instance directly. It asks the widget. Widgets choose between three policies:

  • Customisable — read props.get("width") / props.get("height") in sizeFor and fall back to your own default:

    class CustomCard extends HTMLElement {
    static sizeFor(props) {
    const w = props.get("width");
    const h = props.get("height");
    return {
    w: typeof w === "number" ? w : 220,
    h: typeof h === "number" ? h : 140,
    };
    }
    }
  • Fixed — declare static archWidth / static archHeight (and skip sizeFor). Instance widget.width: 999 has no effect.

    class Actor extends HTMLElement {
    static archWidth = 110;
    static archHeight = 140;
    }
  • Computed — derive size from the model. sizeFor receives (props, ctx) where ctx = { kindName, interfaces, labels }. Pure, deterministic — same inputs always return the same size, so the solver doesn’t have to re-measure.

    class DbTable extends HTMLElement {
    static archWidth = 240;
    static rowH = 22;
    static headerH = 44;
    static sizeFor(_props, ctx) {
    const rows = Math.min(ctx.interfaces.length, 32);
    return { h: this.headerH + rows * this.rowH };
    }
    }

Resolution order per axis: sizeFor(props, ctx)static archWidth/Height0 (solver then uses its global NODE_W/NODE_H default).

Zoom-aware rendering

The renderer exposes zoom through continuous CSS variables that ramp smoothly across user wheel/pinch. Bind layout and reveal to them:

[header] { flex: 1 0 auto; min-height: 1.6em; }
[body] { flex: calc(999 * var(--arch-band-compact, 0)) 0 0; min-height: 0; }
[footer] { max-height: calc(1.6em * var(--arch-band-full, 0)); opacity: var(--arch-band-full-reveal, 0); }

--arch-band-compact ramps 0→1 around the compact-zoom range (default 0.52–0.56 of viewport scale); --arch-band-full ramps later around 1.09–1.13. The matching *-reveal variants ramp slightly after the structure phase so layout settles before content fades in. With body grow tied to band-compact, the body collapses at dot zoom and the header absorbs the leftover space.

For font sizing, container queries are cleaner than raw band signals: declare the header as container-type: size; container-name: my-header; and use cqh/cqw units inside.

Subspace projection

When a module has children, the renderer mounts the subspace as a light-DOM child of the widget host. The widget projects it via its default <slot>:

class MyContainer extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host { display: block; width: 100%; height: 100%; }
.card { display: flex; flex-direction: column; padding: 0.6rem; height: 100%; }
.body { flex: 1; position: relative; }
::slotted(*) { position: absolute; inset: 0; }
</style>
<div class="card">
<header>{{name}}</header>
<div class="body"><slot></slot></div>
</div>
`;
}
}

The <slot> position is where the subspace renders — the renderer-side NestedSpaceFit wrapper measures the slot’s actual rect via ResizeObserver and fit-contains the nested scene inside it. Widgets without a <slot> silently don’t show subspace; that’s the opt-out for leaf-only widgets.

For pixel-perfect dive-in animation, expose the body region under part="subspace" (matches the renderer’s measureItemBodyRect lookup):

<div class="body" part="subspace"><slot></slot></div>

Selection and highlight

State surfaces as host attributes:

:host([data-selected="true"]) .card {
box-shadow: 0 0 0 2px var(--accent), 0 0 24px var(--accent-glow);
}
:host([data-highlighted="true"]) .card {
box-shadow: 0 0 0 2px var(--warn);
}
:host([data-hovered="true"]) .card {
border-color: var(--accent);
}

Edge anchors

Mark connection points with data-anchor="<side>". The layout engine reads getBoundingClientRect():

<div class="anchor" data-anchor="in" style="position:absolute; left:-4px; top:50%"></div>
<div class="anchor" data-anchor="out" style="position:absolute; right:-4px; top:50%"></div>

Inline templates

When you want a one-off custom look without a JS sidecar, set widget: to a string literal:

service Cart {
team: shop
labels { domain: shopping }
widget: "
<div class='w-full h-full p-3 rounded-xl bg-arch-card
border border-purple-400/50'>
<div class='font-semibold text-purple-200'>{{name}}</div>
<div class='text-xs text-slate-400'>{{kindName}} · {{team}}</div>
<div class='text-xs text-purple-400/80'>{{labels.domain}}</div>
</div>
"
}

The parser sees the string value and routes the module through the inline-template host instead of a custom element.

Triple-quoted strings

For templates that contain double quotes (HTML attributes), use triple quotes:

service Cart {
widget: """
<archui-card variant="outline" accent="#a78bfa">
<archui-header slot="header" name="{{name}}"></archui-header>
</archui-card>
"""
}

Triple-quoted strings are raw — backslashes aren’t escape sequences, and ${expr}-style interpolation is deliberately not supported.

Substitution rules

Template substitution is strict and logic-free. There are no conditionals, loops, or formatters — only {{path}} lookups:

TokenResolves to
{{name}}Instance name
{{kindName}} (or {{kind}})Kind keyword
{{description}}Joined description entries
{{<dotted.field>}}Field by path
{{labels.<dotted>}}Label by path
{{{<path>}}}Same as above but raw (skips HTML escaping)
{{widget.<…>}}Always empty — widget config never leaks into content

Unknown paths render as the empty string. Double braces escape; triple braces inject raw HTML.

CSS for inline templates

Tailwind utilities used in templates are compiled server-side via UnoCSS (Tailwind-compatible). The server walks every string-form widget: template, extracts candidate class names, and exposes the compiled CSS at GET /api/widgets/css. The viewer fetches it at boot and re-fetches on workspace updates.

Custom arch-* utilities ship out of the box:

ClassEffect
bg-arch-cardPre-tuned dark gradient for cards
bg-arch-card-hotIndigo-tinted variant
shadow-arch-glow / -lgSubtle accent glow
shadow-arch-cardDrop shadow + inner highlight
text-arch-{50..950}Extended sky-blue palette

The arch.ui primitive library

The stdlib ships a primitive library at arch.ui. Custom widgets and inline templates can compose it by tag name:

service Cart {
team: shop
labels { domain: shopping }
widget: """
<archui-card variant="outline" accent="#a78bfa">
<archui-header slot="header" icon="server" name="{{name}}"></archui-header>
<archui-body slot="body">
<archui-tech-stack></archui-tech-stack>
</archui-body>
<archui-details slot="details">
<archui-field path="team" label="team"></archui-field>
<archui-label path="domain" format="mono"></archui-label>
</archui-details>
<archui-anchor side="in"></archui-anchor>
<archui-anchor side="out"></archui-anchor>
</archui-card>
"""
}

Theme tokens live under --au-* CSS custom properties. Override on any host to retheme a subtree:

:host { --au-accent: #a78bfa; }

Tokens are layered with CSS @layer archui.tokens, archui.project; so project tokens win over stdlib defaults regardless of import order.

Cascade

Both widget and widget.<…> are regular fields, so the propagation mechanisms from Chapter 18 apply. Use cascade * to bundle the whole config so per-kind defaults can be wholesale-replaced or surgically tweaked:

type module service {
cascade * widget: arch-module {
icon: service
color: "#60a5fa"
subheader: team
footer: "labels.domain"
}
}
// Replace the whole bundle:
type service fancy {
widget: my-fancy-element
// arch-module's icon / color / etc. are NOT inherited — the
// cascade-group root was replaced.
}
// Or override just a leaf:
service Orders {
widget.color: "#22c55e" // keeps icon: service, subheader: team, etc.
}

Edge widgets

Edges (lines on the board) can carry their own widget too. The mechanism mirrors module widgets — a widget: field on the interface chain, with widget.<…> props. Interfaces typically declare their widget at the kind type level:

type interface command {
cascade widget: arch-edge
cascade widget.color: "#60a5fa"
}

Edge widgets receive el.archEdge = { from, to, id, details } and el.archPath (the rendered SVG path string). Use the path to draw decorations along the line — labels, animated dots, glow accents.

Quick reference

Want to …Do this
Tweak default widget lookSet widget.<icon|color|subheader|footer|label1|label2>
Replace the widget entirelySet widget: my-element (drops parent’s cascade * group)
Customise a single leaf, keep the restwidget.color: …
Force-fix card size at the widgetDeclare static archWidth / archHeight on the element, no sizeFor
Make card size customisableImplement sizeFor(props) reading props.get("width")/get("height")
Render nested childrenDeclare a <slot></slot> in shadow DOM, mark part="subspace" on the wrapper
Skip nested children entirelyOmit the slot — light DOM is dropped, leaf-only widget
One-off custom look, no JSInline template via widget: "<html>...</html>"
Compose stdlib primitivesUse archui-* tags in templates