Skip to content
HomePagesHomePages template kit

Slot primitives

The binding you build from a schema, the `Slot.*` primitives that render it and own its marker, and the `SlotGroup` and `Section` contract components.

To render slot X, you use slot X’s primitive — there is nothing else, and the marker cannot be anywhere else. Three sentences cover the whole surface:

  • Every primitive is its slot’s marker, renders your markup when you want control (as picks the element, whose own props all flow; children replace the default rendering), and derives everything else from the binding.
  • Bindings are how content moves — across files, author components, and island boundaries alike, as plain JSON props.
  • A list is an ordinary React array, mapped inside its marked container.

bindSlots ties a slot’s id, its declared definition, and its runtime value into one object; Slot.* renders that value and derives its marker from the binding, placing it on the element it rendered, so a click in the editor resolves back to the schema key. Those two halves are the whole authoring contract for editable content: you never hand-roll a marker yourself, and a primitive that can read a slot’s own frame or playback never needs it re-passed at the call site.

Everything here is imported from the package root:

import { bindGroups, bindItems, bindSlots, Image, Section, Slot, SlotGroup } from "@homepages/template-kit";
Primitive Renders Used when
bindSlots nothing — returns the binding map once per render, at the top of every Renderer.tsx
bindItems nothing — returns one ItemBinding per list member iterating a list slot’s items
Slot.Text / Slot.Number the value as text, or an author-formatted node any scalar slot rendered as its own element
Slot.Select an author-defined label keyed off the stored value a select slot
Slot.Url the value in href or src, author children inside a url slot
Slot.Image / Slot.Video the responsive-image / video machinery an image slot — or a video or tour slot, whose poster Slot.Image renders
Slot.Tour the provider’s lazy <iframe>, in the declared frame a tour slot
Slot.Document one anchor at the served file, author children inside a document slot
Slot.List the list’s marked container; children are your own markup a list slot, whatever its item holds
Image / Video / Tour the same machinery, from a raw value and no marker a single-slot list’s item, or a raster the template ships
SlotGroup + bindGroups the shared container of a group’s members a group(...) card in the schema’s layout
Section class="tr-section" on the section root always — every renderer roots at exactly one

A slot with no marker in the rendered DOM cannot be selected or edited — see missing-slot-marker, which proves marker coverage from the rendered page.

Build the binding once, at the top of the render, from the schema value (not import type — a binding carries the slot’s declared def, which is why the schema import here is a real value):

import { bindSlots, defineSchema, Section, Slot, text } from "@homepages/template-kit";
import type { SectionProps } from "@homepages/template-kit";
const schema = defineSchema({
label: "Hero",
slots: {
eyebrow: text.short({ label: "Eyebrow" }),
headline: text.medium({ label: "Headline" }),
},
});
type Props = SectionProps<typeof schema>;
export function Renderer({ slots, nav }: Props) {
const slot = bindSlots(schema, slots);
return (
<Section id={nav.selfAnchor} className="bg-surface py-16">
<p className="text-sm uppercase"><Slot.Text slot={slot.eyebrow} as="span" /></p>
<Slot.Text slot={slot.headline} as="h1" className="text-4xl font-semibold" />
</Section>
);
}

slot.headline is a binding: { id, def, value, present }. id is the slot key — the identity every Slot.* primitive derives its marker from — def is exactly what the schema declared for it (so a primitive can read the slot’s own frame, crop, playback, prefix or values instead of it being re-exported and handed back at the call site), value is this render’s resolved value — normalized at bind time to the exact shape the schema promises — and present is whether that value is anything other than its kind’s blank. The map is typed off typeof schema, so slot.headlin is a compile error rather than an undefined that quietly renders nothing.

Bindings are plain JSON — no getters, no class instances, no non-enumerable helpers — so a binding is also a valid island prop: pass slot.units straight into a "use client" component and it round-trips through JSON exactly as written. See Islands below.

