Children and slots
A primitive holds other primitives in one of two ways, and which one you reach for decides what a proposal can say about the result.
Children are an ordered list. The parent renders them where it renders them, in the order they appear, and does not know what they are.
Slots are named regions. The parent places each one deliberately — a
heading here, actions there, media over on that side — and content
addressed to a slot it does not place renders nothing at all.
Slots
A slot is a region the primitive places
The heading above sits in a named region. Everything after it is an ordinary child, placed in order.
Fill the slot or leave it, and the section still knows where its heading goes.
Every one of these goes through the same pipeline a model’s answer would: interpreted, analysed, weighed, judged, applied, appended. The log is real and it is in this tab — reload the page and the example is back as it was.
show the treehide the tree
{
"kind": "element",
"id": "n_slottree9",
"type": "loom.page",
"props": {
"fills": true,
"width": "readable",
"loom:theme": {
"palette": "minimal",
"fontPack": "minimal-sans",
"stylePreset": "precise"
}
},
"children": [
{
"kind": "element",
"id": "n_slottree8",
"type": "loom.section",
"props": {
"tone": "surface",
"width": "readable",
"eyebrow": "Slots"
},
"children": [
{
"kind": "slot",
"id": "n_slottree3",
"name": "heading",
"children": [
{
"kind": "element",
"id": "n_slottree2",
"type": "loom.heading",
"props": {
"level": 2
},
"children": [
{
"kind": "text",
"id": "n_slottree1",
"value": "A slot is a region the primitive places"
}
]
}
]
},
{
"kind": "element",
"id": "n_slottree5",
"type": "loom.prose",
"props": {
"measured": true
},
"children": [
{
"kind": "text",
"id": "n_slottree4",
"value": "The heading above sits in a named region. Everything after it is an ordinary child, placed in order."
}
]
},
{
"kind": "element",
"id": "n_slottree7",
"type": "loom.prose",
"props": {
"tone": "muted",
"size": "small"
},
"children": [
{
"kind": "text",
"id": "n_slottree6",
"value": "Fill the slot or leave it, and the section still knows where its heading goes."
}
]
}
]
}
]
}Children: order is the meaning
buildElement(ids, {
type: "loom.page",
children: [hero, features, pricing],
})
Nothing about that list is named. features is second because it is second, and
moving it is a move operation against a known id. Use children when the parent
is genuinely a container: a page, a stack, a grid, a list.
A container's own props say how the list is arranged — columns, density, alignment — never what is in it.
Slots: the parent decides where
buildElement(ids, {
type: "loom.hero",
props: { align: "center", eyebrow: "The AI-native UI runtime" },
children: [
buildSlot(ids, "heading", [heading(ids, 1, "Your AI can change this page")]),
prose(ids, "And you can see exactly what it changed."),
buildSlot(ids, "actions", [primaryAction, secondaryAction]),
],
})
A slot node is a child like any other, but its name is what the parent reads. In the component:
component: ({ loom, children }: LoomPrimitiveProps) =>
createElement(
"section",
{ ...loom.editable },
loom.slots["heading"],
createElement("div", { className: "body" }, children),
loom.slots["actions"]
)
loom.slots is always present — empty when the node has none — so a primitive
reads a slot without first proving the map exists. A slot the component never
places renders nothing, which is the property that makes a region a region
rather than a position in the list.
Declare the names you accept:
slots: ["heading", "actions", "media"]
Which one to use
Ask what a proposal should be able to say.
- If "add another one of these" is a sensible change, they are children.
- If the page needs "the heading of this section" to be a place — one that exists whether or not anything is in it — it is a slot.
A hero has one headline in one position, and that position is not "first child"; it is the heading region, which the hero styles, sizes and lays out around. A feature grid, by contrast, has as many tiles as it has, and each is a child.
Containers and their children
The pattern that repeats across the library: a container primitive that arranges, and a child primitive that is the thing being arranged.
loom.stat-grid over loom.stat. loom.feature-grid over loom.feature.
loom.tier-table over loom.tier. The naming rule is deliberate — a container
is its child's name plus the arrangement — so that a reader who knows one pair
can guess the rest.
A tree, not a template
The page is data, so a change to it is addressable rather than a diff of generated code.
A delta, not a rewrite
Four operations against an existing tree — insert, remove, move, configure.
A gate, not a hope
A pure function decides what is allowed, before anything is applied.
Every one of these goes through the same pipeline a model’s answer would: interpreted, analysed, weighed, judged, applied, appended. The log is real and it is in this tab — reload the page and the example is back as it was.
show the treehide the tree
{
"kind": "element",
"id": "n_featuregrid5",
"type": "loom.page",
"props": {
"fills": true,
"width": "wide",
"loom:theme": {
"palette": "minimal",
"fontPack": "minimal-sans",
"stylePreset": "precise"
}
},
"children": [
{
"kind": "element",
"id": "n_featuregrid4",
"type": "loom.feature-grid",
"props": {
"columns": "three"
},
"children": [
{
"kind": "element",
"id": "n_featuregrid1",
"type": "loom.feature",
"props": {
"icon": "◇",
"title": "A tree, not a template",
"body": "The page is data, so a change to it is addressable rather than a diff of generated code.",
"surface": "card"
},
"children": []
},
{
"kind": "element",
"id": "n_featuregrid2",
"type": "loom.feature",
"props": {
"icon": "◈",
"title": "A delta, not a rewrite",
"body": "Four operations against an existing tree — insert, remove, move, configure.",
"surface": "card"
},
"children": []
},
{
"kind": "element",
"id": "n_featuregrid3",
"type": "loom.feature",
"props": {
"icon": "◆",
"title": "A gate, not a hope",
"body": "A pure function decides what is allowed, before anything is applied.",
"surface": "card"
},
"children": []
}
]
}
]
}It is also what makes the vocabulary extensible without growing: a new kind of list is usually a new child, arranged by a container that already exists.
What neither of them carries
No arrangement carries behavior. There is no onClick in a tree, no handler
passed from a parent to a child, no expression evaluated anywhere in a node.
Everything a primitive does when a reader interacts with it lives in the
component you registered.
That is the constraint that makes composing safe to hand to a model: the worst a rearrangement can produce is a page you would not have chosen.