Developers
Start building
Themes/Sections, blocks and element styles

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

src/sections/quote-wall.tsx
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>
  ),
});
  • type is the key layout JSON uses. Keys are unique per theme; reusing a platform key (hero, heading, button, link-list…) replaces the platform element everywhere.
  • settings is an object literal. Its keys are the setting ids, its order is the inspector order, and the component's settings prop 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 a const, is typed ElementProps<typeof schema>.
  • blocks.accepts lists child types: imported definitions, type keys, or a wildcard (below). blocks.max caps the count.
  • templates limits 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 defineSection component must spread attrs onto 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 in attrs.className, so the node's own classes are merged into it with cn(attrs.className, 'py-14') from @poltura/theme-sdk/ui. A plain className='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 the li or span its frame names) that carries the editor anchor, the in-page anchor and the merchant's style, and hands the block an empty attrs. Merchants can restyle every block without extra work from you.
  • context: { requires: ['product'] } declares that a block reads the nearest product (useNearestProduct from @poltura/theme-sdk/product); provides declares the scopes an element opens for its children. The product template, every product card and a ProductScope open a product scope. Collection, category and all-products pages open a collection scope, whose listing useListing from @poltura/theme-sdk/listing reads. The Customizer refuses a block that requires a scope anywhere outside one.
  • presets are named starting configurations (settings, style, child elements). Only a preset with the id default is applied when a merchant inserts the plain tile. Every other preset is its own tile in the picker, right after the element, labelled Element (Preset). Name the preset a container should start with default, 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 and limit: 1 caps 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.

BuilderValue
text, textarea, richtextstring, translatable by default
number, range (min, max, step, unit)number
checkboxboolean
select, radio (options; radio may add icon)the union of option values
color (darkDefault adds <id>_dark), gradientstring
imagea media:// or asset:// reference
urla store path or an absolute URL
videoa source, or { src, poster?, autoplay?, loop?, muted?, alt? }
link (withLabel){ href, new_tab?, rel?, label? }
fontCSS font stack
linkListlinks with optional children
product, collection, categoryone id
productsid array
collectionList (max, 12 unless set){ kind: 'collection' | 'category', handle }[] in the merchant's order
pagea content page path such as /about; read with usePage
post{ kind: 'post' | 'guide', slug }; read with usePost
menua menu handle, which useMenu reads
menuItemthe 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
colorSchemea named colour scheme of the theme; empty keeps the parent's, read with useColorScheme
colorSchemeGroupthe theme's colour schemes, as a theme setting
partnerBadges{ order, hidden } badge keys
icona Lucide icon name or an uploaded SVG
datetimeISO 8601 string with offset, or empty
copy (key)none: edits a string in texts/
group('Label'), note('Text')none: inspector heading and help text
variableemail only: the name of one of the email's variables
keyValuesemail only: rows of a translated label beside a variable value
stepsemail only: numbered rows of a translated title and description
linksemail only: rows of a translated link text and the variable holding its address
htmlemail only: HTML sent as written
promotionemail 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.

