Custom components
Render the document's kebab-case node types with your own SwiftUI, UIKit, or WebKit views.
Register a handler keyed by the kebab-case node type. The factory receives a single ComponentContext and returns a ComponentBody — .custom { … } for SwiftUI:
import JoyDOM
let registry = ComponentRegistry().withDefaultPrimitives()
registry.register("contact-button") { context in
.custom {
Button(context.props.string("label") ?? "") {
context.events.emit("tap")
}
}
}register is last-wins and chainable, so a custom "img" overrides the built-in one.
The component context
The context carries everything the component can see:
context.props— the node's effective props:props.string("key")reads a scalar,props.value("key")the raw JSON value.context.events— the outbound surface for reporting interactions (emit).context.node— the component's own raw specNode(structure, children, authored extras).context.children— the rendered-children slot. Place it in a.custombody to render the node's JSON children at that position; ignore it and they don't render (shadow-DOM-without-<slot>semantics). Don't retain it past the factory invocation: each snapshot allocates a fresh slot box, so holding one keeps that snapshot's rendered subtree pinned in memory.context.parent— this node's parent as a raw specNode(nilat the root; read its id viaparent?.props?.id). The whole-document tree is intentionally not exposed — only this one parent hop.context.style— the node's fully resolved/cascaded style, i.e. what the renderer itself lays it out with:style?.visual,style?.item,style?.container,style?.display.
Rendering children
Place context.children wherever the node's JSON children should appear:
registry.register("fancy-card") { context in
.custom {
VStack(alignment: .leading) {
Text(context.props.string("title") ?? "")
context.children // the node's JSON children render here
}
}
}The children slot only activates for kebab-case custom types with .custom bodies. A registered override for a built-in type ("div", "img", …) keeps the renderer-managed children layout, and .uiKit / .webView bodies can't host SwiftUI children, so nodes rendered by those fall back to the auto-wrapped container (with a diagnostic).
Overriding a built-in
Overriding a built-in also takes over its event dispatch: the renderer stops auto-wiring the document's onclick/onfocus/onblur for that type (wiring both would fire one tap twice), so an override that should stay tappable re-wires explicitly — one line:
registry.register("div") { context in
.custom {
MyDivChrome()
.onTapGesture { context.events.emit("onclick") }
}
}A document that binds events on an overridden type without this re-wire gets a diagnostic pointing here.
register vs. registerDefault
register(_:factory:) makes the type an app component — the renderer hands event dispatch to
you. registerDefault(_:factory:) is for drop-in replacements of built-in rendering where the
renderer should keep ownership of the node's event wiring, as it does for withDefaultPrimitives()
— e.g. your own registerDefault("img") { context in .custom { … } } that loads images from your
asset pipeline. (The bundled withTestAssetImageFactory() is one such replacement, but it lives in
the dev-only JoyDomSampleSpecs target — import JoyDomSampleSpecs to reach it — not the shipped
JoyDOM library, so app code registers its own instead.) There's also default { … }, a single
catch-all rendered for any node whose type has no registered factory; it's treated like a registry
hit, so a kebab-case unknown type's .custom body gets a working context.children slot too.
UIKit and WebKit bodies
When SwiftUI can't express the component, return a different ComponentBody. A UIKit body bridges a UIView:
registry.register("signature-pad") { context in
.uiKit(
make: { SignatureView() },
update: { view in view.strokeColor = .label }
)
}A WebKit body loads HTML into a WKWebView. Both baseURL (nil) and onMessage (a no-op) default, so .webView(html: …) alone compiles; pass onMessage only when the page talks back:
registry.register("rich-text") { context in
.webView(
html: context.props.string("html") ?? "",
baseURL: nil,
onMessage: { payload in print(payload) }
)
}JS contract: the page posts to the cssLayout handler — window.webkit.messageHandlers.cssLayout.postMessage({ … }) — and only message bodies that decode as [String: String] reach onMessage; other shapes are dropped silently.
Neither bridge hosts SwiftUI children, so don't rely on context.children inside them.
Where next
- Events — what
emitdispatches and how.onEventreceives it. - API reference (DocC) ↗ —
ComponentRegistry,ComponentContext, andComponentBodyin full.