skip to the page

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.

A named region, and the children after itlive · rendered through the runtime

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.

Propose a change:

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 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."
            }
          ]
        }
      ]
    }
  ]
}
The section places its heading slot itself. Its other children are an ordered list it lays out below.

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 container over a repeated childlive · rendered through the runtime

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.

Propose a change:

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 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": []
        }
      ]
    }
  ]
}
A feature grid arranges feature nodes. The repeated thing is a node, so a proposal can add one — a fixed field would have needed a new primitive.

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.