Skip to Content
DevelopersBuildingSection settings and SectionFrame

6 min read

Section settings and SectionFrame

Every section a merchant can add — the library sections in packages/blocks/src/sections/library.tsx and packages/blocks/src/sections/modern.tsx, and the frame-wrapped blocks in packages/blocks/src/puck-config.tsx (ProductGrid, FeaturedProducts, CollectionGrid, RichText, BlogPosts) — carries one settings field: layout, spacing, style and advanced options, defined once in packages/blocks/src/section-settings.ts and rendered by a shared SectionFrame wrapper (packages/blocks/src/components/section-frame.tsx).

The schema (section-settings.ts)

SectionSettingsValue has four groups:

type SectionSettingsValue = { layout: { contentWidth: 'narrow' | 'normal' | 'wide' | 'full'; alignment: 'left' | 'center' | 'right'; gap: number } spacing: { paddingTop: { mobile: number; tablet: number; desktop: number } paddingBottom: { mobile: number; tablet: number; desktop: number } } style: { colorScheme: string backgroundColor: string backgroundImage: { url: string; alt: string } overlayOpacity: number cornerRadius: 'none' | 'sm' | 'md' | 'lg' border: boolean shadow: 'none' | 'sm' | 'md' } advanced: { showOnMobile: boolean showOnDesktop: boolean entranceAnimation: 'none' | 'fade' | 'slide-up' | 'zoom' anchorId: string } }

sectionSettingsField(schemeOptions) returns the Puck Fields entry for it — one top-level settings object with one nested object per group. The editor recognises that shape (isSectionSettingsField in apps/admin/src/lib/customize-field-shape.ts) and draws it with section-settings-groups.tsx: a divider and a “Section settings” heading under the section’s own fields, then one collapsible row per group with its current values summarised on the right (settingsGroupSummary in apps/admin/src/lib/customize-settings-summary.ts — “Normal · Center”, “48 / 64”, “Mobile only · Fade”). A { mobile, tablet, desktop } number object (the padding) renders as one three-up row (responsive-number-field.tsx). Nothing about the schema itself changed, so any section that adopts it gets this panel for free.

A section wires it in by adding settings: sectionSettingsField(SCHEMES) to its fields, and settings: defaultSectionSettings(...) to its defaultProps. Library sections don’t do this by hand: framed() in packages/blocks/src/sections/framed.tsx (used by library.tsx and the newer modern.tsx) adds both, and wraps the section’s render in SectionFrame.

Three kinds of settings

sectionSettingsField(schemes, kind) takes a SettingsKind. The value type is always the full SectionSettingsValue; the kind only decides which groups the editor offers.

KindOffersUsed by
section (default)Layout, Spacing, Style, AdvancedEverything drawn inside the shared frame: the library and kit sections, Zero Drift sections, Featured products, Collection grid, Rich text
bleedSpacing, and in Advanced only show on mobile / desktop and the anchorFull-bleed sections that draw their own frame: the Hero carousel and the Announcement bar (width, alignment, background and entrance motion are theirs), and Header and Footer
flowbleed plus the colour schemeSections the storefront draws for the page: the main sections (product, collection, page, blog, article, cart, search, not found), Related products, Recently viewed

A bleed section puts the result on its own outermost .section-frame element with bleedFrameProps(settings) (padding, visibility classes, anchor id; the hero passes it as HeroCarousel’s frameProps). A flow section is wrapped in a frame in dynamic.tsx only when the settings change something: isNeutralFlow(settings) (no padding, scheme 1, shown everywhere, no anchor) draws the storefront’s output with no frame at all, so pages saved before this existed render byte for byte as before. FLOW_SETTINGS holds the neutral defaults.

migrateSectionSettings(props, fallback) takes the section’s own default settings as fallback for documents saved before it had settings; normalizeDoc passes defaultProps.settings, so the panel shows the padding the section really renders with (not the generic 48 px).

Deliberately without settings, listed with reasons in test/section-settings-coverage.test.tsx: the three overlays that float over the page (Signup popup, Sticky add to cart, Mobile dock), the Cookie banner, and imported Liquid sections (they use their own theme’s schema). Layout blocks and elements keep their own style panel. That test also checks that every other section declares settings and applies them (the anchor reaches its HTML).

The editor recognises any of the three shapes: isSectionSettingsField accepts settings that have spacing and advanced, so a bleed or flow field gets the Section settings panel in the Advanced tab instead of falling back to a plain object field in Basic.

Background colours are theme names