bindItems(binding) resolves a list binding into one ItemBinding per member — { slot, index, value, present }, plus fields (one field binding per declared row field) when the item is a record. Iteration is then ordinary React:

<Slot.List slot={slot.units} as="ul">
{bindItems(slot.units).map((unit) => (
<SlotGroup slot={unit} as="li" key={unit.index}>
<Slot.Text slot={unit.fields.unit_label} as="span" />
<Slot.Number slot={unit.fields.list_price} as="span" format={(n) => `$${n.toLocaleString("en-US")}`} />
</SlotGroup>
))}
</Slot.List>

index is the member’s array position, which is also its data-slot-item value — the two cannot drift because both are read from the same binding. A row field binding’s marker is data-slot-field, not data-slot-id — it addresses one field of one row, the fourth and last dimension of slot identity, and a record cannot nest inside a record so no fifth dimension can exist. An ItemBinding is plain JSON like every other binding, so it crosses component and island boundaries as an ordinary prop.

Slot.Text / Slot.Number — text leaves and the leaf rule

Section titled “Slot.Text / Slot.Number — text leaves and the leaf rule”

Both render the value into the ONE element you name via as, and both follow the same rule for when that element’s text is editable live versus when it merely displays the value:

<Slot.Text slot={slot.headline} as="h1" className="text-4xl font-semibold" />

The leaf rule. A raw render — no format, no children — emits data-slot-text-leaf, which tells the editor this element’s whole text content is the slot’s value, so it can write textContent on every keystroke instead of waiting for the next server render:

// raw: data-slot-id="headline" data-slot-text-leaf=""
<Slot.Text slot={slot.headline} as="h1" />

format (an author function keyed off the value) and children (author markup instead of the raw value) both withhold the leaf — the rendered text is not the raw value, so the editor must not patch it as if it were:

// formatted: data-slot-id, no leaf — "$1,500,000" is not the raw 1500000
<Slot.Number slot={slot.list_price} as="span" format={(n) => `$${n.toLocaleString("en-US")}`} />;
// children: data-slot-id, no leaf
<Slot.Text slot={slot.headline} as="h1"><em>{slot.headline.value}</em></Slot.Text>;

multiline keeps the leaf and splits the value on \n into <br> — the value is still the raw text, just wrapped for a multi-line field:

<Slot.Text slot={slot.bio} as="p" multiline />

What withholding the leaf costs. A leafed element updates as the user types; a formatted one round-trips through a re-render instead, because the editor cannot write the raw value into text that is not the raw value. That trade is inherent to formatting rather than particular to this design — Slot.Select has always ridden the same path — but it is worth spending deliberately: format where the formatting matters more than the typing latency, and leave the primary editing target raw where it does not.

The format callback only ever sees a filled value. Its parameter is non-nullable, so nothing inside one can be handed a blank to interpolate; a blank slot renders the empty element instead, and the callback does not run. See The empty state below for what to render in that case.

Values arrive normalized. bindSlots canonicalizes every value at bind time, so value is exactly the shape the schema declared and there is nothing to guard for at the call site. A legacy content doc holding a raw number in a text-typed slot (or a numeric string in a number-typed one) arrives as the declared type; anything unrecognizable arrives as the kind’s blank — "" for text, select and url, null for number, image, poi and document, the blank record for video, [] for a list. A string-valued slot is therefore a string even when unfilled, so .trim() on one cannot throw, and a list is always an array, so .map cannot. The whole model, and why it lives at the binding: Values and emptiness.

Slot.Select / Slot.Url — values that never leaf

Section titled “Slot.Select / Slot.Url — values that never leaf”

Neither ever emits data-slot-text-leaf, because in both cases the rendered output is not the raw stored value.

Slot.Select renders an author-defined label keyed off the stored value — the kit does not ship or read any catalog of option labels, so you supply the mapping as children or format:

