On this pageOverview
CustomElement
Native web components are a standard browser feature: a hyphenated tag, a class extending HTMLElement, observed attributes, JS properties, and CustomEvents. They render their own shadow DOM and handle their own keyboard and pointer behavior. Foldkit gives them a typed seam through CustomElement.define, so they slot into a view next to standard elements without manual property wiring or a separate mount step.
You declare the element’s shape once with Schema. Property factories arrive as PascalCase methods on the builder, event factories as On{PascalCase} methods. The builder is callable, so the element itself appears inline in your view alongside h.div, h.button, etc. Property changes diff across renders; CustomEvents come back as Messages.
CustomElement.define mirrors Command.define and Mount.define
The shape is consistent across Foldkit’s lifecycle primitives. Declare the foreign element once. The runtime owns the wiring. Your view stays a pure function from Model to VNode.
Foldkit only owns the typed binding. Registering the element class with the browser is the same step you would take in any other framework. Most third-party packages do it for you when imported: import 'vanilla-colorful/hex-color-picker.js' calls customElements.define('hex-color-picker', HexColorPicker) as a side effect, and <hex-color-picker> is then a real tag in the browser. If you author your own element, you do the same: customElements.define('your-tag', YourClass) once, usually alongside the class definition.
A CustomElement.define call takes the tag name, a record of properties keyed by their JS property name, and a record of events keyed by their kebab-case event name. It returns a spec you can export and share across modules. Inside the view module, call .withMessage<Message>() to mint a typed builder bound to your Message universe.
import '@shoelace-style/shoelace/dist/components/qr-code/qr-code.js'
import { Schema as S } from 'effect'
import { CustomElement } from 'foldkit'
import { type Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import 'vanilla-colorful/hex-color-picker.js'
// The two side-effect imports above register each custom element with
// the browser. Foldkit does not call customElements.define for you;
// most third-party packages do it as a side effect on import. If you
// author the class yourself, you call customElements.define once next
// to the class.
// Declare a typed Foldkit binding for each element. Properties become
// PascalCase factories on the builder, events become On{PascalCase}
// factories, all checked against the declared Schema.
const hexColorPicker = CustomElement.define({
tag: 'hex-color-picker',
properties: {
color: S.String,
},
events: {
'color-changed': S.Struct({ value: S.String }),
},
})
const qrCode = CustomElement.define({
tag: 'sl-qr-code',
properties: {
value: S.String,
fill: S.String,
background: S.String,
size: S.Number,
},
events: {},
})
// Mint typed builders for your Message universe (mirrors
// `M.withReturnType<T>()`).
const ChangedFillColor = m('ChangedFillColor', { value: S.String })
const Message = S.Union([ChangedFillColor])
type Message = typeof Message.Type
const h = html<Message>()
const fillPicker = hexColorPicker.withMessage<Message>()
const qr = qrCode.withMessage<Message>()
// Use the builders inline next to standard elements. Property factories
// write JS properties through Snabbdom's propsModule. Event factories
// convert kebab-case CustomEvents into Messages. The picker and the QR
// never talk directly; they share state through the Model.
export const designerView = (model: {
content: string
fillColor: string
}): Html =>
h.div(
[h.Class('flex gap-6')],
[
fillPicker([
fillPicker.Color(model.fillColor),
fillPicker.OnColorChanged(detail =>
ChangedFillColor({ value: detail.value }),
),
]),
qr([
qr.Value(model.content),
qr.Fill(model.fillColor),
qr.Background('#ffffff'),
qr.Size(200),
]),
],
)The element constructor is callable. Pass attributes (including the property and event factories) as the first argument and children as the second, same shape as h.div and friends. Property factories are checked against the declared Schema at the type level: qr.Size("220") is a compile error when size is declared as S.Number. Event factories receive a typed detail argument; the consumer returns a Message that the runtime dispatches when the CustomEvent fires.
Each property in the config becomes a PascalCase factory: value → Value, isDisabled → IsDisabled. The factory writes a JS property on the live element through propsModule, not an HTML attribute. That distinction matters: properties can carry any JS value (arrays, dates, objects), and propsModule diffs them across renders so the element only receives writes when the value actually changes. Removed boolean properties get reset to false on cleanup.
Each event becomes an On{PascalCase} factory derived from the kebab-case name: "color-changed" → OnColorChanged. The factory takes a (detail) => Message callback. When the element dispatches the CustomEvent, the runtime extracts event.detail, runs your callback, and dispatches the resulting Message just like any other handler.
Validation runs at define time
CustomElement.define validates property and event names up front. Property names must be valid JS identifiers; event names must be hyphen-separated lowercase segments. Collisions between factory names (e.g. a click event and an onClick property both producing OnClick) throw immediately so you catch them before they ship.
CustomElement is the right primitive when the foreign DOM speaks the three regular web-component surfaces: typed JS properties, observed attributes, and dispatched CustomEvents. Everything you push to the element is a property; everything you read back is an event. The element owns its rendering and its internal state, and Foldkit sees the same surface a vanilla JS or React consumer would.
Mount stays the right primitive for foreign DOM that does not speak properties, attributes, and events. A code editor that exposes an imperative setValue(text) method, a map renderer that wants its own HTMLElement to render into, an audio worklet that hands back a node, all of those need direct access to the live element. OnMount is the seam for that. Web components do speak attributes and properties and events, so CustomElement.define is the higher-level fit when those surfaces are available.
The Web Components example pairs two real third-party elements: <hex-color-picker> from vanilla-colorful emits color-changed CustomEvents that flow back as Messages, and <sl-qr-code> from @shoelace-style/shoelace accepts typed properties that the runtime diffs through propsModule. The picker and the QR code never touch each other directly; they share state through the Model.