Sections, blocks and element styles
Define theme sections and blocks with defineSection and defineBlock, give them settings and style capabilities, and register them for the storefront and the Customizer.
On this page
You declare every section and block a theme adds once, in TSX, next to its component.
One defineSection({...}) or defineBlock({...}) object carries the type key, the picker
label and icon, the inspector settings, the accepted children, the style capabilities and
the component. The theme compiler reads these objects without running any code, and builds
the Customizer's catalogue, the code editor's layout JSON Schema and the publish checks from
them. There is no separate manifest file.
Layout and content stay in JSON beside the code: pages/*.json, pages/custom/*.json,
layout/*.json and settings/values.json. The Customizer and the code editor edit the same
files. The Customizer never changes theme.json.
A section
import { EditableText, Slot, defineBlock, defineSection, field } from '@poltura/theme-sdk/elements';
import { cn } from '@poltura/theme-sdk/ui';
export const wallQuote = defineBlock({
type: 'wall-quote',
label: 'Quote',
icon: 'quote',
category: 'content',
settings: {
quote: field.textarea({ label: 'Quote', default: 'Best purchase this year.' }),
author: field.text({ label: 'Author' }),
},
style: ['typography', 'color', 'spacing'],
component: ({ settings }) => (
<figure>
<EditableText setting='quote' as='blockquote' className='text-lg' />
{settings.author ? <figcaption>{settings.author}</figcaption> : null}
</figure>
),
});
export const quoteWall = defineSection({
type: 'quote-wall',
label: 'Quote wall',
description: 'Customer quotes in a grid',
icon: 'quote',
category: 'marketing',
templates: ['home', 'page'],
settings: {
heading: field.text({ label: 'Heading', default: 'What customers say' }),
columns: field.range({ label: 'Columns', min: 1, max: 4, default: 3 }),
},
blocks: { accepts: [wallQuote], max: 12 },
style: ['spacing', 'background', 'visibility'],
component: ({ attrs }) => (
<section {...attrs} className={cn(attrs.className, 'theme-container py-14')}>
<EditableText setting='heading' as='h2' className='mb-8 text-3xl font-semibold' />
<Slot className='grid gap-6 md:grid-cols-3' />
</section>
),
});typeis the key layout JSON uses. Keys are unique per theme; reusing a platform key (hero,heading,button,link-list…) replaces the platform element everywhere.settingsis an object literal. Its keys are the setting ids, its order is the inspector order, and the component'ssettingsprop is typed from it with defaults applied and the shopper's language resolved. Every component receives{ settings, attrs, id }. A component written outside the definition, over a settings object kept in aconst, is typedElementProps<typeof schema>.blocks.acceptslists child types: imported definitions, type keys, or a wildcard (below).blocks.maxcaps the count.templateslimits a section to template ids (home,product,collection,page, …). Omit it to offer the section everywhere.slots: [{ name: 'media', label: 'Media' }, …]declares named regions; render each with<Slot name='media' />.- A
defineSectioncomponent must spreadattrsonto one root node. That node carries the element id the Customizer selects, the merchant's anchor and the scoped CSS of the merchant's Style tab. The scoped CSS targets a class the spread brings inattrs.className, so the node's own classes are merged into it withcn(attrs.className, 'py-14')from@poltura/theme-sdk/ui. A plainclassName='py-14'after the spread replaces that class: the merchant's spacing, background and visibility then do nothing, and the theme check reports it. - A block needs nothing on its root: the platform draws every block inside its frame, a
div(or theliorspanitsframenames) that carries the editor anchor, the in-page anchor and the merchant's style, and hands the block an emptyattrs. Merchants can restyle every block without extra work from you. context: { requires: ['product'] }declares that a block reads the nearest product (useNearestProductfrom@poltura/theme-sdk/product);providesdeclares the scopes an element opens for its children. The product template, every product card and aProductScopeopen a product scope. Collection, category and all-products pages open a collection scope, whose listinguseListingfrom@poltura/theme-sdk/listingreads. The Customizer refuses a block that requires a scope anywhere outside one.presetsare named starting configurations (settings, style, child elements). Only a preset with the iddefaultis applied when a merchant inserts the plain tile. Every other preset is its own tile in the picker, right after the element, labelledElement (Preset). Name the preset a container should start withdefault, or it lands empty.keywords: ['kosárba', 'ATC']adds picker search terms. On sections,groups: ['header']offers the section in the header or footer group andlimit: 1caps its instances per template or group.
What "static" means
The compiler evaluates everything except component without running it. Allowed: literals,
field.* calls with literal arguments, field.group('Label') and field.note('…'),
references to const literals in the same file, and imported definitions in accepts.
Anything else (a function call, a spread from another module, a computed key) is a schema
error in the theme check and in the code editor, with its source position. component may be a local
function or an inline arrow, and one component may back several types.
Section-owned blocks
Every block has a component and draws itself through a region. The theme check reports
accepted-without-component for an accepted block without one. A block that only one section
accepts by name belongs to that section: the picker offers it there and nowhere else, as
with the default theme's FAQ split questions and mega menu columns.
Field builders
field comes from @poltura/theme-sdk/elements. Every builder takes label, and optionally
description, placeholder, visibleIf: { field, value }, translatable, content,
renamedFrom and purpose (one line on what the value says, which briefs whoever writes it).
Most also take a default; video and the catalogue pickers (product, collection and the
like) do not. text, textarea and richtext also take maxLength, the most characters the
design holds. select and radio also take renamedValues: { old: 'new' }: a store that saved an
option value you renamed keeps its choice under the new value. An update that renames a value asks
the merchant first, and an update that drops an option nothing renames is not published.
| Builder | Value |
|---|---|
text, textarea, richtext | string, translatable by default |
number, range (min, max, step, unit) | number |
checkbox | boolean |
select, radio (options; radio may add icon) | the union of option values |
color (darkDefault adds <id>_dark), gradient | string |
image | a media:// or asset:// reference |
url | a store path or an absolute URL |
video | a source, or { src, poster?, autoplay?, loop?, muted?, alt? } |
link (withLabel) | { href, new_tab?, rel?, label? } |
font | CSS font stack |
linkList | links with optional children |
product, collection, category | one id |
products | id array |
collectionList (max, 12 unless set) | { kind: 'collection' | 'category', handle }[] in the merchant's order |
page | a content page path such as /about; read with usePage |
post | { kind: 'post' | 'guide', slug }; read with usePost |
menu | a menu handle, which useMenu reads |
menuItem | the id of one top-level link of the menu the element sits in |
list (fields, min, max, 24 rows unless set) | the rows' settings, in order |
colorScheme | a named colour scheme of the theme; empty keeps the parent's, read with useColorScheme |
colorSchemeGroup | the theme's colour schemes, as a theme setting |
partnerBadges | { order, hidden } badge keys |
icon | a Lucide icon name or an uploaded SVG |
datetime | ISO 8601 string with offset, or empty |
copy (key) | none: edits a string in texts/ |
group('Label'), note('Text') | none: inspector heading and help text |
variable | email only: the name of one of the email's variables |
keyValues | email only: rows of a translated label beside a variable value |
steps | email only: numbered rows of a translated title and description |
links | email only: rows of a translated link text and the variable holding its address |
html | email only: HTML sent as written |
promotion | email only: the id of a store promotion that has a code |
fieldSet from the same module groups settings that several elements share, such as a button's
label and link: calling the set with a prefix spreads its fields into a schema as <prefix>_<key>.
defineThemeSettings declares the theme settings panel in src/settings.ts with the same field.* builders.
An icon setting picks from the full Lucide set (shopping-bag,
truck) or holds an SVG the merchant uploaded, sanitized and stored inline. Draw it with
<Icon value={settings.icon} /> from @poltura/theme-sdk/ui: the storefront inlines only the
icons the page uses, so no icon library reaches the browser.
A partnerBadges setting holds the badges of the store's live feeds as
{ order: [...], hidden: [...] }: every live badge not in hidden shows, the keys in order
first, the rest in the order the feeds publish them, so a badge that goes live later appears on
its own. Keys of paused feeds stay in the value for when they return.
A datetime setting is picked with a calendar and a time on the store's clock and stored with
its offset (2026-12-31T23:59:00+01:00). useCountdown from @poltura/theme-sdk/content reads it the
way the platform countdown does: a bare date starts at midnight and a time without an offset is
read on the store clock, both in the store time zone.
A link setting is picked from the store's pages, products, collections, categories and forms,
or typed as an address, with a new-tab switch and, for other sites, nofollow and sponsored.
default is the starting href (field.link({ label: 'Link', default: '/products' })), and a
plain string in a layout file reads as the href. Pass the value to Link from
@poltura/theme-sdk/routing, which checks the href, applies the new tab and rel, and adds
noopener noreferrer to a new tab:
<Link href={settings.cta} className='pol-cta' />withLabel: true also asks for the link's visible text, stored as label; Link shows it when
it wraps nothing. That text is translated per language while the href, tab and rel stay the
same in every language.
A richtext setting is edited with a toolbar (bold, italic, links that may open in a new tab,
bulleted and numbered lists, level 3 and 4 headings; Cmd/Ctrl+B, I and K) and stored as text
with a small markup, never HTML: **bold**, *italic*, [label](/path),
[label](https://…){target=_blank}, - item, 1. item, ### Heading, #### Subheading, and a
blank line between paragraphs. Render it with EditableRichText from
@poltura/theme-sdk/elements, which draws it and lets merchants edit it on the canvas. Store copy that arrives as rich text, such as a product description, is drawn
with RichText from @poltura/theme-sdk/content.
A font setting holds a CSS font stack from the font picker, such as
ui-serif, Georgia, Cambria, serif; apply it with style={{ fontFamily: settings.font }}. The
system stacks always render; a web font renders once the theme loads it. number and range
take a unit (px, %, em, rem, vh, vw, ms, s, deg) shown beside the value; the
stored value stays a plain number.
A video setting takes a YouTube or Vimeo link, or an MP4 or WebM file the merchant uploads, up
to 20 MB, with a cover image, how it plays (autoplay, loop, muted) and a text alternative.
useVideoSetting from @poltura/theme-sdk/media resolves it to the file or player to draw.
Merchant text in text, textarea and richtext fields is translated through the store's
languages (settings_i18n in the layout file). style is never translated.
image, video, url and link settings are shared by every language until a merchant gives
one language its own (a Hungarian banner, a /hu/… link) with "Use a different one for …" in the
Customizer. That value is stored in settings_i18n[<locale>] like translated text, is never
machine translated, and "Use the … one" drops it so the language shares the base value again.
The settings prop already carries the shopper's language, so an element needs nothing extra
to show the right image.
Image and video settings also offer the store's media library: every image and video uploaded through the store admin, searchable by file name. Unused files can be deleted from the picker.
Look and content
Every field is either look, which a style preset may overwrite, or content, which a preset
never touches. Colours, gradients, fonts, selects, radios, numbers, ranges and checkboxes are
look; everything else is content. content: true keeps a look-typed value that is really the
merchant's choice, and content: false makes a content-typed field part of the look:
showReviews: field.checkbox({ label: 'Show reviews', default: true, content: true }),Global settings in src/settings.ts take the same content key; see
Style presets.
Style capabilities
style lists the Style tab groups an element offers. The inspector shows only those, and the
theme check and the Customizer report stored style outside them.
| Capability | Controls |
|---|---|
layout | display, direction (and reversed), wrap, justify, align, gap, columns, overflow |
align | the element among its siblings: placement left, centre or right, grow, order, sticky, stacking |
spacing | padding and margin boxes |
size | width (including shares such as 1/3), max width, min height, aspect ratio |
media | how the element's own image or video fits its box, and its focal point |
typography | size, weight, alignment, transform, tracking, leading, font role, italic, decoration, line clamp |
color | text colour (a role or a custom colour), hover text colour |
background | background colour (role or custom) or gradient, hover fill |
border | width and single sides, style, colour, radius and single corners, hover border colour |
effects | shadow, opacity, filters, backdrop blur, and on hover: shadow, opacity, lift, filters |
background-media | background image and video per device, fit, focal point, fixed scrolling, overlay |
motion | entrance animation played when the element scrolls into view |
dividers | shape dividers on the top and bottom edge of a section |
visibility | hide on desktop, tablet or mobile |
Values are tokens, never pixels. Colours may also be a strictly validated custom colour.
Any value may be responsive, { "base": "lg", "tablet": "md", "mobile": "sm" }, with tablet
below 64rem and mobile below 48rem. Tablet inherits base, and mobile inherits tablet. Without base, desktop keeps the
element's own design and only the overridden devices change.
Style tokens
| Style path | Tokens | CSS variable |
|---|---|---|
spacing.padding.*, layout.gap | none, 3xs, 2xs, xs, sm, md, lg, xl, 2xl, 3xl | --pt-sp-* |
spacing.margin.* | the spacing scale plus auto | --pt-sp-* |
size.minHeight | the spacing scale plus half-screen, screen | --pt-sp-*, svh |
size.width | auto, full, fit, or a share of the parent: 1/4, 1/3, 1/2, 2/3, 3/4 | none |
size.aspect | auto, 1:1, 4:3, 3:2, 16:9, 21:9, 3:4, 2:3, 4:5 | none |
media.fit, media.position | cover, contain; top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right | none |
size.maxWidth | narrow, prose, content, wide, full | --pt-maxw-* |
typography.size | xs, sm, body, lg, h4, h3, h2, h1, display (sets the paired line height) | --pt-fs-* |
typography.weight | regular, medium, semibold, bold | --pt-fw-* |
typography.leading | tight, normal, relaxed (wins over the size's paired line height) | --pt-lh-* |
typography.tracking | tight, normal, wide | --pt-tracking-* |
typography.align | left, center, right, justify | none |
typography.transform | none, uppercase, lowercase, capitalize | none |
typography.family | heading, body, accent (the theme's font roles) | --pt-font-* |
typography.style, typography.decoration | normal, italic; none, underline, line-through | none |
typography.clamp | none, or at most 1 to 4 lines | none |
layout.display | block, flex, grid | none |
layout.direction, layout.wrap | row, column, row-reverse, column-reverse; wrap, nowrap | none |
layout.overflow | visible, hidden, clip, auto | none |
layout.justify | start, center, end, between, around | none |
layout.align | start, center, end, stretch | none |
layout.columns | whole numbers 1 to 6 (with display: grid) | none |
layout.self | left, center, right (the align capability) | none |
layout.grow, layout.order | fill, fit; first, 1 to 6, last (the align capability) | none |
layout.position, layout.top | static, relative, sticky; header (the default) or the spacing scale | --pt-sticky-header, --pt-sp-* |
layout.zIndex | base, raised, overlay, top | --pt-z-* |
color.text, border.color | colour roles: foreground, muted-foreground, primary, primary-foreground, secondary, secondary-foreground, accent, accent-foreground, background, card, card-foreground, muted, border, destructive; or a custom colour (below) | --<role> |
color.background | a colour role, a custom colour, or linear-gradient(<0-999>deg, #hex <0-999>%, …) with 2 to 8 hex stops | --<role> |
border.width, border.sides.* | none, thin, medium, thick; sides takes top, right, bottom, left | --pt-border-* |
border.style | solid, dashed, dotted (a width without a style draws solid) | none |
border.radius, border.corners.* | none, sm, md, lg, pill; corners takes topLeft, topRight, bottomRight, bottomLeft | --pt-radius-* |
effects.shadow | none, sm, md, lg | --pt-shadow-* |
effects.opacity | percent, 0 to 100 in steps of 5 | none |
hover.text, .background, .borderColor | as color.text, color.background and border.color | --<role> |
hover.shadow, .opacity, .lift | as effects.shadow and effects.opacity; lift none, sm, md, lg | --pt-lift-* |
hover.transition | none, fast, normal, slow (normal when a hover value is set) | --pt-duration-* |
effects.blur, effects.backdropBlur | none, sm, md, lg, xl | --pt-blur-*, --pt-backdrop-* |
effects.brightness, .contrast, .saturate | percent, 0 to 200 in steps of 5 (100 is unchanged) | none |
effects.grayscale | percent, 0 to 100 in steps of 5 | none |
hover.blur, .brightness, .contrast, .grayscale, .saturate | as the effects filters; each replaces only its own filter | as above |
background.image, background.video | a same-site path (/media/…) or an http(s) URL of at most 512 URL-safe characters, or none to drop it on a device | none |
background.poster | an address as above; one value for every device | none |
background.fit, background.position | as media.fit and media.position | none |
background.attachment | scroll, fixed | none |
background.overlay, .overlayOpacity | a colour role or custom colour; percent in steps of 5 (50 when unset) | --<role> |
motion.entrance | none, fade, fade-up, fade-down, zoom, slide-left, slide-right | none |
motion.duration, motion.delay | fast, normal, slow; none, sm, md, lg | --pt-enter-*, --pt-delay-* |
motion.repeat | once, repeat; one value for every device | none |
dividers.top, dividers.bottom | pattern (one value): wave, curve, tilt, triangle, zigzag; height: none, sm, md, lg, xl; color: a colour; flip: none, mirror | --pt-divider-* |
visibility.desktop, .tablet, .mobile | false hides the element on that device only | none |
A custom colour is exactly #rgb, #rrggbb, #rrggbbaa, rgb(r, g, b) or
rgba(r, g, b, a) (channels 0 to 255, alpha 0 to 1, one optional space after each
comma). Colour names, var(…), hsl(…) and padded strings are rejected. It is written into the
CSS as is. A link or button root with a background colour also gets a hover shade of that
colour, mixed toward its text colour, because the element's own fill replaces the variant's
hover colour.
Placement (layout.self, offered by the align capability) moves the element's own box
with auto inline margins, so it works where typography.align cannot: a button, a countdown, a
price or a capped image. It places the element in a flex or grid parent (every platform
container) and, in a block parent, any box narrower than it. A placed element keeps its own
width instead of stretching; an element that is full width by design shows its placement once
size.maxWidth caps it. typography.align only aligns the text inside the box. Give align to
any element a merchant drops freely into a container.
Shares and order. A size.width share is a percentage of the parent; in a flex row the
shares shrink together to make room for the gap, so 1/3 and 2/3 sit side by side.
layout.grow: fill makes a child take the space left in its row. layout.order and the
reversed directions change only the visual order, per device: "image right on desktop, image
first on phones" is direction: { base: "row-reverse", mobile: "column" }.
Sticky (layout.position: sticky) keeps the element in view from layout.top, below the
store header by default, and stops it stretching to its row, which would leave it nowhere to
stick. layout.zIndex also positions the element so its level applies. header follows the
header as it hides, reveals or shrinks (see The store header).
Borders. border.width and border.radius set every side and corner; border.sides and
border.corners override single ones, so a top rule is { "sides": { "top": "thin" } }. On a
divider (an <hr> root) an all-sides width draws one line, never two stacked ones.
Aspect and media. size.aspect shapes the box. The element's own image or video, a direct
child of its root, then fills the box, cropped by media.fit (cover unless set) around the
media.position focal point.
Colour scheme. The colour scheme is a setting, not a style: every section and every block
that draws a box takes settings.color_scheme, the id of one of the theme's named colour schemes
(the color_schemes theme setting). The box and everything inside it are drawn in the scheme's
colours, so every child that uses a colour role reads on it without restyling; empty keeps the
parent's scheme. An explicit color.text or color.background wins over the scheme.
Hover. The hover group holds what changes while the pointer is over the element, it has
keyboard focus, or the Customizer previews it (data-pe-state="hover" on the root). Each leaf
comes with the capability of its resting counterpart. A hover colour replaces a gradient fill
too, and a gradient without a hover fill gets a shade of its own. Transitions run at
--pt-duration-*, which the host sets to zero for shoppers who ask for reduced motion.
Backgrounds (background-media). An image and a video sit behind the content, above the
fill colour or gradient, with an overlay colour above both. Each device may have its own image
or video, or none; only the one a device shows loads. fixed keeps the image still while the
page scrolls over it, except for shoppers who ask for reduced motion. The video is muted and
loops, loads only as it nears the viewport, plays only while on screen, and stays paused on its
first frame or poster under reduced motion. The overlay colour resolves inside the box, so a
role follows its colour scheme: a dark scheme with a background overlay tints the image with
the scheme's ground and turns the text light. Addresses reach only DOM attributes, never CSS.
Platform Container, Column and the Custom section draw it; a theme element that declares
background-media renders <ElementBackground /> from @poltura/theme-sdk/elements as the
first child of its root.
Filters (effects). Brightness, contrast, greyscale, saturation and blur apply to the
element's own pixels in a fixed order; backdropBlur frosts what shows through a translucent
fill, for glass panels. A hover filter replaces only its own function and animates with the
hover transition, so a greyscale logo wall can colour up on hover.
Entrances (motion). An element with an entrance carries data-pe-motion in the server
markup and nothing that hides it. Once JavaScript runs, the storefront holds it at its first
frame and plays the preset when a sixth of it is in view: once, or every time it comes back
into view. Nothing is hidden without JavaScript, and nothing moves for shoppers who ask for
reduced motion. none on a device turns an entrance off there. The Customizer replays an
entrance from the Motion group.
Shape dividers (dividers). A built-in inline SVG on the section's top or bottom edge,
hidden from assistive technology, in the page background colour unless set, so the band looks
cut. The pattern is one value for every device; set the height to none to drop it on a
device. The Custom section draws them; a theme section that declares dividers renders
<ElementDividers /> from @poltura/theme-sdk/elements inside its root.
Anything else (a pixel value, an unknown key, a url(…), a free CSS string) is dropped before
it reaches CSS and reported with its JSON path by the Customizer, the theme check and the code
editor, so a hand-edited layout file can never inject CSS.
{
"id": "q1",
"type": "wall-quote",
"settings": { "quote": "Fast delivery." },
"settings_i18n": { "hu": { "quote": "Gyors szállítás." } },
"style": {
"typography": { "size": "h3" },
"spacing": { "padding": { "top": { "base": "md", "mobile": "sm" } } }
}
}Tokens resolve to neutral CSS variables (--pt-sp-*, --pt-fs-*, --pt-lh-*, --pt-fw-*,
--pt-radius-*, --pt-shadow-*, --pt-maxw-*, --pt-gutter, --pt-font-*, --pt-z-*, --pt-lift-*,
--pt-duration-*, --pt-sticky-header, --pt-blur-*, --pt-backdrop-*,
--pt-enter-*, --pt-delay-*, --pt-divider-*, --pt-page-background) and colour roles to the theme's --primary,
--foreground and the rest. The platform ships defaults. Map the neutral layer onto your own
scale in src/styles/tokens.css so merchant choices look native:
:where(:root) {
--pt-sp-md: var(--my-space-4);
--pt-fs-h1: var(--my-font-h1);
--pt-radius-md: var(--my-radius);
--pt-font-heading: var(--my-display-font);
--pt-font-body: var(--my-text-font);
--pt-font-accent: var(--my-accent-font);
--pt-maxw-content: var(--my-container);
--pt-gutter: var(--my-gutter);
}--pt-maxw-content and --pt-gutter are your .theme-container: its border-box max width and
its side padding. A boxed Custom section insets its content by exactly that pair, so map both
to the values your container uses or merchant sections will not line up with yours.
The storefront compiles each section's style into one <style> rule set scoped to
[data-pe="<id>"] at render time, which is why a defined section's root must carry attrs.
Where platform elements take their style
- Grid-native elements. Columns, Product grid and Collection list render a grid of their
own (
layout_default: 'grid'in their schema). Direction or wrap never turn them into flex; only an explicitlayout.displaydoes. Their own breakpoint columns stay until a device gets its ownlayout.columns: a desktop count stays on desktop, and a phone follows a tablet count. - Inner parts inherit. Weight, colour, letter case and tracking, where the element offers them, reach the sale price, the countdown digits and labels, the tab labels, the accordion body and the list icons.
- Text colour on a box. On Container, Column and the Custom section, a text colour also
sets the
--foregroundrole inside the box and a softer--muted-foreground, so the headings and text in it follow; a colour scheme recolours the box as a whole. - Add to cart always sits in a box of its own (
data-pe-group) holding its option pickers: spacing and size style that box, fill and border the button. - Container link. A container with a link is clickable as a whole through a real link over the box, reachable with Tab and named after the first heading, button or image inside it.
- Custom section. Minimum height, where the content sits and the background image come
from the Style tab (
size.minHeight,layout.justify,background). Boxed content sits on the theme container (--pt-maxw-content,--pt-gutter), wide on--pt-maxw-widewith the same gutter, and side padding adds to that gutter. Full width runs backgrounds edge to edge and keeps the theme gutter for content until side padding sets the inset instead:noneruns content edge to edge.
Children: Slot and wildcards
A parent never draws a child itself. <Slot /> without a name renders the children merchants
placed in the default region of the current element, in saved order, each through its own
component and frame, inside one wrapper div carrying className. <Slot name='media' /> does
the same for a declared slot. as picks the region's tag (div, ul, ol, nav, or none
for no box); a region rendered as='ul' or as='ol' takes only blocks whose frame is li.
An empty region renders nothing and takes no space, on the live page and in the Customizer
alike: the Customizer draws its drop target over the page, labelled with the slot's label.
useChildBlocks('media') from @poltura/theme-sdk/elements returns the region's shown children
(id, type and settings in the shopper's language) for counts, tabs and layout decisions;
draw them with Slot.
blocks.static declares fixed children with developer ids. This excerpt of a definition
declares three:
blocks: {
accepts: [ELEMENT_WILDCARD],
static: [
{ id: 'title', type: 'heading', slot: 'details' },
{ id: 'buy', type: 'add-to-cart', slot: 'details', required: true },
{ id: 'summary', type: 'text' },
],
},Merchants may hide and restyle a static but not move or delete it; a required one cannot be
hidden either. A static with a slot renders in that region among the merchant's blocks; one
without renders in the default region, drawn by <Slot />.
Two wildcards open accepts:
ELEMENT_WILDCARD('@element'): every element any active provider offers. That covers platform elements (container, heading, text, button, image, product card…) and every plugin, app or theme block that renders itself.THEME_BLOCK_WILDCARD('@theme'): the elements of every provider except the platform, meaning plugin, app and theme blocks.
A section's own named children always resolve against that section's own block list first,
so a theme block that shares a key with a platform section's child (stat, quote, item)
never changes that platform section.
Slots and what they accept
A slot may narrow what it takes with its own accepts, a subset of the element's
blocks.accepts. This section imports ELEMENT_WILDCARD, Slot and defineSection from
@poltura/theme-sdk/elements and cn from @poltura/theme-sdk/ui:
export const promoSplit = defineSection({
type: 'promo-split',
label: 'Promo split',
icon: 'columns',
category: 'marketing',
settings: {},
blocks: { accepts: [ELEMENT_WILDCARD], max: 12 },
slots: [
{ name: 'media', label: 'Media', accepts: ['image', 'video'] },
{ name: 'body', label: 'Text and buttons' },
],
component: ({ attrs }) => (
<section {...attrs} className={cn(attrs.className, 'grid gap-8 md:grid-cols-2')}>
<Slot name='media' />
<Slot name='body' />
</section>
),
});Each child records its region in "slot": "media" in the layout file. A slot without
accepts takes everything its element accepts. The picker, the navigator's drag and drop,
save validation, the AI assistant and the code editor's JSON Schema all apply the same rule,
and the theme check reports a declared slot the component never renders.
Inline text: EditableText
<EditableText setting='heading' as='h2' /> renders a plain text or textarea setting of the
current element in the shopper's language and lets merchants retype it on the canvas. Outside
the Customizer it is exactly <h2>text</h2>, and nothing when the text is empty.
<EditableRichText setting='body' className='flex flex-col gap-3' /> does the same for a
richtext setting: a double-click opens it with its formatting, Cmd/Ctrl+B and Cmd/Ctrl+I
toggle bold and italic, Enter starts a new paragraph, and Cmd/Ctrl+Enter or a click elsewhere
saves it. Escape discards the edit.
Commerce elements
Six platform elements show live catalogue data wherever a layout accepts elements:
| Element | Type key | Shows |
|---|---|---|
| Product card | product-card | one product: image, name, price, optional badge and add to cart |
| Product grid | product-grid | 2 to 24 products from a collection, a category or a manual pick |
| Price | price | a product's price, with the original price when on sale |
| Add to cart | add-to-cart | a button with the product's options (size, colour…) |
| Collection list | collection-list | 2 to 24 tiles linking to collections, or to the subcategories of a category |
| Countdown | countdown | days, hours, minutes and seconds until end_at (a datetime setting) |
price and add-to-cart with an empty product use the product of the product page they sit
on. Settings hold ids, never copies of catalogue data, so names, prices and stock are always
current and in the shopper's language and currency.
The storefront collects the catalogue data every commerce element on the page needs (templates, site pages, header and footer, mega menu panels included) and loads it in one batched request during server rendering, so the elements render fully on the first paint, on every route. In the Customizer, an element you add fills itself with one follow-up request. Labels left empty (add-to-cart wording, countdown units) use the platform's wording in every store language. Filled labels are merchant text and are translated like any other.
Store data bindings
A text, textarea, richtext, image, url or link setting can read live store data
instead of holding its own value. Merchants bind one from the database button beside the field
label in the inspector. In a layout file the setting holds a binding object:
{
"id": "b_12",
"type": "heading",
"settings": {
"text": {
"$bind": { "source": "product", "id": "organic-tee", "field": "title", "fallback": "Our tee" }
}
}
}| Source | Fields |
|---|---|
product | title, subtitle, description, price, compare_at_price, vendor, image, url |
collection | title, url |
category | title, description, url |
store | name, tagline |
customer | first_name |
idis the handle of a product, collection or category, as the product pickers store it. Leave it out to read the one the page is about: a product page's product, a category page's category, or the collection a listing filters by. In the Customizer, a page-product binding on a template without a product shows a sample product.fieldmust suit the setting: text settings take text fields (anddescription),imagesettings takeimage,urlandlinksettings takeurl. Save validation rejects anything else, and the layout JSON schema offers only the fields that fit.fallback(up to 500 characters) shows when the store has no value: a deleted or unknown product, a product with no subtitle, a guest forcustomer. Without a fallback the setting's own default applies.
Bound values are resolved on the server during rendering, through the same batched request as
the commerce elements, so the first paint already shows them and a page never issues one request
per binding. Prices are formatted in the shopper's currency. Titles and descriptions come in the
shopper's language through the store's catalogue translations. A bound setting is never machine
translated and holds no settings_i18n entry: binding a field drops its translations.
Descriptions are store copy, often HTML from an import. They never reach the page as markup: a
richtext setting reads them into the rich text grammar (paragraphs, headings, lists, bold,
italic, safe links) and every other text setting reads their plain words.
Element components receive the resolved value in settings, like any other. EditableText
leaves a bound setting out of in-place editing on the canvas.
Base language
The base settings of every layout file hold the store's default language, named by
base_locale in settings/values.json. Every other language is an overlay
in settings_i18n[<locale>] holding only the fields that differ. Write a theme's default copy
in the language you ship it in and set base_locale to its bare language code ("hu", not
"hu-HU"); a theme without it is read as English.
- Inline editing on the canvas and the inspector write the base settings in the base language and the overlay in any other.
- When a store's default language differs from its theme's base, the Customizer offers to re-base: that language's overlays become the base, the old base becomes an overlay, and translation fingerprints follow, so nothing is lost or retranslated.
- A theme entering a store (install, starter seed, reset) is re-based automatically only when the store's language already has every string; otherwise it stays as shipped.
Conditions and anchors
Every section and element can carry an optional conditions key beside settings and
style, edited on the Advanced tab under Visibility:
{
"id": "b_07",
"type": "button",
"settings": { "label": "Sign in for 10% off", "anchor": "member-offer" },
"conditions": {
"audience": "guests",
"from": "2026-11-27T00:00:00+01:00",
"until": "2026-12-01T00:00:00+01:00"
}
}audience:guests(shoppers who are not signed in) orcustomers(signed-in shoppers).allis the default and is never stored.from/until:datetimevalues on the store clock.fromis the first moment the element shows,untilthe first moment it no longer does. A bare date means midnight at the start of that day in the store time zone.settings.anchor: the element's in-page link target. A link to#member-offeron the same page scrolls to it. Lower casea-z,0-9,-and_, unique within the layout file; never translated. Any element may set it without declaring ananchorfield.
The storefront evaluates conditions on the server. An element for the other audience, or
outside its schedule, is left out of the rendered HTML and of the page data serialized into
it, so a customer-only code never reaches a guest. Element components never see conditions:
the block and section renderers gate them, and put the anchor on the element root as its id
(through attrs, so spread it).
The Customizer canvas follows Preview as: the other audience is left out exactly as on the storefront. Elements outside their schedule stay on the canvas under a hatch with a "Hidden now: scheduled" chip, and their navigator row shows a clock.
Conditions are evaluated on every request, so a page never shows an ended promotion or hides a
started one. An open page reloads its data at the next from or until of anything on it.
Save validation rejects unknown condition keys, an unknown audience, a malformed date and a
malformed or repeated anchor. until before from is allowed: that element never shows, and
the editor says so.
Limits
Save validation enforces the same caps for the Customizer, the code editor, the AI assistant and publish, and the code editor's JSON Schema flags them while you type:
Limit (limit in the error) | Maximum per layout file |
|---|---|
elements | 1,000 sections and nested blocks together |
sections | 40 sections |
children | 24 children per section or container (a schema's blocks.max may only lower it) |
depth | 8 levels of blocks under a section, in the Customizer and in the theme check alike |
style_size | 12 KiB of serialized style per element (every leaf on every device, with seven background addresses of the longest length, is about 10.3 KiB) |
string_length | 20,000 characters per string in settings or settings_i18n |
file_size | 1 MiB of serialized JSON |
A save over a cap is refused as a whole with a layout_limit_exceeded error naming the limit,
the maximum and the actual value. The Customizer explains it to the merchant in their language
and refuses an insert, paste or duplicate that would cross a cap before it happens.
The store header
The promo bar and the header render inside one host box, [data-theme-chrome="header"], whose
scroll behaviour comes from the header section's own settings in layout/header.json. The
storefront runs it for every theme. A theme only styles the states. The settings are:
| Setting | Values |
|---|---|
behavior | sticky (the default), reveal (hides on scroll down, returns on scroll up), static |
pin | all (the default) or last_row: only the last bar row stays on screen; sticky and reveal only |
shrink | true shrinks the pinned header once the page scrolls; sticky and reveal only |
transparent | off (the default), home, all: transparent over the first section |
mobile_from | tablet (the default), mobile or desktop: from this size down the menu button replaces the menu bar |
drawer_side | left (the default) or right: the side the mobile menu opens from |
drawer_width | sm, md (the default) or full: the mobile menu's width |
The settings are shared by every language. A header section without a setting gets its default, and an unknown value falls back to the default field by field. The transparent and the scrolled colours are the header section's style states on the Style tab, not settings.
The box carries the state as attributes. The server renders data-header-behavior,
data-header-pin, data-header-overlay (transparent on this page) and data-header-shrink.
The runtime adds data-header-scrolled (past the header's height), data-header-hidden
(reveal while scrolling down) and data-header-shrunk. The header section's root carries
data-header-mobile-from.
- No layout shift. An overlaid header is out of flow from the first byte, so nothing
moves when the page scrolls. A shrinking header loses
exactly
--pt-header-shrink-by, which the platform gives back as the box's bottom margin in the same transition. A theme that shrinks declares that length on:rootand shrinks a row by it::where([data-header-shrunk]) .my-row { height: calc(var(--my-row-h) - var(--pt-header-shrink-by)); }. Without a declaration only the logo scales. - Under the header. An overlaid header would cover the top of the page, so the page body
starts below it: the platform pads it by the header's height at rest (promo bar included), which
never changes while the header hides or shrinks. A full-bleed section that should sit under
the transparent header opts out with
data-header-underlayon its root; when it opens the page the padding goes and the section pads its own content by--pt-header-underlay(0pxeverywhere else):padding-top: calc(4rem + var(--pt-header-underlay, 0px)). - Offset.
--pt-header-heightis the live height of the pinned header: the theme's--sticky-topat rest, less the shrink,0pxwhile hidden or static.--pt-sticky-header(thelayout.top: headeroffset) and--sticky-topfollow it, so sticky elements sit right below the header as it moves. - Transparent. While transparent the header bar's
--backgroundand--borderaretransparentand--foregroundand--muted-foregroundread the dark scheme's (--pt-header-overlay-fg,--pt-header-overlay-muted) until the overlay style state repaints them. A header that paints another role (a--cardbar, a slab of its own colour) clears it under:where([data-header-overlay]:not([data-header-scrolled]):not(:focus-within)). Hover and keyboard focus turn it solid, so open menus and search never sit on a see-through ground. - Keyboard. A hidden header stays in the tab order and comes back while anything in it has focus, so focus never lands under it.
- Search. A
searchblock shown as afieldopens its suggestions and results in a popover anchored under the field ([data-search-results]), so the header keeps its height while the shopper types;iconopens the field in such a popover andoverlayin a dialog. Arrow keys move through the options, Enter opens one, and Escape or a click outside closes the popover. - Motion. Transitions run at
--pt-header-duration(--pt-duration-normal), which is zero for shoppers who ask for reduced motion.
A header whose upper rows should scroll away sets the header section's pin to last_row:
the box then rides up by the rows above its last bar row (promo bar included), measured by the
runtime into --pt-header-pin-offset, and only that row stays on screen. Editorial pins its
running head this way, and Pop pins the row holding its floating pill. Launch sets
mobile_from to desktop, which keeps the menu behind the button on every screen.
Restyling platform elements
To keep a platform section's or block's settings and change only its markup, override its renderer in a file named by its type:
Here CatalogHero is a component of your own theme:
import { overrideSection } from '@poltura/theme-sdk/elements';
import { CatalogHero } from '../components/catalog-hero';
export const hero = overrideSection('hero', CatalogHero);import {
overrideBlock,
type ElementRenderProps,
type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
function ContactHero({ settings }: ElementRenderProps<PlatformBlockSettings<'contact-hero'>>) {
return <h1 className='text-5xl font-semibold'>{settings.heading}</h1>;
}
export const contactHero = overrideBlock('contact-hero', ContactHero);The component receives { settings, attrs, id }. The platform keeps the schema, the inspector
and the data, and draws the overridden element's frame itself, so attrs is empty in an
override and the renderer spreads nothing. Settings arrive as stored, typed from the platform's published schema
(ElementRenderProps<PlatformSectionSettings<'hero'>>, PlatformBlockSettings<'faq-item'>),
so give optional fields a fallback. To change the settings too, defineSection the same type
key instead.
Checkout is the exception: Poltura draws it alone. No theme section, block or element renders
there. Its header row and summary column take only the checkout blocks (logo, trust badge,
breadcrumbs, note) and the platform's heading, text, image, icon, divider, spacer and payment
icons, and the theme's client code does not load on it. The theme's stylesheet and tokens still
apply, so a theme styles checkout with CSS on the data-checkout hooks of the platform markup.
The FAQ, testimonials, logo list, stats and steps sections draw their rows as blocks, so a theme
restyles the section's frame and each row on its own: override the section to draw its heading
and <Slot />, and the row type with overrideBlock. The row types are faq-item, quote,
partner-logo, stat and step.
import {
EditableRichText,
Slot,
overrideBlock,
overrideSection,
type ElementRenderProps,
type PlatformBlockSettings,
type PlatformSectionSettings,
} from '@poltura/theme-sdk/elements';
function Faq({ settings }: ElementRenderProps<PlatformSectionSettings<'faq'>>) {
return (
<section className='theme-container py-16'>
<h2>{settings.heading ?? ''}</h2>
<Slot className='divide-y' />
</section>
);
}
function Question({ settings }: ElementRenderProps<PlatformBlockSettings<'faq-item'>>) {
return (
<details open={settings.open_by_default ?? false}>
<summary>{settings.question ?? ''}</summary>
<EditableRichText setting='answer' />
</details>
);
}
export const faq = overrideSection('faq', Faq);
export const faqItem = overrideBlock('faq-item', Question);Every platform section's own renderer comes from @poltura/theme-sdk/sections (BentoSection,
FaqSection, HeroSection, TestimonialsSection and the rest), so an override can wrap it or
delegate to it and a platform fix still reaches the theme. Product blocks do the same with the
parts in @poltura/theme-sdk/product: the variant-picker override draws VariantPicker with
its classes, and the product-reviews override draws Reviews with className and
listClassName.
import { overrideSection } from '@poltura/theme-sdk/elements';
import { BentoSection } from '@poltura/theme-sdk/sections';
export const bento = overrideSection('bento', (props) => (
<div className='theme-frame'>
<BentoSection {...props} />
</div>
));The bento draws its grid and its optional top-sellers rail itself; its tiles are the merchant's elements in its region, a grid of pictures, product cards and link cards by default.
Registration
Every element exported from a file under src/sections/ or src/blocks/ registers itself,
directly or re-exported. There is no registry file: the compiler builds the theme's list of
elements from those exports, and the storefront draws from it.
- One element per file, and the file name is its type:
src/sections/quote-wall.tsxexportsquoteWall,src/blocks/product-title.tsxexportsproductTitle. Name the export after the type in camelCase. - Blocks that only live inside one section (
wall-quoteabove, the mega menu'smenuColumnandpromoCard) stay in that section's file and are exported from it. A block another section also accepts gets its own file insrc/blocks. - Helpers that are not elements belong in
src/componentsorsrc/lib.
The compiler and the code editor report these as issues:
| Issue | Severity |
|---|---|
| Two elements register the same type | error |
| The file name does not match the type | error |
| A block shares its section's file but another section accepts it too | error |
A defineSection, defineBlock or override is not exported, or lives outside src/sections and src/blocks | error |
A definition is wrapped (w(defineSection(...))) or called through a namespace (E.defineSection(...)) | error |
| An override names a type no provider defines | warning |
Theme entries shadow platform, plugin and app entries of the same key.
Element providers
A theme is one of four element providers. The other three are the platform, Poltura's core plugins and installed apps. They all follow the same rules:
- Definition: every provider uses the same
defineSectionanddefineBlockobjects and the samefield.*builders. - Extraction: the same static reader reads every provider. No provider code runs.
- Catalogue: the platform merges the providers into one catalogue, and everything reads it: the Customizer, save and publish checks, translation, the code editor's layout JSON Schema and the storefront.
Each element carries its provider ({ kind, id }). The picker groups by it: This theme for
theme elements, Apps for app elements, and by category for platform and plugin elements.
| Provider | Where it is defined | Type keys | Gated by |
|---|---|---|---|
| platform | @poltura/theme-elements primitives, platform sections | bare, reserved | never |
| plugin | a Poltura core plugin | bare; unique across the platform and every plugin | the plugin's own on and off switch |
| app | the app's source | app:<id>/<name>; public apps are keyed per installation | installed and enabled |
| theme | the theme's TSX | bare | never |
Precedence runs platform, then plugin, then app, then theme:
- A theme may redefine a platform or plugin type. Its schema then replaces theirs.
- A theme may only restyle an app type, with
overrideBlock/overrideSection. - Any other collision is an error, and the first definition stays.
When a gated provider is off, its elements disappear from the picker and from every accepts
list, and they render nothing on the live page. Saved layouts that use them still pass
validation, so turning the provider back on restores every placement. The Customizer and the
storefront read the same on and off state from the store.
Checks
The theme check (poltura theme check <dir>, run by the code editor on every save, by publish
and by the theme build) reports, with positions:
- non-static definitions, unknown icons, categories or templates, unknown
acceptstypes and duplicate type keys; - a section without
componentthat no platform renderer covers; usage: a component (and the same-file functions it calls) reading a setting its own definition does not declare is an error; a declared setting it never reads is a warning;children: every child type a section or block accepts must be drawn by its component: through<Slot />for children saved without a region, a<Slot name>that accepts the type, or the platform renderer. A type drawn only while a setting equals one value (source === 'blocks') counts as undrawn, because a block a merchant adds in any other state would never show. Files the change touched report errors; the rest of the theme reports warnings;accepted-without-component: an accepted theme block without acomponent; an error in files the change touched and a warning elsewhere;checkout-locked: checkout is platform-only. A theme that defines or overrides thecheckoutsection, any block the checkout section owns (checkout-brand,checkout-trust-badge,checkout-breadcrumbs,checkout-express,checkout-address,checkout-shipping,checkout-payment,checkout-summary,checkout-note) or the cart'scart-checkoutbutton is an error, and publish and the theme build refuse it. The storefront ignores such a component and draws Poltura's own. A theme styles checkout with CSS and tokens only;slots: a declared slot never rendered with<Slot name>(missing-slot), a static declared without aslotin a component that draws no default or dynamic<Slot>(static-not-rendered), and a region renderedas='ul'oras='ol'that accepts blocks not framed asli(frame-mismatch);- layout files whose settings or style fall outside their element's schema, or that cross a limit;
media/*: pictures must be a library file (media://<id>) or a file the theme ships (asset://<path under assets/>, orasset('<path>')in code). A remote address (media/no-remote-url) is a warning in the CLI and the code editor and an error when a theme is installed;javascript:,data:andfile:addresses (media/unsafe-url), paths nothing serves such as/images/hero.jpg(media/unresolvable-path), a missing asset (media/unknown-asset) andmedia://in code (media/ref-in-code) are errors. A picture without alt text (media/image-alt), an image over its budget and an unknown library file (media/unknown-media, code editor and install only) are warnings; an asset over its cap or a font that is not woff2 (media/asset-size) is an error; an asset nothing uses (media/unused-asset) is a note. Theme assets and media has the full table.