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.
| Kind | Offers | Used by |
|---|---|---|
section (default) | Layout, Spacing, Style, Advanced | Everything drawn inside the shared frame: the library and kit sections, Zero Drift sections, Featured products, Collection grid, Rich text |
bleed | Spacing, and in Advanced only show on mobile / desktop and the anchor | Full-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 |
flow | bleed plus the colour scheme | Sections 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.
Links and images in section fields
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 fullSectionSettingsValuefrom them:scheme→style.colorScheme,padTop/padBottom→ every breakpoint ofspacing.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 getsdefaultSectionSettings()’s neutral defaults (contentWidth: 'normal', no background, no animation, and so on) so the section renders exactly as it did before. - Given a
settingsobject 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:
apps/admin/src/lib/customize-doc.ts’snormalizeDoc— migrates each section’s raw stored props (before Puck’s owndefaultPropsmerge) so the Field panel edits real migrated values, not blanks. Raw stored props matter here: running migration on the post-merge object risks readingdefaultProps.settings(the fresh default) instead of a doc’s real old scheme/padding.- Inside every section’s own
render()— the sharedframed()helper and eachpuck-config.tsxblock callmigrateSectionSettings(props)right before rendering<SectionFrame settings={...}>. This is what makes the storefront correct too:apps/storefront/src/lib/template-render.tsxcalls a section’srender()directly ({...defaultProps, ...item.props}), the same shape Puck itself builds internally — it never goes throughnormalizeDoc, 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 throughnormalizeDocyet.
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 —
fullwidth renders children with no.page-widthwrapper; narrow/normal/wide set a max-width on it.alignmentsets text/flex alignment;gap(0–16, quarter-rem steps) becomes the content wrapper’s flexgap. - Spacing — writes
--section-pt-mobile/tablet/desktopand--section-pb-*custom properties;packages/blocks/src/styles/base.csshas 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-basedborder-border. - Advanced —
showOnMobile/showOnDesktopbecomemax-md:hidden/md:hidden(pure CSS, no client JS, no layout shift since the hidden class applies at first paint).anchorIdbecomes the section’sid. WithentranceAnimation: 'none'(the default) SectionFrame draws the wrapper<div>itself, so a plain section adds no client component to hydrate. Any other value renders throughSectionReveal(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 withinView. 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.