CapabilityControls
layoutdisplay, direction (and reversed), wrap, justify, align, gap, columns, overflow
alignthe element among its siblings: placement left, centre or right, grow, order, sticky, stacking
spacingpadding and margin boxes
sizewidth (including shares such as 1/3), max width, min height, aspect ratio
mediahow the element's own image or video fits its box, and its focal point
typographysize, weight, alignment, transform, tracking, leading, font role, italic, decoration, line clamp
colortext colour (a role or a custom colour), hover text colour
backgroundbackground colour (role or custom) or gradient, hover fill
borderwidth and single sides, style, colour, radius and single corners, hover border colour
effectsshadow, opacity, filters, backdrop blur, and on hover: shadow, opacity, lift, filters
background-mediabackground image and video per device, fit, focal point, fixed scrolling, overlay
motionentrance animation played when the element scrolls into view
dividersshape dividers on the top and bottom edge of a section
visibilityhide 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 pathTokensCSS variable
spacing.padding.*, layout.gapnone, 3xs, 2xs, xs, sm, md, lg, xl, 2xl, 3xl--pt-sp-*
spacing.margin.*the spacing scale plus auto--pt-sp-*
size.minHeightthe spacing scale plus half-screen, screen--pt-sp-*, svh
size.widthauto, full, fit, or a share of the parent: 1/4, 1/3, 1/2, 2/3, 3/4none
size.aspectauto, 1:1, 4:3, 3:2, 16:9, 21:9, 3:4, 2:3, 4:5none
media.fit, media.positioncover, contain; top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-rightnone
size.maxWidthnarrow, prose, content, wide, full--pt-maxw-*
typography.sizexs, sm, body, lg, h4, h3, h2, h1, display (sets the paired line height)--pt-fs-*
typography.weightregular, medium, semibold, bold--pt-fw-*
typography.leadingtight, normal, relaxed (wins over the size's paired line height)--pt-lh-*
typography.trackingtight, normal, wide--pt-tracking-*
typography.alignleft, center, right, justifynone
typography.transformnone, uppercase, lowercase, capitalizenone
typography.familyheading, body, accent (the theme's font roles)--pt-font-*
typography.style, typography.decorationnormal, italic; none, underline, line-throughnone
typography.clampnone, or at most 1 to 4 linesnone
layout.displayblock, flex, gridnone
layout.direction, layout.wraprow, column, row-reverse, column-reverse; wrap, nowrapnone
layout.overflowvisible, hidden, clip, autonone
layout.justifystart, center, end, between, aroundnone
layout.alignstart, center, end, stretchnone
layout.columnswhole numbers 1 to 6 (with display: grid)none
layout.selfleft, center, right (the align capability)none
layout.grow, layout.orderfill, fit; first, 1 to 6, last (the align capability)none
layout.position, layout.topstatic, relative, sticky; header (the default) or the spacing scale--pt-sticky-header, --pt-sp-*
layout.zIndexbase, raised, overlay, top--pt-z-*
color.text, border.colorcolour 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.backgrounda 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.stylesolid, 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.shadownone, sm, md, lg--pt-shadow-*
effects.opacitypercent, 0 to 100 in steps of 5none
hover.text, .background, .borderColoras color.text, color.background and border.color--<role>
hover.shadow, .opacity, .liftas effects.shadow and effects.opacity; lift none, sm, md, lg--pt-lift-*
hover.transitionnone, fast, normal, slow (normal when a hover value is set)--pt-duration-*
effects.blur, effects.backdropBlurnone, sm, md, lg, xl--pt-blur-*, --pt-backdrop-*
effects.brightness, .contrast, .saturatepercent, 0 to 200 in steps of 5 (100 is unchanged)none
effects.grayscalepercent, 0 to 100 in steps of 5none
hover.blur, .brightness, .contrast, .grayscale, .saturateas the effects filters; each replaces only its own filteras above
background.image, background.videoa same-site path (/media/…) or an http(s) URL of at most 512 URL-safe characters, or none to drop it on a devicenone
background.posteran address as above; one value for every devicenone
background.fit, background.positionas media.fit and media.positionnone
background.attachmentscroll, fixednone
background.overlay, .overlayOpacitya colour role or custom colour; percent in steps of 5 (50 when unset)--<role>
motion.entrancenone, fade, fade-up, fade-down, zoom, slide-left, slide-rightnone
motion.duration, motion.delayfast, normal, slow; none, sm, md, lg--pt-enter-*, --pt-delay-*
motion.repeatonce, repeat; one value for every devicenone
dividers.top, dividers.bottompattern (one value): wave, curve, tilt, triangle, zigzag; height: none, sm, md, lg, xl; color: a colour; flip: none, mirror--pt-divider-*
visibility.desktop, .tablet, .mobilefalse hides the element on that device onlynone

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:

src/styles/tokens.css (excerpt)
: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 explicit layout.display does. Their own breakpoint columns stay until a device gets its own layout.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 --foreground role 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-wide with 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: none runs 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:

ElementType keyShows
Product cardproduct-cardone product: image, name, price, optional badge and add to cart
Product gridproduct-grid2 to 24 products from a collection, a category or a manual pick
Pricepricea product's price, with the original price when on sale
Add to cartadd-to-carta button with the product's options (size, colour…)
Collection listcollection-list2 to 24 tiles linking to collections, or to the subcategories of a category
Countdowncountdowndays, 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" }
    }
  }
}
SourceFields
producttitle, subtitle, description, price, compare_at_price, vendor, image, url
collectiontitle, url
categorytitle, description, url
storename, tagline
customerfirst_name
  • id is 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.
  • field must suit the setting: text settings take text fields (and description), image settings take image, url and link settings take url. 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 for customer. 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) or customers (signed-in shoppers). all is the default and is never stored.
  • from / until: datetime values on the store clock. from is the first moment the element shows, until the 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-offer on the same page scrolls to it. Lower case a-z, 0-9, - and _, unique within the layout file; never translated. Any element may set it without declaring an anchor field.

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
elements1,000 sections and nested blocks together
sections40 sections
children24 children per section or container (a schema's blocks.max may only lower it)
depth8 levels of blocks under a section, in the Customizer and in the theme check alike
style_size12 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_length20,000 characters per string in settings or settings_i18n
file_size1 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:

SettingValues
behaviorsticky (the default), reveal (hides on scroll down, returns on scroll up), static
pinall (the default) or last_row: only the last bar row stays on screen; sticky and reveal only
shrinktrue shrinks the pinned header once the page scrolls; sticky and reveal only
transparentoff (the default), home, all: transparent over the first section
mobile_fromtablet (the default), mobile or desktop: from this size down the menu button replaces the menu bar
drawer_sideleft (the default) or right: the side the mobile menu opens from
drawer_widthsm, 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 :root and 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-underlay on its root; when it opens the page the padding goes and the section pads its own content by --pt-header-underlay (0px everywhere else): padding-top: calc(4rem + var(--pt-header-underlay, 0px)).
  • Offset. --pt-header-height is the live height of the pinned header: the theme's --sticky-top at rest, less the shrink, 0px while hidden or static. --pt-sticky-header (the layout.top: header offset) and --sticky-top follow it, so sticky elements sit right below the header as it moves.
  • Transparent. While transparent the header bar's --background and --border are transparent and --foreground and --muted-foreground read 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 --card bar, 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 search block shown as a field opens its suggestions and results in a popover anchored under the field ([data-search-results]), so the header keeps its height while the shopper types; icon opens the field in such a popover and overlay in 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:

src/sections/hero.tsx
import { overrideSection } from '@poltura/theme-sdk/elements';
import { CatalogHero } from '../components/catalog-hero';

export const hero = overrideSection('hero', CatalogHero);
src/blocks/contact-hero.tsx
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.

src/sections/faq.tsx
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.

src/sections/bento.tsx
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.tsx exports quoteWall, src/blocks/product-title.tsx exports productTitle. Name the export after the type in camelCase.
  • Blocks that only live inside one section (wall-quote above, the mega menu's menuColumn and promoCard) stay in that section's file and are exported from it. A block another section also accepts gets its own file in src/blocks.
  • Helpers that are not elements belong in src/components or src/lib.

The compiler and the code editor report these as issues:

IssueSeverity
Two elements register the same typeerror
The file name does not match the typeerror
A block shares its section's file but another section accepts it tooerror
A defineSection, defineBlock or override is not exported, or lives outside src/sections and src/blockserror
A definition is wrapped (w(defineSection(...))) or called through a namespace (E.defineSection(...))error
An override names a type no provider defineswarning

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 defineSection and defineBlock objects and the same field.* 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.

ProviderWhere it is definedType keysGated by
platform@poltura/theme-elements primitives, platform sectionsbare, reservednever
plugina Poltura core pluginbare; unique across the platform and every pluginthe plugin's own on and off switch
appthe app's sourceapp:<id>/<name>; public apps are keyed per installationinstalled and enabled
themethe theme's TSXbarenever

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 accepts types and duplicate type keys;
  • a section without component that 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 a component; an error in files the change touched and a warning elsewhere;
  • checkout-locked: checkout is platform-only. A theme that defines or overrides the checkout section, 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's cart-checkout button 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 a slot in a component that draws no default or dynamic <Slot> (static-not-rendered), and a region rendered as='ul' or as='ol' that accepts blocks not framed as li (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/>, or asset('<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: and file: addresses (media/unsafe-url), paths nothing serves such as /images/hero.jpg (media/unresolvable-path), a missing asset (media/unknown-asset) and media:// 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.

Next

Last updated