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
(
aspicks 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.
Binding a section: bindSlots
Section titled “Binding a section: bindSlots”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 — iterating a list
Section titled “bindItems — iterating a list”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.
Committing to a rendition
Section titled “Committing to a rendition”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
ItemBindingcarries nodef, soframeis passed at the call site from a constsection.tsexports. SeeSlot.List. - A raster the template itself ships —
import photo from "./photo.jpg"yields anAuthorImage, whichvaluealso accepts.altis required on that form, since anAuthorImagehas 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 — the provider embed
Section titled “Slot.Tour — the provider embed”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 — the attached file
Section titled “Slot.Document — the attached file”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 — collections
Section titled “Slot.List — collections”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 list of images: the raw Image primitive
Section titled “A list of images: the raw Image primitive”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.
The empty state
Section titled “The empty state”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.
SlotGroup + bindGroups
Section titled “SlotGroup + bindGroups”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.
The marker attributes
Section titled “The marker attributes”| 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).
One slot, several places
Section titled “One slot, several places”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} />;Islands
Section titled “Islands”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 bindingreturn <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.
Section — the section root
Section titled “Section — the section root”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)]">See also
Section titled “See also”- 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— wherebindSlotsis called.missing-slot-marker— the gate that proves marker coverage from the rendered page.