const STATUS_LABEL: Record<string, string> = { for_sale: "For Sale", for_rent: "For Rent" };
<Slot.Select slot={slot.listing_status} as="span">
{STATUS_LABEL[slot.listing_status.value]}
</Slot.Select>

Reaching the editor’s “attribute-valued, don’t patch as text” path here is deliberate: a raw select value (for_sale) is an internal id, and leafing it would let a keystroke corrupt the enum instead of just changing which label is shown for it.

A creatable select hands you keys you did not declare. The user’s typed text is stored as a normalized key — trimmed, lower-cased, whitespace runs collapsed to - (“Wine Cellar” → wine-cellar) — and nothing transforms it after that: it arrives in your renderer exactly as stored. What that key renders as is yours to decide. Map it to a generic fallback, render the key itself, render nothing — but decide, because the platform will not decide for you. If your section bakes an image or copy against every key and has nothing to show for one it has never seen, leave creatable off; your declared values are then the whole vocabulary.

Because a created key is normalized, check requires the keys you declare on a creatable select to already be in that form: { value: "Pet Friendly" } is rejected in favour of { value: "pet-friendly", label: "Pet Friendly" }. Otherwise a user typing the label would store pet-friendly — a second spelling of a category you had already declared, which your own map would miss.

A plain object literal like STATUS_LABEL is also an unsafe lookup for a creatable select — a typed value matching an inherited Object.prototype member (constructor, toString, …) resolves to that function instead of falling through — so build the map with a null prototype or guard the lookup with Object.hasOwn.

Slot.Url puts the value in an attribute — href on link-like elements, src on embed-like ones (img, iframe, source, embed, video, audio, track, script) — and renders your children inside, never the value as text:

<Slot.Url slot={slot.brochure_link} as="a" target="_blank" rel="noreferrer">
Download the brochure
</Slot.Url>

A blank value omits the URL attribute entirely (never a self-referential href=""); the element still renders, marked and clickable.

Because the value never leafs, a url slot is edited from the sidebar, not by typing on the canvas — the text a visitor sees is your children, which is chrome. If the link’s label should be editable too, that is a second text slot rendered as the Slot.Url’s children; one slot cannot be both the destination and the words.

Slot.Image / Slot.Video — frame and playback from the schema

Section titled “Slot.Image / Slot.Video — frame and playback from the schema”

Both read the slot’s own declared frame (and, for video, playback) off def — the author does not pass either at the call site, so a call site cannot unpack the config and forget a field, or drift from what the schema actually declares:

<Slot.Image slot={slot.hero_image} sizes="(min-width: 1024px) 1200px, 100vw" />;
<Slot.Video slot={slot.tour} />;

An undeclared frame renders unframed — Slot.Image/Slot.Video pass def.frame ?? false through explicitly, retiring the silent third mode (an omitted frame is not the same thing as frame={false}) at the authoring boundary.

Everything else is a normal prop, forwarded to the underlying image/video machinery:

Prop On Meaning
alt both Overrides the value’s own alt. Pass "" for a decorative image or purely-looping video.
fit both "cover" or "contain". Framed mode defaults to cover.
sizes image The slot’s sizes attribute, applied to every srcset candidate.
maxRungWidth image Per-section srcset ceiling in px. Wider rungs are dropped (the smallest is always kept).
mobileBreakpoint image A standalone mobile-crop breakpoint for <picture> switching when def.frame cannot supply one (an undeclared frame). Ignored whenever the slot declares a frame — its own breakpoint wins.
rendition image Commit to ONE rendition of the value (today, only "mobile") as this element’s whole source — see Committing to a rendition below.
preview video A muted, looping, controls-free teaser. Takes precedence over the slot’s declared playback.
selectable both false declines selection on this copy — see selectable={false} below.
…rest both Any <img>/<video> attribute — className, loading, style, ARIA. src, poster, width, height and the playback-owned attributes (autoPlay, loop, muted, controls, playsInline, preload) are off the type; they come from the value or the slot’s own playback config.

