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).
  • [text](url) inline links — the two plain-text body fields that aren't rich text — pricingCallout.reassurance and faqSection.answer — accept markdown-style links, converted by the website's renderInlineLinks helper. See Inline link authoring below.

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: optional eyebrow, headline, supporting copy, up to 3 trust stats, CTA. The eyebrow is the standard mono label (gold on the dark hero) and hides when blank. Two separate animate toggles — headingAnimation (the GSAP 3D word-unfold + colour-ignite + gold rule) and animate (the eyebrow/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. body is optional — clear it and the paragraph doesn't render. Background is parallax mesh-gradient artwork or a solid color with a live alpha slider; padding overrides the card's inner spacing per side, per breakpoint (both below).
deliverablesdeliverablesSectionBulleted "what you get" outcomes. Optional eyebrow, headline (asterisk accents), description.
faqfaqSectionQuestion + answer accordion. Answers are plain text but accept inline [text](url) links (see Inline link authoring) — service-level FAQs use full 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. The reassurance line under the CTA accepts inline [text](url) links (see Inline link authoring). 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.'
}

Only the heading is required. body carries a .max(400) cap but no .required(), and the component guards it with {body && …} — clearing the field removes the paragraph and closes the gap, rather than leaving an empty <p>.

cta background & card padding

Two Styles-group controls shape the card itself.

Background. backgroundType picks image (the parallax mesh-gradient artwork, or a custom upload that replaces it) or color (a flat band, no artwork). The color field sets no disableAlpha, so the alpha slider is live:

  • Alpha 1 — an opaque band.
  • Anything between — the page's grid backdrop shows through the card.
  • Alpha 0 — no background at all. The card keeps its rounded shape and padding, and both the gradient class and the parallax layer drop out, because any picked color — even a fully transparent one — sets solidBg.

That last row is why there's no separate "none" option on backgroundType: alpha 0 already is one. Translucency round-trips through the same toCssColor() helper the aside's glass colors use, which emits an 8-digit hex whenever alpha < 1.

Card padding. The padding object overrides the space between the card edge and its content, in px, per side and per breakpoint:

padding: {
  mobile:  {top?, right?, bottom?, left?},   // below 1024px
  desktop: {top?, right?, bottom?, left?},   // 1024px and up
}

It applies in both one- and two-column layouts — the same element carries the grid classes in 2-col. The field has no initialValue and no section-defaults.ts entry: blank means "design default," so every existing CTA renders unchanged and there's no seed script to run. The defaults live as var() fallbacks in the component's <style> block, restating the utilities the section originally shipped with:

.cta-inner {
  padding-top: var(--cta-pt, 6rem);      /* py-24 */
  padding-right: var(--cta-pr, 1.5rem);  /* px-6  */
  /* …bottom, left */
}
@media (min-width: 64rem) {
  .cta-inner {
    padding-top: var(--cta-lg-pt, var(--cta-pt, 8rem));      /* lg:py-32 */
    padding-right: var(--cta-lg-pr, var(--cta-pr, 3rem));    /* lg:px-12 */
    /* …bottom, left */
  }
}

The three-level fallback is the contract worth remembering: desktop resolves --cta-lg-*--cta-* → default, so filling only the mobile row applies that value at every width — mobile-first, the same as a lg: override you never wrote. Fill a desktop box only to diverge above 1024px.

0 is a real padding value, and numbers carry no stega

paddingVars() in CtaSection.astro tests typeof value === 'number', not truthiness — 0 is a legitimate "flush to the edge" override that a truthy check would silently drop. And unlike the section's string fields, numbers carry no stega payload (only strings do), so these need no stegaClean() before use.

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.

Two plain-text body fields — pricingCallout.reassurance and faqSection.answer — support inline hyperlinks (and only hyperlinks; they deliberately aren't rich text). Author a link with markdown-style syntax:

Reply within one business day — or [book a call](/contact) now.

The sibling of the *asterisk* accent convention: the website helper renderInlineLinks (website/src/lib/inlineLinks.ts) converts each [text](url) run into an <a> carrying the project's gold-underline link treatment (forest-green on cream), and the field is rendered with set:html — in PricingCalloutSection.astro and FaqItem.astro respectively. Notes:

  • External vs internal — an absolute https:// (or http://) URL opens in a new tab (target="_blank" rel="noopener noreferrer"); relative paths (/contact), #anchors, mailto:, and tel: stay in the same tab. There's no per-link "new tab" toggle — it's inferred from the URL.
  • reassurance length — the field's .max(120) is advisory (a warning, not a publish blocker) because the URL text counts toward the limit; keep the visible line short.
  • FAQ structured data — the faqSection answer also feeds the page's FAQPage JSON-LD. The markup is stripped to plain text by stripInlineLinks (defined in website/src/lib/inlineLinks.ts), called from faqPageSchema in website/src/lib/schema.ts, so [text](url) never leaks into acceptedAnswer.text.
  • URL limitation — the URL can't contain a literal ) (the parser stops at the first one) or whitespace. For a URL with parentheses (e.g. a Wikipedia article), percent-encode them: %28 for ( and %29 for ).
  • Draft preview — only the captured URL is stegaClean'd before it enters href (invisible draft-mode characters would break the link); the link text keeps its stega so Presentation Tool click-to-edit still binds to it.
  • Unsafe schemes — a javascript:/data: URL is dropped (the visible label renders as plain text), since the field is injected with set:html.

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