style.backgroundColor stores a theme colour by name (SECTION_BACKGROUND_TOKENS: ink, brand, background, surface, accent, mapped to var(--color-…)) or a custom colour. sectionBackground(value) resolves it, with safeColour still checking custom values. Because none of the names is a CSS colour, they cannot clash with a typed colour, and a named colour follows the colour scheme and dark mode.

Link and image fields need no special type. Use the { label, href } object shape (linkField() in framed.tsx), or a text field named href or labelled “Link”, and the editor shows its link picker; use { url, alt } (imageField()) and it shows the image picker. Both store small, plain values that blocks render directly: an href string (plus an optional newTab on link objects) and { url, alt, focal? }. The exact shapes, and how blocks apply newTab and focal, are in Theme editor. Media URLs (“Image URL”, “Video link”) on their own stay plain text fields.

Backward compatibility: migrateSectionSettings

Sections used to carry three flat props — scheme, padTop, padBottom — instead of a settings object. migrateSectionSettings(props) is the one pure function that bridges old and new:

  • Given legacy flat props and no settings, it builds a full SectionSettingsValue from them: scheme → style.colorScheme, padTop/padBottom → every breakpoint of spacing.paddingTop/paddingBottom (the same value at mobile, tablet and desktop — old sections had no responsive padding, so migrating never invents a difference between breakpoints that didn’t exist before). Everything else gets defaultSectionSettings()’s neutral defaults (contentWidth: 'normal', no background, no animation, and so on) so the section renders exactly as it did before.
  • Given a settings object already (a section saved after this schema shipped), it deep-merges that onto the defaults — filling in any group a future field might add, without touching what is already set.
  • Never throws. Malformed or missing input always falls back to defaultSectionSettings().

It’s called in two places, both load-bearing:

  1. apps/admin/src/lib/customize-doc.ts’s normalizeDoc — migrates each section’s raw stored props (before Puck’s own defaultProps merge) so the Field panel edits real migrated values, not blanks. Raw stored props matter here: running migration on the post-merge object risks reading defaultProps.settings (the fresh default) instead of a doc’s real old scheme/padding.
  2. Inside every section’s own render() — the shared framed() helper and each puck-config.tsx block call migrateSectionSettings(props) right before rendering <SectionFrame settings={...}>. This is what makes the storefront correct too: apps/storefront/src/lib/template-render.tsx calls a section’s render() directly ({...defaultProps, ...item.props}), the same shape Puck itself builds internally — it never goes through normalizeDoc, which is admin-only. Migrating at render time means both paths — the admin’s live Puck instance and the storefront’s direct call — see the same correct settings regardless of whether the stored doc has been through normalizeDoc yet.

SectionFrame

SectionFrame (packages/blocks/src/components/section-frame.tsx) still accepts its original scheme/padTop/padBottom props for callers that never adopted the schema (a few puck-config.tsx blocks, the storefront preview’s placeholder sections in apps/admin/src/lib/customize-preview.tsx) — that legacy path is unchanged.

Passing settings instead turns on the full schema:

  • Layout — full width renders children with no .page-width wrapper; narrow/normal/wide set a max-width on it. alignment sets text/flex alignment; gap (0–16, quarter-rem steps) becomes the content wrapper’s flex gap.
  • Spacing — writes --section-pt-mobile/tablet/desktop and --section-pb-* custom properties; packages/blocks/src/styles/base.css has the three breakpoint rules that read them, each falling back to the previous breakpoint so a section with only a mobile value set still gets sane tablet/desktop padding.
  • Style — colour scheme class, an optional inline background colour, a background image layer with a black overlay at overlayOpacity, corner radius and shadow as Tailwind classes, border as a token-based border-border.
  • Advanced — showOnMobile/showOnDesktop become max-md:hidden/md:hidden (pure CSS, no client JS, no layout shift since the hidden class applies at first paint). anchorId becomes the section’s id. With entranceAnimation: 'none' (the default) SectionFrame draws the wrapper <div> itself, so a plain section adds no client component to hydrate. Any other value renders through SectionReveal (packages/blocks/src/components/section-reveal.tsx): the server HTML is always visible, and only a section still below the fold once Motion has loaded (fetched after hydration, see Storefront performance) is hidden and then played in with inView. Reduced motion renders it plain and visible.

Testing

packages/blocks/test/section-settings.test.ts covers: defaults reproducing today’s output, the legacy-props migration (including that it never invents a responsive difference), a partial/future settings object getting filled in, malformed input never throwing, the hidden-on-mobile/desktop class names and that reduced motion always disables the entrance animation regardless of which one is set.

Was this helpful?