Skip to content
HomePagesHomePages template kit

Crop an image slot

Add an image slot the editor crops to a fixed ratio.

Add an image slot the editor crops to a fixed ratio.

  • A section to add the slot to — Install scaffolds one.
  • Its fixtures.ts built from a scenario, so the photo id resolves — Pick a fixture scenario.

Starting from a section you already have, add an image slot — a crop config, the decision that fills it, an <Image> in the Renderer, and a fixture value.

Pick the builder for the kind you want and pass only that kind’s options: image() takes a crop, and a text slot does not, so a misplaced option is a compile error rather than something check finds later.

Add image and imageAssign to the section’s existing kit import — the scaffolded file takes only defineSchema, text and textBlock, so both are missing until you do.

The slot and the decision that fills it are one declaration, in one file (Sections and declarations):

// section.ts — an image slot, own aspect, in `defineSchema`'s `slots`
interior_image: image({
label: "Interior image",
crop: { mode: "locked", aspect: 4 / 3, aspectLabel: "4:3" },
fill: imageAssign({
pick: "Pick a bright, decluttered interior shot.",
}),
}),

A slot is editable by default, so nothing is written out: locked: true is what takes a slot away from the user.

The slot is the whole binding. Its key mints the decision’s id and its produces — so neither is yours to write or keep in agreement. imageAssign is the builder for an image slot; giving this slot a textBlock instead is a compile error, not a check finding.

// Renderer.tsx — a Slot.Image usage, own aspect
<Slot.Image slot={slot.interior_image} className="w-full" />

Slot.Image reads url, alt, responsive and mobile off the binding’s value, takes the slot’s own declared frame off its def, and owns the marker — so this call site states neither the geometry nor the marker, and cannot drift from what section.ts says.

Last, the fixture value. Add media and scenarios to the fixture file’s existing import from @homepages/template-kit/fixtures — the scaffolded file takes only defineFixtures, so they are missing until you do.

// fixtures.ts — the photo's asset id, exactly as a saved page states it
interior_image: media.photo(scenarios.luxuryMultiUnit.photos[3]!.id),

A fixture states the asset reference and nothing else: the served url, the alt fallback and the responsive ladder are all attached when the fixture loads — see Media is stated by id.

An image slot supports two independent geometry configs — use one, not both, per slot:

  • crop — steers the editor’s cropper only: mode: "locked" fixes an aspect (w/h) and shows the aspectLabel pill; mode: "free" lets the user resize any edge. This is what the steps above use.
  • frame — a per-breakpoint aspect (desktop/mobile, switching at a declared breakpoint) that is the single source of truth for BOTH the cropper’s target ratio and the Renderer’s own layout box (imported as a shared constant on both sides — see the schema declaration). Reach for frame instead of crop when the section’s markup itself needs to know the ratio (e.g. to size a wrapper), not just the editor.

frame.breakpoint is one thing only: the viewport width at which the crop switches from the mobile aspect to the desktop one. It is a <picture> switch over which cropped rendition is served, and it means nothing else.

In particular it carries no obligation to match your layout’s own breakpoints. A section whose columns reflow at lg (1024px) may perfectly well switch its hero crop at 425px — the two answer different questions (“when does this grid stack?” versus “when is the tall crop the better picture?”) and picking one number for both is a coincidence, not a rule. Choose each on its own merits and don’t chase agreement between them.

Either way, framing is baked into the served asset — the Renderer never applies an aspect ratio to the pixels itself; it only reads url, alt, responsive (and mobile) off the slot’s ImageValue.

Three additions across the section folder’s three files — section.ts (the slot, carrying the decision that produces it), Renderer.tsx, fixtures.ts — plus image and imageAssign on section.ts’s existing kit import and media and scenarios on the fixture file’s:

// section.ts — in `defineSchema`'s `slots`
interior_image: image({
label: "Interior image",
crop: { mode: "locked", aspect: 4 / 3, aspectLabel: "4:3" },
fill: imageAssign({
pick: "Pick a bright, decluttered interior shot.",
}),
}),
// Renderer.tsx — wherever the image belongs in the markup
<Slot.Image slot={slot.interior_image} className="w-full" />
// fixtures.ts — in the authored-slots object
interior_image: media.photo(scenarios.luxuryMultiUnit.photos[3]!.id),

Verify with the author loop.

  • schema-invalid — the crop config says mode: "locked" and states no aspect, which is the one shape the cropper cannot act on.
  • missing-slot-marker — the new editable slot never reaches the rendered DOM with its marker.
  • sidebar-order — the slot is declared in one position in the schema and rendered in another.
  • render-invariant — the fixture value resolves to no url, so the section renders a src-less <img>.
  • Renderer.tsxImageValue, the fields a Renderer reads vs. only carries, and frame.
  • Slot primitives — the Image primitive’s full prop table.