Framed (the default, when the slot declares frame). A wrapper div carries the marker and the aspect-ratio box and absolutely-positions the inner <img>/<video> with object-cover. The box is frame.desktop.aspect, switching to frame.mobile.aspect below frame.breakpoint. With no value the wrapper renders alone — a poster if the video has one, otherwise a neutral role="img" rect — keeping the layout box and the slot selectable. Either side’s aspect can be declared nominal: true on the schema (a fluid, non-aspect slot like a full-bleed hero), which withholds just that side’s aspect-ratio so your own height classes decide the box instead.

The placeholder fill rides on .tr-image-frame (from base.css), not a theme utility — retint it per template with --tr-image-frame-bg.

Unframed (the slot declares no frame) renders a single <img class="w-full"> / <video class="w-full"> — a link-sourced video renders its provider <iframe> instead — for an element whose parent already establishes the layout box: a logo, or a slide inside its own aspect-ratio parent:

<Slot.Image slot={slot.brand_logo} fit="contain" className="h-12" />

Responsive images. Given a responsive descriptor on the value, Slot.Image serves per-format rungs: the base format (last in AVIF → WebP → base order) rides srcset on the <img> itself, and the earlier ones become <source srcset> inside a generated <picture>. A mobile variant carrying a responsive descriptor of its own adds media-gated crop <source>s ahead of those, given a breakpoint — the slot’s frame.breakpoint, or the mobileBreakpoint prop when unframed.

Do not write markup or browser-side code against a particular rendered root. Which element the primitive roots at is decided by the descriptor, the mobile variant, the breakpoint and whether the value is blank, and the marker does not always ride the outermost element: a slot declaring a frame puts it on the framed wrapper, and an unframed one puts it on the <img> itself — which may in turn sit inside a generated <picture> — or on the placeholder box when the value is blank. Find it by querying the rendered markup for the attribute rather than by reading any one element’s attributes.

A video’s or tour’s thumbnail is an image. Slot.Image binds a video or a tour slot as well as an image one, and renders that asset’s poster:

<Slot.Image slot={slot.tour} />

The frame is the bound slot’s own declared frame, and the marker names that slot — so the still and the player are two renderings of one selection, and the editor opens the same card from either. Do not declare a separate image slot beside the video or tour for this: the user would pick a photo in one card and a video in the other, and nothing would keep them in agreement.

A poster is selected, never edited. There is no crop (neither video() nor tour() accepts one) and no choice of frame — picking a different video or tour is the only lever. rendition has no effect here, since a poster carries no per-view crop, and a poster that does not exist yet renders the same neutral rect a blank image slot does. That last state is a passing one for a video (an upload still transcoding) and an ordinary steady one for a tour, whose provider may publish no usable thumbnail at all.

Video playback. Slot.Video passes the slot’s declared playback through — a hosted upload takes it as <video> attributes verbatim, a provider link as that player’s own embed params, as far as the player allows; the primitive infers nothing from the asset. See Video slots for the fields and the autoplay-requires-muted rule. With no playback declared you get a plain controls player, because a video a visitor cannot start is a worse failure than a control bar the author did not think about. preview overrides all of that for a decorative, autoplaying loop — see One slot, several places below for the preview-then-player pattern this exists for.

Two elements at different DOM positions can render the SAME slot value without ever sharing one <picture> — a mobile card and a desktop aside are two separate elements, not one element switching at a media query. rendition="mobile" resolves the value to its mobile crop before anything else runs, so that element’s whole output is that ONE rendition:

{/* Same slot, two elements, one committed crop each — never a shared <picture>. */}
<Slot.Image slot={slot.headshot} rendition="mobile" className="size-28 rounded-full lg:hidden" />;
<Slot.Image slot={slot.headshot} className="hidden w-full rounded-lg lg:block" />;

