Page Builder
Section types
The page builder draws from a fixed menu of 21 section types — each one a Sanity object schema paired with an Astro component — and this page is the catalog of what every type does and the behavior worth knowing before you reach for it.
If you want the dispatch mechanics (fetch → SectionRenderer → _type switch) or how a section joins the scroll-in cascade, see Page builder. If you want the step-by-step recipe for wiring a brand-new type, see Adding a section. This page stays focused on the what: one row per section, plus the gotchas that bite.
The mental model
Every section is a defineType({type: 'object', name: '<name>Section', …}) in studio/schemaTypes/sections/, registered once in registry.ts as a defineArrayMember. That single sectionMembers list is the source of truth for which types editors can drop into a sections[] array, and it flows into every page-builder consumer: homepage.sections[], page.sections[], service.sections[], portfolioPost.sections[], and the two index singletons' sectionsBelow[].
Schema names are camelCase with a Section suffix (heroSection, faqSection); the short labels in this catalog (hero, faq) are just the prefix. Each name maps 1:1 to a *Section.astro component in website/src/components/sections/ and a discriminated-union variant in website/src/sanity/types.ts.
A few cross-cutting traits show up repeatedly in the catalog below:
- Reference fallback — sections that hold arrays of references (
work,logoWall,testimonials) auto-fall-back to featured/recent documents when the editor leaves the picks empty. The fallback is resolved in the website's GROQ projections, not in the schema. - Explorer fallback — the two explorer sections pull their items from the shared
explorerServicedoc list; empty picks show all items byorder. - Embed / raw-HTML mode —
codeBlockandcta(in embed mode) render pasted markup verbatim, including<script>tags. Trusted sources only. - Auto-fetch —
serviceGridandpsiScoresignore manual content entirely; their data comes from published docs / a weekly cron. *asterisk*accents — most headline fields wrap accent words in*asterisks*, rendered gold on dark surfaces, forest-green on cream. Heroes andctaare the exceptions (they carry their own accent mechanics).
The catalog
The list below is in registry order. Each entry gives the schema name, the Studio title, the one-line purpose from the schema's own description, and notable behavior.
| Type | Schema name | Purpose & notable behavior |
|---|---|---|
| hero | heroSection | Above-the-fold hero: headline, supporting copy, up to 3 trust stats, CTA. Two separate animate toggles — headingAnimation (the GSAP 3D word-unfold + colour-ignite + gold rule) and animate (the supporting copy/CTA cascade). Headline accents use inline <span class="text-forest"> / <span class="heading-underline"> HTML, not asterisks. |
| heroExplorer | heroExplorerSection | Hero band fused with the interactive services explorer: H1 + CTA + a swapping panel + checkbox chips. Each chip swaps the panel copy and the right-half background image. Chips come from the shared Service Explorer list; selections forward to the CTA as a HubSpot multi-checkbox query param (see below). |
| about | aboutSection | Founder bio + portrait. Heading, body, image, and the signature line (name + title) are editable per instance. The one cream accent panel by default ({mode: 'panel', background: 'paper'}); body falls back to a gradient placeholder with author initials when the portrait is empty. |
| codeBlock | codeBlockSection | Raw HTML / embed section for iframes (YouTube, Loom, Codepen), form embeds, maps, scripts, any third-party widget. Pasted code renders as-is, <script> tags included. |
| contentImage | contentImageSection | 50/50 side-by-side: rich text + optional button in one column, image in the other. Stacks below lg. Configurable image side, stacked above/below order, and per-breakpoint hide. Can swap the single image for a before/after comparison slider. |
| cta | ctaSection | Closing call-to-action band. twoColumnLayout reveals an aside; embedMode switches that aside from an info-rows table to a raw form embed. Heading splits into a plain part + a gold-underlined headingHighlight. headingLevel picks H1 vs H2. Background can be parallax mesh-gradient artwork or a flat solid color. |
| deliverables | deliverablesSection | Bulleted "what you get" outcomes. Optional eyebrow, headline (asterisk accents), description. |
| faq | faqSection | Question + answer accordion. Answers are plain text — service-level FAQs use rich text instead. |
| logoWall | logoWallSection | Strip of client logos. Manual picks, or fallback to all client docs flagged featured. |
| marquee | marqueeSection | Scrolling claims band on the Ink background; mark items highlighted to render in gold. No Styles field — it has no outer band, so per-section background/spacing controls and the animate toggle do not apply. |
| psiScores | psiScoresSection | 50/50 side-by-side: rich text + optional button in one column, this site's four live Google PageSpeed (mobile) scores as radial dials in the other. Scores auto-update weekly and are NOT editable in Studio. Stacks below lg. |
| pricingCallout | pricingCalloutSection | Single pricing block with starting price, duration, and a note. Used on service detail pages. |
| pricing | pricingSection | Up to 3 pricing tiers. Mark exactly one as featured for the Ink + gold-border treatment. On reveal, outer tiers slide from their own side and the middle rises. |
| process | processSection | Up to 5 numbered steps explaining how an engagement runs. Headline asterisk accents. |
| receipts | receiptsSection | Forest-band quantified outcomes. Up to 4 stats; each has a value, optional unit, label, and optional sub-line. |
| richText | richTextSection | Long-form prose for legal pages, policies, and deep-dive copy — the full rich-text editor. Three layout variants (see below). |
| serviceGrid | serviceGridSection | Grid of service cards, auto-populated from published service docs sorted by their order field. No manual content. |
| servicesExplorer | servicesExplorerSection | Interactive accordion: the left image swaps to the open item; each item's label is the header and its description is the revealed body. Items come from the shared Service Explorer list; visitor selections forward to the CTA as a HubSpot multi-checkbox query param. |
| stickyScroll | stickyScrollSection | A list of content + image groups. On desktop the image column pins while text scrolls, crossfading per group; on mobile it stacks into standard pairs. Has the only per-section reveal-timing control (revealTrigger). |
| testimonials | testimonialsSection | Quote blocks. Manual picks, or fallback to testimonials flagged featured. |
| work | workSection | Case-study cards. Manual picks, or fallback to the most recent case studies. On a service page, falls back to that service's relatedCaseStudies instead. |
Two sections opt out of the Styles lever
marqueeSection and servicesExplorerSection declare only a styles group (no sectionLayoutField()), because they have no panel/full-bleed band to control. Both heroes, ctaSection, and stickyScrollSection are also always-full-bleed and skip the sectionLayout Display lever. See Page builder for how the lever threads through SectionShell.
Variants & modes worth knowing
richText layout variants
richTextSection.variant is a three-way picker that changes only the prose treatment:
options: {
list: [
{title: 'Prose (centered)', value: 'prose'},
{title: 'TOC sidebar', value: 'tocSidebar'},
{title: 'Editorial (lede)', value: 'editorial'},
],
}
- Prose — centered ~65ch column (default).
- TOC sidebar — sticky table-of-contents auto-built from the body's H2s.
- Editorial — wider column with a display-font lede paragraph.
cta two-column + embed modes
ctaSection starts as a centered single column. Toggle twoColumnLayout to put the heading/body/CTA on the left and reveal an aside on the right. The aside then has two modes via embedMode:
- Off — info-rows table + email button.
- On — a raw HTML/JS form embed (HubSpot, Tally, etc.), rendered as-is.
A custom validation rule warns when embed mode is on but no embed code is pasted, so the aside doesn't silently render empty:
if (parent?.twoColumnLayout && parent?.embedMode && !value) {
return 'Embed mode is on but no embed code is pasted — the aside will render empty.'
}
contentImage comparison slider
contentImageSection mixes in the shared comparisonFields() factory. When comparisonEnable is on, the single image field is hidden and a before/after slider takes its place:
hidden: ({parent}) => parent?.comparisonEnable === true,
Both comparison images must be present before the website renders the slider. The same comparisonFields() factory is reused by stickyScrollSection, where each pinned group can be a single image or a comparison module.
Reference fallbacks
Three sections accept reference arrays and fall back when the picks are empty:
| Section | Empty-picks fallback |
|---|---|
logoWallSection | all client docs flagged featured |
testimonialsSection | testimonials flagged featured |
workSection | most recent case studies (or a service's relatedCaseStudies on a service page) |
This makes featured: true on client and testimonial docs load-bearing — it's what drives the no-pick fallback. The fallback lists are resolved in website/src/sanity/queries/projections.ts (featuredClientsProjection, featuredTestimonialsProjection, recentCaseStudiesProjection) and passed into SectionRenderer as the clients / testimonials / caseStudies props. See Querying content.
Explorer item resolution
heroExplorerSection and servicesExplorerSection both pull from the canonical explorerService doc list ("Service Explorer" in Studio). Their services array is optional manual picks:
Leave empty to show all Service Explorer items by their order.
Each item's chip/accordion label, description, and image live on the explorerService document, not on the section. In servicesExplorer, the first item is open by default and drives the left image.
Gotchas
Embed and raw-HTML sections render pasted code verbatim
codeBlockSection.code and ctaSection.embedCode (in embed mode) are injected with Astro's set:html and run any <script> tags they contain. Only paste from trusted sources. On the website side the embed string is run through stegaClean() before set:html so draft-mode stega characters don't corrupt the markup — never skip that clean.
Stega breaks string equality on Sanity string fields
In draft mode, Sanity string fields carry trailing zero-width characters, so a raw value === 'right' comparison silently fails. Several sections gate layout on a stega-sensitive string and must stegaClean() it first — e.g. contentImage/stickyScroll imagePosition, psiScores scoresPosition, and stickyScroll revealTrigger. Always clean a Sanity string before any logic comparison.
PSI scores are not editable — they auto-refresh weekly
psiScoresSection shows the site's four live Google PageSpeed mobile scores, fetched by a weekly cron. The schema description says it explicitly: the scores are NOT editable here. Don't add editable score fields or try to override them in Studio.
marquee has no band, so band-level controls don't apply
marqueeSection ships without a styles group, without sectionLayoutField(), and without an animate boolean. It renders directly on the Ink background with no outer panel — so per-section background, spacing, and scroll-in animation controls have nothing to attach to. The marquee never animates in.
Mark exactly one pricing tier featured
pricingSection allows up to 3 tiers; mark exactly one as featured to get the Ink + gold-border highlight treatment. Featuring zero or several muddies the visual hierarchy.
HubSpot query-param contract (both explorers)
When a visitor selects chips/services, the explorer CTA appends them to its destination URL as a semicolon-separated multi-checkbox value:
/contact?interested_services=web-design;seo
The queryParam field (default interested_services) must equal the internal name of the HubSpot form's multi-checkbox property for pre-fill to work, and each value must match an explorerService value slug / HubSpot property option. See HubSpot & lead capture.
Heading accent authoring
Most headline fields use *asterisks* for accent words (rendered gold on dark, forest-green on cream by the website's renderAccentHeading helper). The exceptions author accents differently and must stay that way:
heroSection/heroExplorerSection— inline HTML spans (text-forest,heading-underline) tied to the GSAP headline contract.ctaSection— a separateheadingHighlightfield rendered with the gold underline.
See Typography and Design tokens for the accent color system.
Adding to the catalog
Adding a new section type touches both packages and the registry is the hinge. In short: create the *Section.astro component + Studio schema, register it in index.ts and registry.ts, add a case branch to SectionRenderer.astro, and append a variant to SectionData in website/src/sanity/types.ts. A sectionTypes.test.ts enforces the three-way contract (registry ↔ renderer ↔ union) so a forgotten branch fails CI. The full checklist lives in Adding a section.
Where this lives
| Concern | Path |
|---|---|
| Section registry (source of truth) | studio/schemaTypes/sections/registry.ts |
Schema barrel (allSectionTypes) | studio/schemaTypes/sections/index.ts |
| One schema per section | studio/schemaTypes/sections/*.ts |
| Astro components | website/src/components/sections/*Section.astro |
| Dispatch | website/src/components/SectionRenderer.astro |
| Discriminated union | website/src/sanity/types.ts (SectionData) |
| Three-way contract test | website/src/components/sectionTypes.test.ts |
| Shared meta-field factory | studio/lib/section-fields.ts (sectionLayoutField) |
| Comparison-slider fields | studio/lib/comparison-fields.ts |
| Default copy | studio/lib/section-defaults.ts |
| Reference / fallback projections | website/src/sanity/queries/projections.ts |
The page-builder pattern overview and the per-section anatomy notes also live in studio/CLAUDE.md ("Page-builder pattern") and website/CLAUDE.md ("Page-builder rendering flow").