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 explorerService doc list; empty picks show all items by order.
  • Embed / raw-HTML modecodeBlock and cta (in embed mode) render pasted markup verbatim, including <script> tags. Trusted sources only.
  • Auto-fetchserviceGrid and psiScores ignore 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 and cta are 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.

TypeSchema namePurpose & notable behavior
heroheroSectionAbove-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.
heroExplorerheroExplorerSectionHero 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).
aboutaboutSectionFounder 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.
codeBlockcodeBlockSectionRaw HTML / embed section for iframes (YouTube, Loom, Codepen), form embeds, maps, scripts, any third-party widget. Pasted code renders as-is, <script> tags included.
contentImagecontentImageSection50/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.
ctactaSectionClosing 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.
deliverablesdeliverablesSectionBulleted "what you get" outcomes. Optional eyebrow, headline (asterisk accents), description.
faqfaqSectionQuestion + answer accordion. Answers are plain text — service-level FAQs use rich text instead.
logoWalllogoWallSectionStrip of client logos. Manual picks, or fallback to all client docs flagged featured.
marqueemarqueeSectionScrolling 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.
psiScorespsiScoresSection50/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.
pricingCalloutpricingCalloutSectionSingle pricing block with starting price, duration, and a note. Used on service detail pages.
pricingpricingSectionUp 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.
processprocessSectionUp to 5 numbered steps explaining how an engagement runs. Headline asterisk accents.
receiptsreceiptsSectionForest-band quantified outcomes. Up to 4 stats; each has a value, optional unit, label, and optional sub-line.
richTextrichTextSectionLong-form prose for legal pages, policies, and deep-dive copy — the full rich-text editor. Three layout variants (see below).
serviceGridserviceGridSectionGrid of service cards, auto-populated from published service docs sorted by their order field. No manual content.
servicesExplorerservicesExplorerSectionInteractive 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.
stickyScrollstickyScrollSectionA 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).
testimonialstestimonialsSectionQuote blocks. Manual picks, or fallback to testimonials flagged featured.
workworkSectionCase-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:

SectionEmpty-picks fallback
logoWallSectionall client docs flagged featured
testimonialsSectiontestimonials flagged featured
workSectionmost 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 separate headingHighlight field 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

ConcernPath
Section registry (source of truth)studio/schemaTypes/sections/registry.ts
Schema barrel (allSectionTypes)studio/schemaTypes/sections/index.ts
One schema per sectionstudio/schemaTypes/sections/*.ts
Astro componentswebsite/src/components/sections/*Section.astro
Dispatchwebsite/src/components/SectionRenderer.astro
Discriminated unionwebsite/src/sanity/types.ts (SectionData)
Three-way contract testwebsite/src/components/sectionTypes.test.ts
Shared meta-field factorystudio/lib/section-fields.ts (sectionLayoutField)
Comparison-slider fieldsstudio/lib/comparison-fields.ts
Default copystudio/lib/section-defaults.ts
Reference / fallback projectionswebsite/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").

Previous
Page builder overview