rendition always wins over any breakpoint. Resolving the value clears its own .mobile first, so there is nothing left to device-switch to — no media-gated <source> is ever emitted once a rendition is picked, even if def.frame declares one. A value with no .mobile falls back to its desktop rendition, never a degenerate <source>.

Image — the raw primitive behind Slot.Image

Section titled “Image — the raw primitive behind Slot.Image”

Slot.Image is a thin binding wrapper: it reads frame off the binding’s def, derives the marker from its id, and hands both to Image, which is the responsive-image engine itself. Every prop in the table above is Image’s. Reach for it directly in the two cases where there is no slot binding to read:

  • An item of a single-slot list — the ItemBinding carries no def, so frame is passed at the call site from a const section.ts exports. See Slot.List.
  • A raster the template itself shipsimport photo from "./photo.jpg" yields an AuthorImage, which value also accepts. alt is required on that form, since an AuthorImage has no alt of its own to fall back on. See Static assets.
<Image value={slot.hero_image.value} frame={PHOTO_FRAME} fit="cover" sizes="100vw" />

Image emits no marker of its own — identity reaches the DOM only through a slot’s own primitive, and Image is handed a value, not a binding. That is why it is correct inside a Slot.List (the container carries the collection’s marker) and for a template-shipped asset (nothing editable to mark), and why an editable slot renders through Slot.Image instead. Video stands in the same relation to Slot.Video.

Slot.Tour renders a tour slot as the provider’s own lazy <iframe>, framed by the slot’s declared frame and titled from the value’s alt:

<Slot.Tour slot={slot.walkthrough} />;

It reads frame off def exactly as Slot.Image/Slot.Video do, and takes the same frame={false} opt-out and selectable={false}. It takes no playback and no fit: a tour declares no playback config, and object-fit has no effect on an iframe — the provider’s player letterboxes inside whatever box it is given.

With no value, the framed wrapper renders alone as a neutral role="img" rect. It is not a poster facade, and that is deliberate: a tour’s thumbnail is a separate rendering, so bind the same slot to Slot.Image when you want the still — beside this one for a still and an embed together, or instead of it for a still alone.

Tour is the raw primitive behind it, standing in the same relation to Slot.Tour that Image does to Slot.Image: it takes a value rather than a binding and emits no marker of its own, so reach for it only where there is no slot binding to read.

Slot.Document renders a document slot as one anchor at the served file, with your own markup inside it. There is no viewer behind it and there is not meant to be: the browser displays what it can (a pdf, a text file) and downloads the rest (a spreadsheet, a word processor file), so a single link is the whole render and what that link looks like — an icon, the filename, a size line — is yours.

<Slot.Document slot={slot.brochure} className="flex items-center gap-3">
<span>{slot.brochure.value?.filename ?? "No brochure attached yet"}</span>
</Slot.Document>

It defaults to an anchor element, and opens in a new tab (target="_blank", rel="noopener noreferrer"); both are ordinary props you may override, and as picks a different element exactly as it does elsewhere. It never emits a text leaf — the value is a URL in an attribute, and what the element says is your markup.

A blank value keeps the marked element and drops only the href, so an unfilled slot is a selectable placeholder rather than a link pointing at the page it sits on. Render your children in both branches — they are the only thing the element says — and branch on present for what a missing file should read as.

The value is { asset_id, url, filename, content_type, size_bytes } — the served bytes and their description, and nothing else. A document is never transformed, so unlike an image or a video there is no crop, no frame, no rendition and no responsive ladder to configure: the slot declares that it holds a file, and which file types are accepted is the upload gate’s business rather than a per-slot lever.

A list of documents is list.of(document({ … }), { … }) and renders through Slot.List like every other list. Its item is a single slot, so the editor edits the whole list as one card and the rows carry no per-row marker — plain <li> elements with your own <a> inside, not a Slot.Document each (unit-collection-item-marker).

Slot.List is the one primitive for every list slot, whatever its item holds. It renders the container you name via as, carrying the list’s own marker — and nothing else. The children are your own markup; iteration is plain React over bindItems, with SlotGroup marking each row the editor selects individually:

<Slot.List slot={slot.units} as="ul">
{bindItems(slot.units).map((unit) => (
<SlotGroup slot={unit} as="li" key={unit.index}>
<Slot.Text slot={unit.fields.unit_label} as="span" />
<Slot.Number slot={unit.fields.list_price} as="span" />
</SlotGroup>
))}
</Slot.List>

The index the editor patches by is the array position, structurally — there is no separate index to pass and drift out of sync with. Because the children are ordinary JavaScript, a carousel’s tripled clones ([...items, ...items, ...items].map(...)), a hand-balanced column split (two .slice().map()s), or a container with no row markup at all are all plain array work, never list-primitive modes. An empty collection renders the marked container with your placeholder children inside, so the slot never loses selectability.

A single-slot list of images is the one case where the obvious primitive is not the one to reach for. Slot.Image takes a slot binding, and reads the declared frame off its def; a single-slot list’s ItemBinding is { slot, index, value, present } and carries no def, so handing one to Slot.Image is a compile error, not a runtime surprise. Render the item through Image — the same engine Slot.Image wraps — passing frame explicitly:

<Slot.List slot={slot.gallery} as="div" className="grid grid-cols-3 gap-2">
{bindItems(slot.gallery).map((photo) => (
<Image
key={photo.index}
value={photo.value}
frame={PHOTO_FRAME}
fit="cover"
sizes="(min-width: 768px) 33vw, 100vw"
loading="lazy"
className="w-full rounded"
/>
))}
</Slot.List>

PHOTO_FRAME is a named const your section.ts declares and exports, read twice from that one declaration: by the item (image({ label: "Photo", frame: PHOTO_FRAME })) and by this call site. Exporting it is what keeps the tile’s aspect box and the item’s declared frame from drifting apart.

A hand-written <img> is the wrong answer here, even though it is shorter. It serves one url at one size: no responsive srcset, no mobile-crop <picture> switch, and no aspect box, so the grid reflows as each photo loads. What it does not risk is a source-less <img>: a list member with no served url is dropped at bind time, so a thin gallery renders fewer tiles rather than broken ones, and render-invariant stays green either way. Nothing catches this one for you — the machinery is the whole reason to reach for the primitive here.

No per-item marker is missing here: a single-slot list is edited as one card, so the collection’s own marker on the Slot.List container is the whole of its identity. A record list is the other half of the grammar — its ItemBinding carries fields, each a field binding with a def, so a row’s image field renders through <Slot.Image slot={unit.fields.photo} /> exactly like a top-level slot, and SlotGroup marks the row. Which of the two you get is not declared anywhere: it follows from the declared item. See the list grammar.

selectable={false} — a copy that declines selection

Section titled “selectable={false} — a copy that declines selection”

Every primitive accepts selectable={false}, which renders the copy with full identity plus a non-selectable flag — for a copy the editor should never resolve a click into: a carousel clone, a decorative mirror. The copy still carries every marker and still receives live patches, so it never lags its selectable sibling; the flag only declines click, outline, and scroll targeting.

{/* the live, selectable copy */}
<Slot.Text slot={slot.headline} as="h1" />;
{/* a decorative duplicate elsewhere on the page — patched live, never selected */}
<Slot.Text slot={slot.headline} as="span" className="opacity-0 lg:opacity-100" selectable={false} />;

A flagged copy satisfies no coverage requirement — a slot still needs at least one selectable rendering.

By default, a blank slot still renders a marked, clickable empty element — that is what lets a user select an unfilled slot and type into it. There is no primitive prop for the empty state: every binding carries a derived present, and what a blank value looks like is your own markup branching on it.

What each primitive renders with nothing in it: Slot.Text / Slot.Number an empty element of whatever as names; Slot.Select, Slot.Url and Slot.Document the element with the attribute omitted, your children still inside it; Slot.Image and Slot.Video a marked role="img" placeholder — carrying the aspect box when the slot declares a frame, and a bare w-full div with no height when it does not; Slot.List the marked container with your own children inside it. Every one of them is marked, and none of them is an <img> with an empty src — see render-invariant.

Three tiers, in order of how much you are changing:

{/* 1. Do nothing — the primitive renders its marked empty element. */}
<Slot.Text slot={slot.subtitle} as="p" />
{/* 2. Change what fills it — one element, different children. */}
<Slot.Text slot={slot.price} as="span">
{slot.price.present ? `$${slot.price.value}` : "Price on request"}
</Slot.Text>
{/* 3. Change the element itself — a primitive in each arm. */}
{slot.brochure_link.present
? <Slot.Url slot={slot.brochure_link} as="a">Download the brochure</Slot.Url>
: <Slot.Url slot={slot.brochure_link} as="div">Brochure coming soon</Slot.Url>}

Rendering nothing at all is the same branch with null on one arm — {slot.x.present && <Slot.Text …/>}. Reach for it only where the unfilled state genuinely has nothing to show, such as a second, optional copy of a value that would otherwise leave an empty <p></p> in the layout. The slot’s primary rendering should stay selectable when empty, or a user can never discover it to fill it in the first place.

Collapse is the honest default, and the canvas is earned by size. The editor adds nothing to an empty element — no injected height, no placeholder chrome, no extra attribute — so an empty element your CSS gives no height collapses to nothing, exactly as it does on the published page. Something with no box receives no click and offers the selection outline nothing to draw, which makes that slot sidebar-only in the editor. Rendering an empty state with real size is the one way an empty slot becomes canvas-selectable: declare the slot’s frame so an image placeholder carries its aspect box, give a list container a minimum height, or fork to a sized placeholder at tier 3. Sidebar reachability never depends on any of it.

Dropping the marker in the blank branch is the one thing that fails a check. Each slot is rendered against a fixture with that slot emptied, and its marker must still be present in that rendering — a slot marked when filled and unmarked when blank is exactly what missing-slot-marker reports. A copy carrying selectable={false} does not satisfy it, empty or not.

Grouping is authored in two places that must agree. The schema declares the group as a group(...) card in its layout, which collapses that card’s members into one card in the editor sidebar:

import { defineSchema, group, text } from "@homepages/template-kit";
export const schema = defineSchema({
label: "Contact",
slots: {
agent_name: text.short({ label: "Agent name" }),
agent_email: text.short({ label: "Agent email" }),
agent_phone: text.short({ label: "Agent phone" }),
},
layout: [group("agent", "Agent", ["agent_name", ["agent_email", "agent_phone"]])],
});

layout is the authoring form; meta.groups is what it emits, and is the spelling a check message or a serialized schema shows you.

Renderer.tsx binds the declared groups once, alongside the slots, and marks the members’ shared container with SlotGroup — which makes the canvas outline and select them as the single unit matching that one card:

<SlotGroup slot={group.agent} as="div" className="flex flex-col gap-2">
<Slot.Text slot={slot.agent_name} as="h3" />
<Slot.Text slot={slot.agent_email} as="span" />
<Slot.Text slot={slot.agent_phone} as="span" />
</SlotGroup>

group.agent typechecks only where that card is declared in layout — a mistyped id is a compile error, not a marker that silently means nothing. The SlotGroup element must be an ancestor of every member’s marked element — the editor walks up with closest("[data-slot-group]"), so a marker that is not an ancestor is never found.

Constant Attribute Emitted by On
ATTR_SLOT_ID data-slot-id every value primitive, derived from its slot binding’s id the element rendering a slot’s value
ATTR_SLOT_TEXT_LEAF data-slot-text-leaf Slot.Text / Slot.Number on a raw render an element whose text is the slot’s value
ATTR_SLOT_ITEM data-slot-item SlotGroup over an ItemBinding, derived from its index the unit container of one list row
ATTR_SLOT_FIELD data-slot-field every value primitive, derived from a row field binding’s field one declared field of a record row — clicking it on the canvas selects that field and lands the caret in its sidebar editor
ATTR_SLOT_GROUP data-slot-group SlotGroup over a GroupBinding (via bindGroups) the container wrapping a group’s members
ATTR_SECTION_INSTANCE_ID data-section-instance-id the section root — written by the platform, not by your markup

There is no escape hatch: a primitive’s marker is derived from its binding and cannot be emitted any other way — a primitive can BE whatever element the markup needs (as accepts any element, with that element’s own props), which is what makes a hand-written marker unnecessary. Changing what an attribute means is a platform contract break, so these names are stable across patch and minor releases of the kit; a rename is exactly the kind of breaking change that ships as a major bump (see Versioning).

A slot may be rendered as many times as the design needs, and every visible copy is a live editing target — bind and mark each one.

Responsive pairs. A mobile card and a desktop table row rendering the same value, one of them hidden at each breakpoint:

<Slot.Text slot={slot.price} as="span" className="sm:hidden" />;
<Slot.Text slot={slot.price} as="span" className="hidden sm:inline" />;

Preview, then player. A silent looping teaser that a click replaces with the real controls player — one slot, two Slot.Video, both selectable:

<Slot.Video slot={slot.tour} preview />;
<Slot.Video slot={slot.tour} />;

Still, then player. A play-tile in an overview card and the player further down the page — Slot.Image over the same video slot, so the thumbnail is always a frame of the video the user actually picked:

<Slot.Image slot={slot.tour} className="rounded-lg" />;
<Slot.Video slot={slot.tour} />;

A binding is plain JSON, so an island receives one directly as an ordinary prop — no context provider, no hand-serialized payload, and the island itself never needs to import schema or call bindSlots:

// Renderer.tsx — server-rendered, already holds the binding
return <UnitTable slot={slot.units} />;
"use client";
import { bindItems, Slot, SlotGroup } from "@homepages/template-kit";
import type { SlotBinding } from "@homepages/template-kit";
import type { schema } from "./schema";
export function UnitTable({ slot }: { slot: SlotBinding<typeof schema.slots.units> }) {
return (
<Slot.List slot={slot} as="ul">
{bindItems(slot).map((unit) => (
<SlotGroup slot={unit} as="li" key={unit.index}>
<Slot.Text slot={unit.fields.unit_label} as="span" />
</SlotGroup>
))}
</Slot.List>
);
}

The island’s own schema import stays import type (erased at build time, costing the bundle nothing) even though the binding it receives was built from the value on the server side. See Islands for the full props contract.

return (
<Section id={nav.selfAnchor} className="bg-surface py-16">
</Section>
);

Renders <section class="tr-section"> and merges any className you pass. as picks a different element from section | div | article | header | footer | aside; every other prop is a normal HTML attribute.

.tr-section (from base.css) is full-bleed by contract: 100% width and zero margin, so consecutive sections butt edge-to-edge and a page composes as a vertical stack with no framework-injected whitespace. Vertical separation is the section’s own job — a background and padding on this root or a child, never a margin that would re-open gaps between sections; section-root-margin grades that from the rendered page.

A centered, max-width well is plain JSX on a child:

<div className="w-full max-w-[var(--tr-container-max)] mx-auto px-[var(--tr-container-pad)]">
  • The marker contract — the attribute constants, and what the kit does not dictate about your DOM.
  • Values and emptiness — why the binding is the one value path, and who owns formatting and the empty state.
  • The schema — the declaration each binding carries on its def.
  • Renderer.tsx — where bindSlots is called.
  • missing-slot-marker — the gate that proves marker coverage from the rendered page.