Sections

Reusable parts of your pages with fields anyone can edit: setting types, the template language, styles, limits, versions, copies and add-on sections.

Self-hosted videos, responsive pictures and licensed font uploads extend the template and font primitives described here.

Written 2026-09-27. A storefront page is a list of sections: the hero, the product collection, a band with an offer, a row of testimonials, questions and answers. Every theme comes with its own built-in sections, and you (or your agent) can write sections of your own: a section is written once, placed on as many pages as you like, and edited by anyone in the builder, field by field, without touching code.

Build a section instead of a custom HTML block when a layout repeats, or when someone should be able to change its words, colours or images later. A block is a one-off piece of markup; a section is a small, typed component with its own fields.

Nothing you do with sections reaches a shopper until you publish the storefront. Saving a section changes the draft; publish_storefront makes it live.

1. What a section is

A section is one package of five parts, and a sixth when it needs one:

  1. Settings: up to 40 typed settings (text, a colour, an image, a product, a list of questions…) in up to 8 groups. The builder turns them into fields; an agent fills them in. The same part says where the section may go (which kinds of page, the header or footer) and how many times one page may hold it.
  2. A template: the section's HTML, with {{ }} to print a setting or a store value, {% if %} to show a part only sometimes and {% for %} to repeat a part for each item of a list.
  3. Styles: CSS for this section only. Every rule stays inside the section, so nothing it does can reach the rest of the page.
  4. Presets: named starting points, such as "Announcement" or "With a code", each a set of settings.
  5. Details: an id, a name, a description, a category and a version.
  6. An interaction, optional: behavior is an isolated worker for bounded section UI and commerce actions; it runs only after Scripts in sections is enabled. The older script field remains a separate, more powerful same-origin option. See section behaviours and section scripts.

Each time a section appears on a page is an instance, with its own settings. A promo band placed on the home page and on the catalog is one section and two instances, and changing one instance's text leaves the other alone.

There are four kinds of section in a store:

KindWhere it comes fromCan you change it?
Built-inEvery store has them: the theme's hero, collection, more products and styles sections, each page's own body, and custom HTMLYou place, hide, remove and fill them in; the theme draws them
A theme's ownPicking a theme puts its header, footer and home page sections in your store (themes)Yes: they are your own from then on, and picking the theme again never overwrites them
Your ownYou or your agent saved it with save_sectionYes, and every save is a new version
From an add-onAn add-on you installed ships itNo; make your own copy with fork_section

The theme's four home sections (hero, collection, more products, styles) can be hidden with set-section-visibility or taken off the home page for good with remove-section, for example when your own sections replace them. add-section { sectionId: "hero" } puts a removed one back, once. The one rule left for the home page: it has to show at least one visible section or block (home_empty). Every other page keeps its own body: the theme's (main), or on the product, catalog and cart pages a section of your own made to be that page's body (§6a).

Your own header and footer. A section whose slots include "header" (or "footer") can take the theme's own header (or footer) away: place one instance in that slot and every page shows your section instead, inside the page's header (or footer) landmark. The slot holds one section; leave it empty, or hide its instance, and the theme's own comes back. The payment page always keeps the theme's own. A header section can stay at the top of the window while the page scrolls: give its schema sticky: true (its slots must include "header") and the store makes its own header landmark sticky, above the page and below the store's own layers. In the builder's edit mode the header stays in place, so it never covers what you select under it. "header.top" and "footer.top" are different: they add sections and blocks above the theme's header and footer and keep it.

2. The package

FieldWhat it is
schemaVersionAlways 1: the version of the section format.
id2 to 40 lower-case letters, digits and single hyphens, starting with a letter. Built-in ids and layout words (hero, main, start, page and the like) are taken.
nameWhat the builder calls it, up to 60 characters.
descriptionOne or two sentences for the builder's section picker, up to 400 characters.
categoryOne of hero, text, products, collections, media, social-proof, promotion, layout, custom.
versionmajor.minor.patch, such as 1.2.0. Leave it out when saving and the next version is chosen from what changed.
author{ kind, name }: kind is "owner" for the store's own sections. Optional.
origin"store" for the store's own; add-on sections are read-only. Optional.
appOnly on an add-on's sections: the add-on that ships it.
forkedFromSet for you on a copy made with fork_section: { id, version, origin }.
renderingAlways "template" for your own sections. Optional.
schema{ settings, groups, pages, slots, maxInstancesPerPage, elements, fieldOnly, sticky, body }: the settings the builder and agents fill in, where the section may go and how often; elements and fieldOnly: part editable; sticky: true keeps a header section (slots include header) at the top of the window; body: a page kind makes it that page's body (the *-page parts). Up to 40 settings in up to 8 groups; pages from home, catalog, product, page, cart, blog, post (default home and page); slots from header.top, footer.top, header, footer (default none); maxInstancesPerPage 1 to 60 (default 10).
templateThe section's markup with {{ }} output, {% if %} and {% for %}, up to 32 KB, in the template language described below.
stylesCSS for this section only, up to 16 KB. Every rule is confined to the section; :scope or & means the section itself. Colour, number, range, select, on/off and image settings arrive as var(--fh-s-<setting in kebab case>).
scriptOptional legacy same-origin module; new or changed source requires a signed-in person. Agents and apps use isolated behavior instead.
behaviorOptional isolated section behaviour worker; export default function mount(api). Bounded UI, overlay, commerce and section storage bridge. See describe_sections part behaviors.
artworkOptional: { name: svg text }, up to 32 of 32 KB, for the icon tag.
presetsUp to 12 named starting points: { id, name, description?, settings } with some of the settings filled in.

3. Setting types

TypeWhat it holdsIts own fields
textOne line of text, up to 600 characters.default, maxLength, placeholder, required, inlineEditable
textareaPlain text over several lines, up to 4,000 characters.default, maxLength, placeholder, required, inlineEditable
richtextFormatted text: paragraphs, bold, italic, links, lists and small headings, up to 8,000 characters.default, maxLength, required, inlineEditable
numberA number, with an optional minimum, maximum, step and unit.default, min, max, step, integer, unit
rangeA number picked on a slider between a minimum and a maximum.min (required), max (required), step (required), default (required), unit
booleanOn or off.default
selectOne choice from a list of 2 to 24 options.options (required), default (required), display
colorA colour as #rrggbb, or #rrggbbaa with an opacity pair, or empty for the theme's own.default
fontA family from the font list (part fonts) and the weights used, or empty for the theme's own.default, weights, italic
imageAn image from your store's media library.default, required
linkWhere a button or link goes: a page on your store, an https:// address, mailto: or tel:.default
productOne of your products, by its handle.default
collectionOne of your collections, by its handle.default
urlAn outside https:// address.default
listRepeated items of the same shape, such as questions or testimonials, up to 24.fields (required), itemLabel, minItems, maxItems, default
htmlYour own HTML (built-in custom HTML section only).default
cssYour own CSS for this section (built-in custom HTML section only).default

Every setting also has key (letters, digits and _, starting with a letter; the template reads it as settings.<key>), label, and optionally help and group. inlineEditable lets people type a text, textarea or rich text setting on the page itself in the builder.

Every default has to fit its own setting: a default colour is a real #rrggbb, a default link is a real address, a default list's items have the list's fields. A setting a template reads but the section does not declare is refused when you save.

Images are always your store's own. Upload one first (upload_image, or the media library) and use its key (products/<id>.jpg) or its /media/ path; a full address on your own store's domain is turned into its path for you, and an image anywhere else is refused, so a section can never show something from a site you do not control.

image_url: 800 asks your store for a copy no wider than about 800 pixels. Copies are made at a fixed set of widths (64 up to 2,400, rounded up to the next one) and kept, so a large photo is sent at the size the page shows it; if a copy cannot be made, the original is sent instead and the page still shows the image.

4. Templates

A template is ordinary HTML with three additions: {{ }} prints a value, {% if %} shows a part only when a condition holds, and {% for %} repeats a part for each item of a list. A template reads only the objects listed below, and a template cannot fetch, include another file, run code or keep variables of its own (interactive behaviour goes in the section's isolated behaviour, or a legacy script). Every object, filter and tag comes with an example and exactly what it renders.

Tags

TagSyntaxWhat it doesExampleRenders
output{{ value | filter: argument }}Prints a value, escaped. Chain up to 6 filters with |.<p>{{ settings.tagline }}</p><p>Cups &amp; &lt;saucers&gt;</p>
if{% if condition %}…{% elsif condition %}…{% else %}…{% endif %}Shows a part only when a condition holds. elsif and else are optional; conditions may use filters, the operators, and, or and not.{% if settings.showBadge %}<span>Sale</span>{% else %}<span>New</span>{% endif %}<span>Sale</span>
for{% for item in list | reverse limit: 8 offset: 0 %}…{% else %}…{% endfor %}Repeats a part for each item of a list; | reverse (optional) turns it round first. limit (a whole number or a number setting) is at most 50, and a loop without one stops there; offset skips items first. The else part shows when the list is empty. Inside, loop tells you where you are.{% for product in settings.shelf.products limit: 8 %}{{ product.title }};{% endfor %}Blue mug;Side plate;
for-else{% for item in list %}…{% else %}…{% endfor %}What to show when the list has nothing in it.{% for question in settings.questions %}{{ question.question }}{% else %}Nothing yet{% endfor %}Nothing yet
comment{# a note #}A note for whoever edits the template; never printed.A{# not shown #}BAB
widget{% widget "newsletter" label: "…" placeholder: "…" button: "…" note: "…" %}Places a store control: newsletter, search, cart-button, account, quick-add (part widgets). Arguments are quoted text or a setting (empty takes the default). Between elements, outside loops, at most 4.{% widget "account" label: "Sign in" %}<a class="fh-widget fh-widget-account" data-fh-widget="account" href="/account"><span class="fh-widget-label">Sign in</span></a>
icon{% icon "star" size: 20 label: "…" %}Draws an icon (part icons) in the text colour; the name is quoted text or a setting. size 12 to 96 (24). A label makes it announced. Between elements only. "art:<name>" draws its artwork; size is the width, 8 to 1600.{% icon "check" size: 16 %}<svg class="fh-icon fh-icon-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12l5 5l10 -10"/></svg>

Operators: == (equal), != (not equal), > (greater than (numbers)), < (less than (numbers)), >= (greater than or equal (numbers)), <= (less than or equal (numbers)), contains (a list holds a value, or a text holds another text), and (both are true), or (either is true), not (the opposite, written before a value).

Everything printed with {{ }} is HTML-escaped (& < > " and ' become entities), so text from settings or products never becomes markup. | raw prints a value as HTML; the section's markup, raw parts included, keeps a custom HTML block's rules (no scripts, event handlers or inline styles; links only to your store, https, mailto: and tel:).

In a condition, false, empty (null), an empty text, 0 and an empty list count as false; everything else counts as true. Printing empty prints nothing; printing true or false prints the word; printing an image, product or collection prints nothing (print one of its fields instead).

Text outside {{ }} and {% %} is printed exactly as written, spaces and new lines included.

Objects

settings (every section): This instance's settings by key. Product, collection and image settings are objects (empty when none is chosen); a list setting is a list of items.

FieldTypeMeaning
<key>settingOne setting's value, typed by its definition.

Example: <h2>{{ settings.heading }}</h2> renders <h2>Winter sale</h2>.

section (every section): This instance and where it sits.

FieldTypeMeaning
idtextThe instance id, unique on the page; an id attribute shows as fhc-<id>.
typetextThe section's id.
versiontextThe section version being shown.
indexnumberIts position among the page's sections, from 1.
firstbooleanTrue for the page's first section.
lastbooleanTrue for the page's last section.

Example: <div id="{{ section.id }}"> renders <div id="promo">.

store (every section): Your store.

FieldTypeMeaning
nametextThe store's name.
headlinetextThe store headline from the theme settings.
introtextThe store introduction from the theme settings.
currencytextThe store currency, such as GBP.
localetextThe store language and region, such as en-GB.
logoimage?The store logo, when set.
socialProfileslist<socialProfile>The store's social profiles.
contactcontactHow to reach the store, as its footer shows it.

Example: {{ store.name }} renders Hallo Ceramics.

page (every section): The page being shown.

FieldTypeMeaning
kindtexthome, catalog, product, cart or page (a content page).
titletextThe page's title.
slugtextA content page's slug; empty elsewhere.
urlurlThe page's address.
productproduct?The product, on a product page.
collectioncollection?The collection, on a collection's listing.

Example: {% if page.kind == "home" %}Welcome{% endif %} renders Welcome.

cart (every section): A summary of the shopper's cart.

FieldTypeMeaning
itemCountnumberUnits in the cart.
subtotalmoneyThe cart subtotal in minor units.
currencytextThe cart currency.

Example: {{ cart.itemCount }} items renders 2 items.

request (every section): The address being shown.

FieldTypeMeaning
pathtextThe path of the page, such as /collections/mugs.

Example: {{ request.path }} renders /.

routes (every section): Addresses of your store's own pages, so links keep working on any domain.

FieldTypeMeaning
homeurlThe home page.
catalogurlEvery product.
carturlThe cart.
searchurlSearch results; a form sends q.
accounturlThe shopper's account: sign-in and orders.

Example: <a href="{{ routes.catalog }}">Shop</a> renders <a href="/collections/all">Shop</a>.

navigation (every section): The store's menus: the merchant's links, or the theme's when none are set.

FieldTypeMeaning
headerlist<menuItem>The header menu.
footerlist<menuItem>The footer menu.
trackingtrackingLink?Track your order, for a footer to print where it likes; a template that reads it gets footer without it. Empty when footer links there.

Example: {% for link in navigation.header %}<a href="{{ link.href }}">{{ link.label }}</a>{% endfor %} renders <a href="/collections/all">Shop</a><a href="/pages/about">About</a>.

blog (every section): The store's latest published posts; empty without posts.

FieldTypeMeaning
urlurlThe blog page.
postslist<post>Up to 12, newest first.

Example: {% for post in blog.posts limit: 3 %}<a href="{{ post.url }}">{{ post.title }}</a>{% endfor %} renders <a href="/blog/first-firing">The first firing</a>.

now (every section): Today, in UTC.

FieldTypeMeaning
yearnumberThe year, such as 2026.
datedateToday, such as 2026-09-27.

Example: &copy; {{ now.year }} {{ store.name }} renders &copy; 2026 Hallo Ceramics.

builder (every section): Whether the page is drawn in the builder.

FieldTypeMeaning
editingbooleanTrue only in the builder: draw a placeholder for an empty state. False for shoppers and preview links.

Example: {% if builder.editing %}Pick products{% else %}Shop{% endif %} renders Shop.

loop (inside {% for %}): Where the innermost for loop is.

FieldTypeMeaning
indexnumberThe item's position, from 1.
index0numberThe item's position, from 0.
firstbooleanTrue for the first item.
lastbooleanTrue for the last item.
lengthnumberHow many items the loop shows.
itemIdtextOver a list setting, the item's id (tiles-2) for data-fh-item; styles read its fields as var(--fh-i-<field>).

Example: {% for product in settings.shelf.products %}{{ loop.index }}.{{ product.title }} {% endfor %} renders 1.Blue mug 2.Side plate .

image (image settings, store.logo, product.image, collection.image): An image from your store's media library.

FieldTypeMeaning
srcurlThe image's address on your store.
alttextIts description for screen readers; empty when none was given.
widthnumber?Its width in pixels, when known.
heightnumber?Its height in pixels, when known.

Example: <img src="{{ settings.image \| image_url: 800 }}" alt="{{ settings.image.alt }}"> renders <img src="/media/products/mug.jpg?width=800" alt="A blue mug">.

product (product settings, page.product, collection.products): One of your products.

FieldTypeMeaning
titletextThe product's name.
handletextIts handle, the last part of its address.
urlurlIts page on your store.
pricemoneyIts price in minor units; print it with | money.
compareAtPricemoney?The earlier price shown struck through, when set.
priceFrommoney?The lowest option price, when the product has options.
hasVariantsbooleanTrue when shoppers pick options such as size.
availablebooleanTrue when it can be bought now.
onSalebooleanTrue when compareAtPrice is higher than price.
isNewbooleanTrue while the store's New badge applies to it.
imageimage?Its main image.
imageslist<image>Every image, main image first.
descriptiontextIts description as plain text.
tagslist<text>Its tags.
createdAtdateWhen it was added; print it with | date.
discountPercentnumberThe sale as a whole percentage, as the theme's badge shows it; 0 when not on sale.
inventorynumber?Units in stock; empty when unknown.
lowStockbooleanTrue when in stock with 10 or fewer left.

Example: {{ settings.featured.title }}: {{ settings.featured.price \| money }} renders Blue mug: £24.00.

collection (collection settings, page.collection): One of your collections. A collection setting set to "all" is every product, newest first, and its url is routes.catalog.

FieldTypeMeaning
titletextThe collection's name.
handletextIts handle.
urlurlIts page on your store.
descriptiontextIts description as plain text.
imageimage?Its image, or its first product's.
productCountnumberHow many products it holds.
productslist<product>Its products in the collection's order, at most 50.

Example: {{ settings.shelf.title }} ({{ settings.shelf.productCount }}) renders Mugs (2).

menuItem (navigation.header, navigation.footer): One link of a menu.

FieldTypeMeaning
labeltextThe link's text.
hrefurlWhere it goes.
currentbooleanTrue when it points at the page being shown.

Example: {% for link in navigation.header %}{% if link.current %}<span aria-current="page">{{ link.label }}</span>{% endif %}{% endfor %} renders <span aria-current="page">Shop</span>.

trackingLink (navigation.tracking): The order tracking link; print it as a marked element.

FieldTypeMeaning
labeltextIts text.
urlurlThe tracking page.
currentbooleanOn that page.

Example: {% if navigation.tracking %}<a data-fh-el="tracking" href="{{ navigation.tracking.url }}">{{ navigation.tracking.label }}</a>{% endif %} renders <a data-fh-el="tracking" href="/orders/track">Track your order</a>.

post (blog.posts): A published post.

FieldTypeMeaning
titletextIts title.
urlurlIts page.
imageimage?Its cover.
excerpttextIts summary.
datedatePublished on.

Example: {% for post in blog.posts %}<p>{{ post.date \| date: "long" }}: {{ post.excerpt }}</p>{% endfor %} renders <p>2 September 2026: How the new glaze came out.</p>.

contact (store.contact): The store's contact details from its store profile; each part is empty until the merchant gives it.

FieldTypeMeaning
legalNametextThe business's legal name.
emailtextThe support email address.
phonetextThe phone number.
addressaddressThe postal address.

Example: {% if store.contact.email %}<a href="mailto:{{ store.contact.email }}">{{ store.contact.email }}</a>{% endif %} renders <a href="mailto:hello@hallo.example">hello@hallo.example</a>.

address (store.contact.address): A postal address; all of it is empty until a first line and a country are given.

FieldTypeMeaning
line1textThe first line.
line2textThe second line.
citytextThe town or city.
regiontextThe county, state or region.
postalCodetextThe postcode.
countrytextThe country, as the store profile holds it.

Example: {{ store.contact.address.city }} {{ store.contact.address.postalCode }} renders Leeds LS1 1AA.

socialProfile (store.socialProfiles): One of the store's social profiles.

FieldTypeMeaning
platformtextinstagram, tiktok, x and so on: also its icon's name.
labeltextIts name, such as Instagram.
urlurlThe profile's https address.

Example: {% for profile in store.socialProfiles %}<a href="{{ profile.url }}">{{ profile.label }}</a>{% endfor %} renders <a href="https://instagram.com/hallo">Instagram</a>.

Filters

FilterTakesArgumentsWhat it doesExampleRenders
uppertextnoneCapital letters.{{ settings.heading | upper }}WINTER SALE
lowertextnoneSmall letters.{{ settings.heading | lower }}winter sale
truncatetextlength (optional)Shortens text to a length, ending with … when it was cut.{{ store.headline | truncate: 10 }}Stoneware…
moneymoneyformat (optional)A price in minor units in the store currency, written exactly as the theme writes prices ($42.00, £24.00).{{ cart.subtotal | money }}£48.00
datedateformat (optional)A date written out.{{ settings.featured.createdAt | date: "long" }}20 September 2026
image_urlimage, product or collectionwidthThe address of an image (a product's or collection's main image) for a given width. Empty when there is no image.{{ settings.featured | image_url: 400 }}/media/products/mug.jpg?width=400
urlproduct, collection or textnoneThe page address of a product or collection; a link text passes through when it is a safe link.{{ settings.featured | url }}/products/blue-mug
defaultanythingfallbackA fallback for an empty value.{{ store.logo.alt | default: store.name }}Hallo Ceramics
sizelist or textnoneHow many items a list holds, or characters a text holds.{{ settings.shelf.products | size }}2
joinlist of textseparator (optional)One text from a list.{{ settings.featured.tags | join: ", " }}mugs, blue
firstlistnoneThe first item. Without a loop, a path takes one: list.first, list.last, list.0.{{ settings.featured.tags | first }}mugs
lastlistnoneThe last item of a list.{{ settings.featured.tags | last }}blue
reverselistnoneThe list backwards.{{ settings.featured.tags | reverse | join: ", " }}blue, mugs
filltextvaluePuts a value where the text says {n}.{{ "Only {n} left" | fill: settings.discount }}Only 20 left
initialstextnoneA name's initials (first two words), in capitals.{{ store.name | initials }}HC
plusnumberamountAdds a number.{{ settings.discount | plus: 5 }}25
minusnumberamountTakes a number away.{{ settings.discount | minus: 5 }}15
timesnumberfactorMultiplies.{{ settings.discount | times: 2 }}40
divided_bynumberdivisorDivides, keeping decimals; add | round for a whole number.{{ settings.featured.price | divided_by: 100 }}24
roundnumberplaces (optional)Rounds to the nearest.{{ settings.discount | divided_by: 3 | round: 1 }}6.7
floornumbernoneRounds down to a whole number.{{ settings.discount | divided_by: 3 | floor }}6
ceilnumbernoneRounds up to a whole number.{{ settings.discount | divided_by: 3 | ceil }}7
absnumbernoneThe number without its sign.{{ settings.discount | minus: 25 | abs }}5
rawtextnonePrints the value as HTML instead of escaping it. It still follows the same rules as a custom HTML block, so it can format text but never run code.{{ settings.note | raw }}<strong>Free</strong> delivery

Widgets

WidgetWhat it isArguments
newsletterThe store's newsletter sign-up: an email field and a button. Addresses are confirmed by email before anything is sent.label What screen readers call the form. (Newsletter); placeholder The hint inside the email field. (Email address); button The button's text. (Subscribe); note The consent line under the form. (Marketing email only. Unsubscribe any time.); fieldLabel A visible label for the email field (fh-widget-field-label, after the field); none when empty.; noteLinkLabel A link in the note, such as your privacy page: its text; it goes where the note says {link}, or after the note.; noteLinkHref Where that link goes: a page of your store (/pages/privacy).; noteLink2Label A second link in the note, at {link2}, or after the first.; noteLink2Href Where the second link goes: a page of your store.
searchA search box that opens the store's search results; it shows what the shopper searched for.label What screen readers call the search. (Search); placeholder The hint inside the field. (Search products); button The button's text. (Search)
cart-buttonA link to the cart with the number of items in it (shown when there are any).label The link's text. (Cart); icon An icon name to show before the text; none when empty.
accountA link to the shopper's account: sign-in, orders and addresses.label The link's text. (Account); icon An icon name to show before the text; none when empty.
quick-addOne click adds a named product (or one of its options) to the cart, with the store's own add form: a product setting or a handle, never a price. The shopper stays on the page and the cart count updates; on the cart page the page reloads with the new totals; without a script the form still posts. A product with options needs variant:, else it links to its page.product The product: a product setting (settings.gift), the page's product, or a handle in quotes.; variant The option to add, by id, for a product with options (such as product.selectedVariant.id).; quantity How many, 1 to 10. (1); label The button's text. (Add to cart); addedLabel What the button's status says once it is added. (Added); chooseLabel The link's text for a product with options and no variant. (Choose options); soldOutLabel What it says when nothing is left. (Sold out); el An element key: the merchant can select, move and hide it in the builder.

Recipes

search: The search widget with its own hint; on the search results page it shows what was searched for.

Example: {% widget "search" placeholder: "Find a mug" %} renders <form class="fh-widget fh-widget-search" data-fh-widget="search" method="get" action="/search" role="search" aria-label="Search"><input class="fh-widget-input" type="search" name="q" maxlength="80" autocomplete="off" placeholder="Find a mug" aria-label="Find a mug"><button class="fh-widget-button" type="submit">Search</button></form>.

social-icons: Each social profile as its platform's icon, announced by its name.

Example: {% for profile in store.socialProfiles %}<a href="{{ profile.url }}">{% icon profile.platform size: 20 label: profile.label %}</a>{% endfor %} renders <a href="https://instagram.com/hallo"><svg class="fh-icon fh-icon-instagram" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Instagram" focusable="false"><path d="M4 8a4 4 0 0 1 4 -4h8a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-8a4 4 0 0 1 -4 -4l0 -8"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M16.5 7.5v.01"/></svg></a>.

item-colours: A list whose items each have their own colour: give the list a colour field, put data-fh-item on each item's element, and read the field in styles as var(--fh-i-<field>). No style attribute needed.

Example: {% for tile in settings.tiles %}<li data-fh-item="{{ loop.itemId }}">{{ tile.title }}</li>{% endfor %} renders <li data-fh-item="tiles-1">Mugs</li><li data-fh-item="tiles-2">Plates</li>.

fonts: A font setting names a family from the font list; the section loads only the weights the setting declares and exposes the family as var(--fh-s-<setting>), so styles write font-family:var(--fh-s-heading-font,inherit). Printing it gives the family's name.

Example: <p>Set in {{ settings.headingFont }}</p> renders <p>Set in Fraunces</p>.

header: A section with slots: ["header"] (or "footer") placed in that slot replaces the theme's own header (or footer) on every page but the payment page. Build it from store.logo, navigation, the cart-button, search and account widgets, and details and summary for a menu that opens on small screens. schema.sticky: true keeps a header at the top of the window while the page scrolls.

Example: <a href="{{ routes.home }}">{{ store.name }}</a>{% widget "cart-button" %} renders <a href="/">Hallo Ceramics</a><a class="fh-widget fh-widget-cart-button" data-fh-widget="cart-button" href="/cart" aria-label="Cart (2)"><span class="fh-widget-label">Cart</span><span class="fh-widget-count" aria-hidden="true">2</span></a>.

main-heading: A section may hold one h1, outside loops, for the page's main heading (a hero's). The page keeps the first h1 it meets and shows any later one as an h2 with the same attributes; on catalog, product and content pages the theme's own title is the h1, so a section's h1 shows as an h2 there.

Example: <h1 data-fh-el="heading">{{ settings.heading }}</h1> renders <h1 data-fh-el="heading">Winter sale</h1>.

one-item: One item of a list without a loop, such as the first product of a collection as the page's h1: .first, .last or a position from 0 (up to 99) as a step of the path. An item that is not there prints nothing.

Example: <h1>{{ settings.shelf.products.first.title }}</h1><p>{{ settings.shelf.products.1.title }}</p> renders <h1>Blue mug</h1><p>Side plate</p>.

all-products: A collection setting set to "all" (a default, a preset or the merchant's choice) is the whole catalogue, newest first, capped like any collection; loop over its products with limit: and offset:.

Example: <a href="{{ settings.everything \| url }}">{{ settings.everything.title }}</a> renders <a href="/collections/all">All products</a>.

sale-badge: The theme's sale pill, from the product's own numbers.

Example: {% if settings.featured.onSale %}<span class="pill">-{{ settings.featured.discountPercent }}%</span>{% endif %} renders <span class="pill">-20%</span>.

field-label: fieldLabel gives the newsletter's email field a visible label (fh-widget-field-label, after the field inside fh-widget-field). Without a placeholder of its own the field's placeholder is then a space, so styles can float the label: .fh-widget-input:not(:placeholder-shown)+.fh-widget-field-label.

Example: {% widget "newsletter" fieldLabel: "Email" %} renders <form class="fh-widget fh-widget-newsletter" data-fh-widget="newsletter" method="post" action="/api/newsletter" aria-label="Newsletter"><span class="fh-widget-field"><input class="fh-widget-input" id="fh-promo-w1" type="email" name="email" required maxlength="254" autocomplete="email" placeholder=" "><label class="fh-widget-field-label" for="fh-promo-w1">Email</label></span><input class="fh-widget-trap" name="company" tabindex="-1" autocomplete="off" aria-hidden="true"><button class="fh-widget-button" type="submit">Subscribe</button><p class="fh-widget-note">Marketing email only. Unsubscribe any time.</p></form>.

Not available

  • Other files: no include, render, layout or import.
  • Code: no scripts, event handlers or javascript: links. Use details and summary, or a section script.
  • Forms, inputs and svg: use {% widget %} and {% icon %}. Section templates may write buttons for interaction controls: type=button, class, aria and inert data markers survive; submit behaviour and form attributes are refused.
  • Inline styles: removed. Use the section's styles and CSS variables.
  • Network: a template fetches nothing; it sees only the objects here.
  • Variables: no assign or capture. Use filters in place, and list.first for one item.
  • Other stores' data, the shopper's personal details, orders and customer records.

Headings

A section may hold one <h1>, outside any {% for %}: the page's main heading, such as a hero's. To print one item of a list outside a loop (the first product of a collection as the h1), take it with a step of the path: .first, .last, or a position from 0 up to 99. <h1>{{ settings.shelf.products.first.title }}</h1> prints the first product's name; an item that is not there prints nothing. A page keeps only one: the first h1 it meets, in page order, and every later section h1 is written as an h2 with the same attributes, so two hero sections never make two main headings. On catalog, product, cart and content pages, and on the home page while the theme's own hero is on it, the theme already draws the page's h1, so a section's h1 there is written as an h2. A page body section (§6a) is the exception: the theme no longer draws that page's heading, so the body's own h1 stays the page's h1. Mark it (data-fh-el) and style it exactly as you would an h2. A custom HTML block still starts at h2.

Every product, today and the store's details

  • A collection setting can be "all": the store's whole catalogue, newest first, as the catalogue page shows it, capped like any collection. Use it as a default, in a preset or from the builder, where the collection picker offers All products first. {% for product in settings.shelf.products limit: 8 offset: 8 %} pages through it, and {{ settings.shelf | url }} is the catalogue page (routes.catalog).
  • now.year and now.date are today, in UTC: &copy; {{ now.year }} {{ store.name }}. A page that shows them is never served from yesterday's copy.
  • store.contact is how to reach the store, exactly what the theme's footer shows: legalName, email, phone and address (line1, line2, city, region, postalCode, country), each empty until the merchant fills in the store profile. The address is empty until it has a first line and a country.
  • A product knows its sale as product.discountPercent (the theme's -20%, 0 when not on sale), its stock as product.inventory (empty when unknown) and product.lowStock (10 or fewer left).
  • | money writes a price exactly as the theme does ($42.00, £24.00), so a section's prices and the theme's agree. Every currency is read in the unit checkout charges in: whole units for yen and the like, thousandths for BHD, JOD, KWD, OMR and TND, hundredths for the rest.
  • plus, minus, times, divided_by, round, floor, ceil and abs do arithmetic: {{ product.price | divided_by: 100 | round }}. Dividing by zero gives 0, and a result is always a number.
  • fill puts a value into a text where it says {n}: {{ settings.stockLine | fill: product.inventory }} with "Only {n} left" prints "Only 3 left", escaped like any value.
  • {% for post in blog.posts | reverse %} walks a list backwards. | reverse is the one filter a loop takes, written straight after the list; offset: and limit: then apply to the turned list, so {% for item in settings.steps | reverse limit: 3 %} shows the last three items, last first. (This differs from some other template languages, which cut first and turn after.) Each item keeps its own loop.itemId.
  • builder.editing is true only when the page is drawn in the builder (its edit mode, or the dashboard's own preview), so a section can show the merchant a placeholder where a shopper would see nothing: {% if settings.shelf.products %}…{% elsif builder.editing %}<p class="hint">Pick a collection</p>{% endif %}. It is false on every shopper's page, every preview link and every cached page.
  • The hidden attribute is kept on any element of a template or a custom HTML block, written bare (<p hidden>, whatever value it came with): the element is not shown until a script or a style shows it.
  • initials is a name's monogram, the first letters of its first two words in capitals: {{ store.name | initials }} is "OB" for Oak & Bloom. reverse turns a list round: {{ settings.featured.tags | reverse | join: ", " }}.
  • blog.posts is the store's latest published posts, newest first, at most 12, on any page: each has title, url, image (its cover, empty without one), excerpt and date (print it with | date). blog.url is the blog's page. With no published posts the list is empty, so wrap a journal band in {% if blog.posts %}. The posts are read only for a page whose sections read blog.
  • A menu link's current is true on the page it points at. A link with a query string (/collections/all?sort=newest) is current only when the page's query matches too, in any order; a link without one is current on its path whatever the query.

Accessibility and loading

A template may write aria-label, aria-labelledby, aria-describedby, aria-controls, aria-hidden, aria-expanded, aria-current, role (from a fixed list of landmarks and plain roles) and tabindex (0 or -1) on any element, and loading (eager or lazy), fetchpriority and decoding on an image; each value is checked, exactly as for a custom HTML block. An image without loading gets loading="lazy"; write loading="eager" fetchpriority="high" on the picture at the top of the page. A link to a page of your own store (a path or #anchor) carries no nofollow.

Store controls and icons

A template cannot write a form, an input or an svg: use the widgets and icons below. It may write a script control button: <button type="button" class="next" aria-label="Next slide">Next</button>. Only class and supported ARIA attributes survive, and its type is always button; it cannot submit a form. Merchant custom HTML blocks still refuse buttons.

  • {% widget "newsletter" %}, {% widget "search" %}, {% widget "cart-button" %} and {% widget "account" %} place your store's own sign-up form, search box, cart link (with the number of items in it) and account link, working exactly as the theme's do. They take text arguments (label:, placeholder:, button:, note:, icon:, each quoted text or a setting) and carry classes such as fh-widget-input and fh-widget-button, so the section's styles decide how they look. The newsletter's consent line can link to your own pages: noteLinkLabel: and noteLinkHref: (a path on your store, such as /pages/privacy), and a second pair noteLink2Label: and noteLink2Href:. Each link goes where the note says {link} ({link2}), or after the note: note: "By subscribing you agree to our {link} and {link2}." noteLinkLabel: "Privacy policy" noteLinkHref: "/pages/privacy" noteLink2Label: "Terms" noteLink2Href: "/pages/terms". A link to anything but a path on your store is left out. The newsletter also takes fieldLabel:, a visible label for the email field (<label class="fh-widget-field-label">, right after the field inside fh-widget-field); without a placeholder: of its own the field's placeholder is then a space, so styles can float the label with .fh-widget-input:not(:placeholder-shown)+.fh-widget-field-label. At most four per template, never inside a loop.
  • {% widget "quick-add" product: settings.gift label: "Add to cart" el: "gift" %} puts one named product in the cart with one click: product: is a product setting, the page's product, or a handle in quotes (product: "linen-tote"); variant: names one option of a product with options (variant: product.selectedVariant.id; without one the widget links to the product's page, saying chooseLabel:), and quantity: is 1 to 10. It is your store's own add form, carrying the handle, the option and the quantity and never a price: the store prices it from the catalogue, exactly as the add-to-cart button does. With the store's script the shopper stays on the page, the cart count updates and the button's status says addedLabel: ("Added"); on the cart page the page reloads with the new totals; without a script the form posts as usual. It works in any section, on any page but the payment page, where it draws nothing; in the builder it is an inert group. Classes: fh-widget-button, fh-widget-label, fh-widget-status, fh-widget-sold-out.
  • {% widget "buy-now" product: settings.product quantity: 1 discount: "FLASH20" label: "Buy now" el: "buy" %} takes the shopper straight to checkout with one named product, past the cart: a flash sale on the home page, or any page. product: is a product setting, the page's product, or a handle in quotes; variant: names one option of a product with options (without one the button becomes a link to the product's page, with the same classes, saying chooseLabel: or the label); quantity: is 1 to 10 and never more than the store has; discount: is a discount code the checkout applies when it is valid for this order, and ignores (full price) when it is not. It is the same form the product page's Buy now posts, carrying the handle, the option, the quantity and the code, never a price or an amount: the checkout refuses a post from another site, prices the product from the catalogue and checks the stock and the code again. It draws nothing when the product cannot be bought: sold out, not published, a handle that names nothing, or a store not taking payments yet; nothing on the payment page, and an inert group in the builder. Without product: it is the product page's own button, joined to the page's product form, as in "Page bodies". Its root is the product page's (fh-widget fh-widget-buy-now, data-fh-widget="buy-now"), its button fh-widget-button fh-widget-buy; with el: the merchant selects, moves, resizes and layers it like any element. Not inside a loop.
  • {% icon "star" size: 20 %} draws one of more than a hundred icons (arrows, carts, bags, delivery, social networks and more; describe_sections { parts: ["icons"] } lists them) in the colour of the text around it. Give it label: when it means something on its own, and screen readers read the label; without one they skip it.
  • Artwork. A section can carry its own drawings (a design kit's icons, ornaments and small illustrations) in the package: "artwork": { "leaf": "<svg viewBox=\"0 0 32 32\">…</svg>" }, names of lower-case letters, digits and hyphens, at most 32 drawings, each at most 32 KB and 160 KB together. {% icon "art:leaf" %} draws it inline; size: is its width in pixels (8 to 1600, the height follows the viewBox), without it the drawing keeps its own size, and label: works as for icons. Colours are drawn as written: write currentColor where the drawing should take the text colour. Each drawing is checked when the section is saved and again when it is drawn: no script, style element, text, image, link, animation, event attribute or reference outside the drawing (only url(#id) and href="#id"), and a drawing that does not pass is refused with the reason (artwork_invalid, artwork_limit). The page gets the drawing rewritten: its own root attributes replaced (class fh-art fh-art-<name>, size, viewBox, its fill and stroke kept) and every id inside it given a prefix, so two drawings never share a gradient or clip path. It is decoration, never an element the merchant edits; style it with .fh-art (for example max-width:100%;height:auto). A setting's value always names a built-in icon, never artwork. A store control that takes icon: takes a drawing too: {% widget "cart-button" icon: "art:bag" %} draws it at its own size in the icon's place.

A header section is usually store.logo or store.name, a loop over navigation.header (each link knows whether it is the page being shown), the search, cart and account widgets, and details and summary for a menu that opens on a phone. A footer section loops over navigation.footer and store.socialProfiles, whose platform is also the name of its icon.

The order tracking link. Shoppers who lost their shipping email find their order on the store's tracking page, so every footer offers "Track your order". A footer prints it where its design has a place, as navigation.tracking (label, url, current), marked so the merchant can move or hide it:

{% for link in navigation.footer %}<a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}
{% if navigation.tracking %}<a data-fh-el="tracking" href="{{ navigation.tracking.url }}">{{ navigation.tracking.label }}</a>{% endif %}

with { key: "tracking", label: "Track your order link", type: "link" } in schema.elements. A template that reads navigation.tracking anywhere gets navigation.footer as the merchant's links alone (the theme's defaults when there are none). A template that never reads it gets the footer menu followed by the tracking link, exactly as every footer did before 2026-09-27, so no published footer changed. navigation.tracking is empty when the footer menu already links to the tracking page. The theme's own footers always show it.

Your home page's module slot. A section for the home page may place {% slot "home.below" %}, where the store's home modules show (the collection grid, add-on blocks): they then show there instead of below the page's sections, once. A page body places its own page's slots the same way (§6a).

Ids and anchors. An id a template writes is shown with fhc- in front, so no section can take an id the page already uses: id="{{ section.id }}" is id="fhc-promo" on the page. A link inside a section to #promo is written #fhc-promo the same way, so it keeps working; a menu link or another page writes the prefix itself: /#fhc-promo.

5. Editable elements

A merchant edits a section in the builder the way they would in a design tool: they select one element inside it (a heading, a paragraph, a button, a picture) and type into its text, or align, resize, hide, reorder or nudge it, separately for phones if they like. They can only select what the template marks, so marking is part of writing a section, not an extra.

In the builder a merchant selects one element inside a section and types into its text, or aligns, resizes, hides, reorders or nudges it, on desktop and on phones. They can only select what the template marks. Define the marks correctly and the merchant can change your section's words and positions without you; leave them out and the section can only be moved as a whole.

  • Put data-fh-el="<setting key>" on the element that prints each text, textarea, rich text, image, link or url setting: the h2 that prints settings.heading, the img that shows settings.image, the a that uses settings.ctaLink. The mark's own attributes count as inside it, so alt="{{ settings.imageAlt }}" on a marked img and href on a marked a need nothing more.
  • A button or link whose label is a setting of another name, or a card that moves as one, is declared in schema.elements { key, label, type: text | richtext | image | button | link | group, setting?, style?, group? } and marked with data-fh-el="<key>".
  • Put data-fh-group="<key>" on a container whose marked direct children the merchant may reorder, and make that container display:flex or display:grid in the styles; order then moves them. A group that is neither gets a note, because reordering would move nothing.
  • A mark is a fixed key, never an expression, and names a setting or a declared element; anything else is refused with its template line (element_unknown, element_invalid). At most 40 elements per section.
  • save_section refuses a new package that prints a text, image or link setting only outside marked elements: elements_unmarked when nothing is marked, and element_unmarked for each setting, with the line of its first print. Sections already saved keep working, and rollback_section, fork_section and copies between environments are not held to it.
  • The one way out, written on purpose: list the setting in schema.fieldOnly (for example fieldOnly: ["imageAlt"] when the text is only ever an attribute of an unmarked element). The merchant still edits it in the section's fields; it just cannot be selected on the page.
  • List items ({% for item in settings.questions %}) print item fields, not settings: they are edited in the section's fields and are outside the rule. Settings a tag only tests ({% if settings.image %}) or hands to a widget are not prints.
  • Styles for a marked element should not depend on its position (no :first-child on marked children, no absolute placement of a group's members): the merchant's order and nudges must still read well. Test at phone width (390px).

What save_section and preview_section answer about it:

FieldMeans
editableEvery element the builder can select: { key, type, label, setting?, group?, style }. style lists the knobs offered (align, size, visible, spacing); order, offset, width, height and layer are offered on every element.
builderOne sentence to relay to the merchant: what they can select and change in this section.
notesAdvice, never a refusal: a declared element no template line marks, a group that is not flex or grid, a setting printed outside its mark as well.

A complete package that passes, a hero whose four elements sit in one group the merchant can reorder:

{
  "schemaVersion": 1,
  "id": "hero-stack",
  "name": "Hero",
  "description": "A heading, a line of text, a button and a picture, stacked.",
  "category": "hero",
  "version": "1.0.0",
  "origin": "store",
  "schema": {
    "settings": [
      {
        "key": "heading",
        "type": "text",
        "label": "Heading",
        "default": "Made by hand, made to last",
        "maxLength": 120,
        "inlineEditable": true
      },
      {
        "key": "body",
        "type": "textarea",
        "label": "Text",
        "default": "Small batches from our workshop.",
        "maxLength": 400,
        "inlineEditable": true
      },
      {
        "key": "ctaLabel",
        "type": "text",
        "label": "Button label",
        "default": "Shop now",
        "maxLength": 40
      },
      {
        "key": "ctaLink",
        "type": "link",
        "label": "Button link"
      },
      {
        "key": "image",
        "type": "image",
        "label": "Picture"
      },
      {
        "key": "imageAlt",
        "type": "text",
        "label": "Picture description",
        "maxLength": 160
      },
      {
        "key": "background",
        "type": "color",
        "label": "Background"
      }
    ],
    "elements": [
      {
        "key": "cta",
        "label": "Button",
        "type": "button",
        "setting": "ctaLabel"
      }
    ],
    "maxInstancesPerPage": 2,
    "pages": [
      "home",
      "page"
    ]
  },
  "template": "<div class=\"hero\" data-fh-group=\"stack\">\n  <h2 data-fh-el=\"heading\">{{ settings.heading }}</h2>\n  <p data-fh-el=\"body\">{{ settings.body }}</p>\n  {% if settings.ctaLabel %}<a class=\"hero__button\" data-fh-el=\"cta\" href=\"{{ settings.ctaLink | default: routes.catalog }}\">{{ settings.ctaLabel }}</a>{% endif %}\n  {% if settings.image %}<img class=\"hero__image\" data-fh-el=\"image\" src=\"{{ settings.image | image_url: 1200 }}\" alt=\"{{ settings.imageAlt }}\">{% endif %}\n</div>\n",
  "styles": ":scope{background:var(--fh-s-background,var(--fh-background,transparent));color:var(--fh-foreground,inherit)}\n.hero{display:flex;flex-direction:column;align-items:center;gap:1rem;max-width:760px;margin-inline:auto;padding:clamp(2.5rem,8vw,5rem) 1.25rem;text-align:center}\n.hero>*{margin:0}\n.hero h2{font-family:var(--fh-font-heading,inherit);font-size:clamp(2rem,6vw,3.5rem);line-height:1.1}\n.hero__button{display:inline-block;padding:.8rem 1.6rem;border-radius:999px;background:var(--fh-accent,currentColor);color:var(--fh-on-accent,#fff);text-decoration:none}\n.hero__image{width:100%;height:auto;border-radius:12px}\n"
}

Changing one element of a placed instance:

  • Text, images and links live in the instance's settings: { type: "update-section", instanceId, settings: { heading: "New words" } }.
  • { type: "set-element-style", instanceId, key, align?: "left" | "center" | "right", size?: "s" | "m" | "l" | "xl", visible?, spacing?: 0-4, order?: -20 to 20, offsetX?: -400 to 400 px or { value, unit: "cqw" } -100 to 100 (a share of the section width, two decimals), offsetY?: -400 to 400 px, width?, height?: { value, unit: "px" } 8 to 2000, { value, unit: "%" } 1 to 100 of the parent or { value, unit: "cqw" } 0.5 to 100 of the section width, layer?: -20 to 20 (in front of or behind the other elements of the section), mobile?: { order?, offsetX?, offsetY?, align?, visible?, width?, height?, layer? }, tablet?: the same } changes one element of a placed instance; null takes a knob off. Geometry (order, offsets, width, height, layer) is per device: the top level applies from 1024px, tablet from 641px to 1023px, mobile at 640px and below, and a device without its own shows the section layout; align and visible a device leaves out come from desktop. Shares of the section width scale with it. A moved element may cover its neighbours; it never rises above another section or the buy and consent controls. Width never exceeds the parent; text, buttons and groups use height as a minimum; an image keeps its proportions unless both are set (then it is cropped to fill).
  • { type: "set-element-order", instanceId, group, order: ["cta", "heading", "body"], mobile? } numbers a group's elements in one step.
  • The theme's own sections are edited through their areas: set-element and set-element-style with areaId.
[
  {
    "type": "update-section",
    "instanceId": "hero-stack",
    "settings": {
      "heading": "New season"
    }
  },
  {
    "type": "set-element-style",
    "instanceId": "hero-stack",
    "key": "heading",
    "size": "xl",
    "align": "left",
    "mobile": {
      "align": "center"
    }
  },
  {
    "type": "set-element-order",
    "instanceId": "hero-stack",
    "group": "stack",
    "order": [
      "image",
      "heading",
      "body",
      "cta"
    ]
  },
  {
    "type": "set-element-style",
    "instanceId": "hero-stack",
    "key": "cta",
    "offsetY": 12
  }
]

The builder changes only data: the instance's settings (the words, pictures and links) and its element overrides (align, size, visibility, spacing, order, position). The store turns the overrides into CSS confined to the instance; your template and styles are never rewritten. An instance with no overrides renders exactly as the template says.

A custom HTML block has no elements: the builder can move, hide or remove it, but nobody can select or edit anything inside it. Build a section whenever the merchant should be able to change the words or the layout later.

6. Styles

A section's styles are CSS under the same rules as a block's CSS in custom code: the same at-rules are kept, the same things are removed. Every rule is confined to the section, and :scope or & means the section itself:

:scope{background:var(--fh-s-background);color:var(--fh-s-foreground)}
.promo a{color:inherit;text-decoration:underline}

:scope and & at the start of a selector stand for the section itself, and what follows keeps its meaning: :scope > .row (its own rows), :scope * and :scope a (anything, or any link, inside it), &.is-dark and :scope:hover (the section in that state). A list inside :is(), :where(), :not() or :has() keeps its own commas. Two things stay out of section styles: @container queries (use @media), and arrows that move a carousel. Use CSS scroll snap for a row, or a sandboxed section behaviour for controls and accessible interaction; legacy section scripts remain a separate, more powerful option.

Fixed layers. A section whose schema says fixed: true may use position: fixed in its styles: a full-screen menu on a phone, a purchase bar along the bottom. The store keeps shoppers safe around it:

  • Every z-index in that section's styles is at most 100 (a higher one is written as 100, and one that is not a whole number or a keyword, such as var(), is left out), so a section's layer sits above a sticky header section (50) and below the store's own layers: the skip link, a script's overlay, the builder's outlines. A position other than a plain keyword is left out too.
  • On the cart page position: fixed is left out of every section's styles, header and footer included, so nothing can cover the checkout button or the totals. Write the fallback first and it applies there: position:absolute;position:fixed.
  • In the builder a fixed layer is written as position: absolute: it scrolls with the page, and the builder's outlines and selection still reach everything under it.

Custom HTML blocks, the store stylesheet and every section without fixed: true never use position: fixed. Everywhere, with or without fixed: true, a position has to be a plain word (static, relative, absolute, sticky, and fixed where allowed): a value built with var(), env(), attr() or any other function, or written with a backslash escape, is left out, since it could still come out as fixed (--p: fixed; position: var(--p) keeps only --p). A property name written with a backslash escape is left out too, and nested rules follow the same rules (custom code §2).

Styles never contain {{ }}. Instead, every colour, font, number, range, select, on/off and image setting arrives as a CSS variable on the section: --fh-s- followed by the setting's key in kebab case. minHeight becomes var(--fh-s-min-height), with its unit when the setting has one; an on/off setting is 1 or 0; an empty colour is left out, so give a fallback (var(--fh-s-accent,inherit)) and the theme's own colour shows. The theme's colour and font tokens (--fh-accent, --fh-font-heading and the rest) work inside a section as they do anywhere else. For a button on the brand colour use background:var(--fh-accent);color:var(--fh-on-accent): the store computes a readable text colour for whatever accent the owner picks. Never paint a button background:currentColor without a colour of its own.

A colour can be see-through: #rrggbbaa, where the last pair is the opacity from 00 (clear) to ff (solid), such as #00000080 for a half-dark overlay on a photo.

Fonts. A font setting names a family from the font list (describe_sections { parts: ["fonts"] }: about three hundred of the most used open font families; some 1,400 more open families are accepted by their exact name without being listed, so a design's own face can be used) and declares the weights your styles use, at most four (weights: [400, 700]), and italic: true to load the family's italics in those weights too when it has them. The section loads that family in those weights only, and the variable holds the family with a fallback, so h2{font-family:var(--fh-s-heading-font,inherit)} uses it and falls back to the theme's font while the setting is empty. The store's own font pairing is unchanged; a section's font applies inside that section only. The store's own typefaces (the custom pairing) load 400 to 700 unless their weights are named: set-fonts { pairing: "custom", headingFamily: "Playfair", headingWeights: [300, 400, 800], headingItalic: true } loads any weights the family has, at most six, so a section can set var(--fh-font-heading) at 300 or 800 without a font setting of its own.

A picture in the styles. An image setting arrives as url("<its path in your media library>"), at its original size, so styles can lay it as a tiled background (background-image:var(--fh-s-grain,none)) or draw a one-colour drawing in the text colour (background:currentColor;mask:var(--fh-s-mark,none) center/contain no-repeat, with the -webkit-mask twin). An empty setting writes no variable, so give the fallback none. An image field of a list item arrives the same way as --fh-i-<field>. Only a path in the store's own media library is ever written.

List items with their own colours. Colour, number, range, select and on/off fields of a list's items become CSS variables on each item's own element: put data-fh-item="{{ loop.itemId }}" on it inside {% for %}, and the item's background field is var(--fh-i-background) there. No style attribute is needed, and each tile of a row can have its own colour:

.tile{background:var(--fh-i-background,var(--fh-accent))}

6a. Page bodies: the product, catalog and cart pages

The product page, the catalog page (every product, each collection and the search results, one layout), the cart page, each content page (/pages/<slug>), the blog list (/blog) and every post (/blog/<slug>) can be drawn by a section your store owns instead of the theme's shared code. Such a section says schema.body: "product" (or "catalog", "cart", "page", "blog", "post") and stands in the page's body (main), reading the page's own data (product, listing, the whole cart, a content page's page.html, the blog's blog.posts with its paging, a post's post) and placing the page's controls with widgets. A content page body is placed on one content page at a time (page:<slug>): it draws that page's title band and text in your layout, with no theme breadcrumb or title. The blog list and the posts have one layout each, the pages blog and post, where sections sit before and after the body as on any page. The contact page's form is not part of page.html, so keep the theme's own body there. Everything that posts (add to cart, the discount code, checkout) is still the store's own markup: a section decides how the page looks, never where anything goes or what it costs. Checkout, payment, order and account pages stay the platform's.

  • A section whose schema.body is "product", "catalog", "cart", "page", "blog" or "post" is that page's body. Its schema.pages is exactly that kind, schema.slots is empty and maxInstancesPerPage is 1. A "page" body is a content page's: it stands in the body of the content page (page:<slug>) it is placed on, so each content page has its own; add it with add-section { sectionId, position: { target: "start", page: "page:<slug>" } }.
  • On that page the body is the theme's own (main) or exactly one body section, in its place; other sections and blocks may sit before and after. add-section of a body section takes main's place; remove-section puts main back where it stood; add-section { sectionId: "main", position: { target: "start", page } } puts the theme's own back in the body's place. A second body is refused.
  • A hidden or switched-off body section, or one whose section is gone, shows the theme's own body in its place: a page is never without one.
  • The catalog page is one layout for every product, each collection and the search results; listing.kind says which.
  • The body holds the page's one h1 (the theme no longer draws it there, nor a content page's breadcrumb); every other section's h1 on the page is written as an h2.
  • A content page body reads page.title, page.slug, page.url and page.html, the page's text as the theme prints it, through the same allow-list as your markup: print it with {{ page.html | raw }}. Declare the title or the text in schema.elements (type text, no setting) to let the merchant style, move and hide them.
  • A "blog" body is the blog list (/blog, layout page blog): blog.posts is this page's posts (12 a page, newest first), with blog.page, blog.pages, blog.previousUrl and blog.nextUrl. A "post" body is every post (/blog/<slug>, layout page post): post.title, post.date, post.image, post.tags, post.author, post.excerpt and post.html (print it with | raw), and blog.posts the other newest posts for a row of them. Name a loop's item other than post in a post body ({% for other in blog.posts %}).
  • A template writes no form, input or script; inert buttons are allowed. Forms are widgets: platform markup posting to the store's own addresses with the theme's fields, styled by your styles through its classes. Widget arguments are escaped text capped at 120 characters; el: "<key>" makes the wrapper selectable in the builder.
  • At most 16 widgets in a page body (4 elsewhere). Widgets never repeat inside {% for %}, except line-quantity and line-remove, which go inside {% for line in cart.lines %} as line: line.
  • Prices come from the page's data; a template cannot change what is posted or charged. On the product page mark your own price elements with data-fh-variant-price (the price), data-fh-variant-compare and data-fh-variant-compare-amount (the struck price), data-fh-variant-percent (the sale percentage) and data-fh-variant-stock with data-fh-stock-in, -low, -out ({n} is the count): the buy box updates them as the shopper picks options. Leave the struck price and percentage empty when not on sale and hide them with :empty; a variant without a sale hides them with the hidden attribute, so keep [hidden]{display:none} in your styles.
  • {% slot "<id>" %} places the page's module slot (installed modules and add-on blocks such as reviews) where the template wants it, once. A slot the template leaves out stays where the theme puts it: catalog.toolbar just above the body, the others just below.
  • Text that comes from data (a product's title, a price) can still be an element: declare it in schema.elements with type text and no setting. The merchant styles, hides, reorders and nudges it; it is never typed into.
  • The cart is the shopper's own: a cart page is never cached or shared, and section scripts run there wherever the merchant switched them on, unless they turned the cart page off. The checkout button always goes to the store's own checkout, with each line's notes.
  • preview_section { page: "product" | "catalog" | "cart" | "page:<slug>" | "blog" | "post", sample } previews a body against sample: { product: "<handle>" }, { collection: "<handle>" }, { cart: [{ product, quantity?, variant? }] }, { page: "<slug>" } or { post: "<slug>" }, else the store's own newest data (the content page named by page:<slug>, the first page of posts, the newest post), else a built-in sample. In the builder the cart page shows sample lines; a content page, the blog and a post show the store's own.

The product page

product (sections whose schema.pages is ["product"]): The product the page shows, with everything the theme's product page shows.

FieldTypeMeaning
titletextThe product's name.
handletextIts handle.
urlurlThis page's address.
descriptionHtmlhtmlThe description as the merchant formatted it, reduced to paragraphs, emphasis, links, lists and small headings; print it with | raw.
descriptiontextThe description as plain text.
pricemoneyThe price the page opens with: the preselected option's, for a product with options. The buy box keeps a price marked data-fh-variant-price up to date.
compareAtPricemoney?The earlier price, shown struck through, when set.
onSalebooleanTrue when compareAtPrice is higher than price.
discountPercentnumberThe sale as a whole percentage; 0 when not on sale.
priceFrommoney?The lowest option price, when the product has options.
hasVariantsbooleanTrue when shoppers pick options such as size.
availablebooleanTrue when something of it is in stock.
inventorynumber?Units in stock of what the page opens with.
lowStockbooleanTrue when in stock with 10 or fewer left.
isNewbooleanTrue while the store's New badge applies to it.
imageimage?Its main image.
imageslist<image>Every image, main image first (at most 9).
optionslist<productOption>Its options (Size, Colour), in order; empty for a plain product.
variantslist<variant>Every combination on sale; empty for a plain product.
selectedVariantvariant?The combination the page opens with: the first in stock.
skutextIts stock keeping unit.
vendortextIts brand or maker.
productTypetextIts type.
tagslist<text>Its tags.
kindtextphysical, digital, service or subscription.
unitPricetextThe price per unit (per kg, per litre) when set; empty otherwise.
taxHinttextincl. tax or excl. tax when the store charges tax, as the theme prints it beside a price; empty otherwise.
quantityMaxnumberThe most one order may take (10, or the stock when lower).
collectionslist<collectionLink>The collections it is in.
relatedlist<product>The products the theme's product page shows as related: the merchant's picks, then the same collection, the same tags, the newest; never sold out.
createdAtdateWhen it was added.

Example: <h1 data-fh-el="title">{{ product.title }}</h1><p data-fh-variant-price="">{{ product.price \| money }}</p> renders <h1 data-fh-el="title">Linen shirt</h1><p data-fh-variant-price="">£45.00</p>.

productOption (product.options): One option of a product, such as Size. name (text) Its name. position (number) From 1. values (list<optionValue>) Its values, in order.

optionValue (productOption.values): One value of an option. label (text) The value, such as M. available (boolean) True when some combination with it is in stock. selected (boolean) True for the value the page opens with.

variant (product.variants, product.selectedVariant): One combination of option values on sale. id (text) Its id. title (text) Its values joined, such as M / Blue. values (list<text>) One value per option. price (money) Its price. compareAtPrice (money?) Its earlier price. onSale (boolean) True when on sale. discountPercent (number) The sale as a whole percentage. available (boolean) True when in stock. inventory (number) Units in stock. image (image?) Its own image, when it has one. sku (text) Its stock keeping unit. selected (boolean) True for the one the page opens with.

collectionLink (product.collections, listing.collections): A collection, as a link. title (text) Its name. handle (text) Its handle. url (url) Its page. current (boolean) True when it is the listing shown.

WidgetWhat it isArgumentsClasses
buy-boxEverything that buys the product, as the theme's product page has it: an option picker per option (size, colour) that updates the price, the stock line, the buttons and the picture as the shopper chooses, the quantity, Add to cart, Buy now and the sold out and checkout-not-open states.addLabel The Add to cart button. (Add to cart); buyLabel The Buy now button. (Buy now); buyNow show, or hide to leave Buy now out. (show); quantityLabel The quantity's label. (Qty); soldOutLabel What the stock line and the disabled button say when nothing is left. (Sold out); lowStockLabel The stock line with 10 or fewer left; {n} is the number. Empty shows nothing.; inStockLabel The stock line when in stock; {n} is the number. Empty shows nothing.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-buy-box, fh-widget-options, fh-widget-option, fh-widget-option-label, fh-widget-select-wrap, fh-widget-select, fh-widget-option-buttons, fh-widget-option-button, fh-widget-actions, fh-widget-quantity, fh-widget-label, fh-widget-button, fh-widget-add, fh-widget-buy, fh-widget-sold-out, fh-widget-stock, fh-widget-note, fh-widget-closed
variant-pickerThe option pickers alone, for a layout that puts them apart from the buttons. Shares one form with quantity, add-to-cart, buy-now and stock.labelPrefix Words before each option's name ("Select" shows "Select Size"); none when empty.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-variant-picker, fh-widget-options, fh-widget-option, fh-widget-option-label, fh-widget-select-wrap, fh-widget-select, fh-widget-option-buttons, fh-widget-option-button
quantityThe quantity alone: 1 up to 10, or the stock when lower.label Its label. (Qty); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-quantity, fh-widget-label, fh-widget-select-wrap, fh-widget-select
add-to-cartThe Add to cart button alone; disabled and saying so when sold out or while the store cannot take money.label Its text. (Add to cart); soldOutLabel What it says when nothing is left. (Sold out); icon An icon name beside the text; none when empty.; iconPosition after (the default) or before the text. (after); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-add-to-cart, fh-widget-button, fh-widget-add, fh-widget-label, fh-widget-sold-out, fh-widget-note, fh-widget-closed
buy-nowThe Buy now button: straight to the store's checkout with the product, past the cart. On the product page it joins the page's product form (the option and quantity the shopper picked). With product: it works on any page, such as a flash sale on the home page: that product, its variant, quantity and discount code, never a price. Nothing when the product cannot be bought (sold out, not published, a handle that names nothing, checkout not open); a product with options and no variant: becomes a link to its page, same look.product Any page: the product, as a product setting (settings.product), the page's product, or a handle in quotes. Left out on a product page: the page's product form.; variant With product: the option to buy, by id (such as product.selectedVariant.id); needed for a product with options, else the button links to its page.; quantity With product: how many, 1 to 10, never more than in stock. (1); discount With product: a discount code to apply at checkout. The store checks it there; one that is not valid is ignored and the product is sold at full price.; chooseLabel With product: the link's text for a product with options and no variant; the label when empty.; label Its text. (Buy now); icon An icon name beside the text; none when empty.; iconPosition after (the default) or before the text. (after); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-buy-now, fh-widget-button, fh-widget-buy, fh-widget-label
stockThe stock line alone, updated as the shopper picks options.soldOutLabel When nothing is left. (Sold out); lowStockLabel With 10 or fewer left; {n} is the number.; inStockLabel When in stock; {n} is the number.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-stock, fh-widget-stock
galleryThe product's pictures: the main one and a thumbnail per picture that shows it, without a script, as the theme's gallery does. Follows the store's gallery settings (fit, corner radius, thumbnails) and the picture of the option the shopper picks.fit store (the default): the store's gallery fit; section: your styles decide object-fit. (store); radius store (the default): the store's gallery corners; section: your styles decide border-radius. (store); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-gallery, fh-widget-gallery-thumbs, fh-widget-gallery-thumb, fh-widget-gallery-main, fh-widget-gallery-slide, fh-widget-gallery-empty

Slots: product.options Add-on blocks made for beside the product's options. product.below Under the product: add-on blocks such as reviews, and the modules made for there.

A body to adapt:

<div class="product" data-fh-group="info">
  {% widget "gallery" el: "gallery" %}
  <h1 data-fh-el="title">{{ product.title }}</h1>
  <p class="price" data-fh-el="price"><span data-fh-variant-price="">{{ product.price | money }}</span> <s data-fh-variant-compare="" data-fh-variant-compare-amount="">{% if product.onSale %}{{ product.compareAtPrice | money }}{% endif %}</s></p>
  <p data-fh-el="description">{{ product.description }}</p>
  {% widget "buy-box" addLabel: settings.addLabel el: "buy" %}
</div>
{% slot "product.below" %}

The catalog page

listing (sections whose schema.pages is ["catalog"]): What the catalog page lists: every product, one collection or search results, one page of them, with the sorts, filters and pages as links.

FieldTypeMeaning
kindtextall (every product), collection or search: the catalog, a collection and the search results are one page.
titletextThe collection's title, Results for the search, or All products.
descriptiontextThe collection's description; empty elsewhere.
imageimage?The collection's image, when it has one.
querytextWhat was searched for.
totalnumberHow many products match, over every page.
countnumberHow many products this page shows.
sorttextThe sort in use: newest, price-asc, price-desc or name.
sortslist<sortOption>Every sort, each with the address that applies it.
pagenumberThis page's number.
pagesnumberHow many pages there are.
previousUrlurlThe page before; empty on the first.
nextUrlurlThe page after; empty on the last.
pageLinkslist<pageLink>Every page, for a numbered pager; empty on a single page.
filterslist<filterGroup>The filters the store offers here (tags, options, brand, type, availability), each value with its count and the address that adds or removes it.
filteredbooleanTrue when a filter is chosen.
clearUrlurlThis listing with every filter taken off.
priceMinmoney?The lowest price here, when prices differ.
priceMaxmoney?The highest price here, when prices differ.
collectionslist<collectionLink>The store's visible collections, current when it is this one.
allUrlurlEvery product.
urlurlThis listing's own address.
emptybooleanTrue when there is nothing to show.
taxHinttextincl. tax or excl. tax when the store charges tax, as the theme prints it beside each card's price; empty otherwise.
productslist<product>This page's products, in the listing's order, as many as the theme's page shows.

Example: {% for sort in listing.sorts %}<a href="{{ sort.url }}"{% if sort.current %} aria-current="true"{% endif %}>{{ sort.label }}</a>{% endfor %} renders <a href="/collections/shirts" aria-current="true">Newest</a><a href="/collections/shirts?sort=price-asc">Price: low to high</a><a href="/collections/shirts?sort=price-desc">Price: high to low</a><a href="/collections/shirts?sort=name">Name</a>.

collectionLink (product.collections, listing.collections): A collection, as a link. title (text) Its name. handle (text) Its handle. url (url) Its page. current (boolean) True when it is the listing shown.

sortOption (listing.sorts): One way to sort a listing. value (text) newest, price-asc, price-desc or name. label (text) What the theme calls it. current (boolean) True for the sort in use. url (url) This listing sorted that way, filters kept.

filterGroup (listing.filters): One filter: tags, an option, brand, type or availability. key (text) Its address key, such as tag or opt.size. label (text) Its name. options (list<filterOption>) Its values.

filterOption (filterGroup.options): One value of a filter. value (text) The value. label (text) What it says. count (number) How many products carry it. selected (boolean) True when chosen. url (url) This listing with it added, or taken off when chosen.

pageLink (listing.pageLinks): One page of a listing. number (number) From 1. url (url) Its address. current (boolean) True for the page shown.

WidgetWhat it isArgumentsClasses
sortThe sort control: a list that re-sorts the listing as soon as it changes, keeping the search and the filters. listing.sorts has the same as links.label Its label. (Sort by); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-sort, fh-widget-label, fh-widget-select-wrap, fh-widget-select, fh-widget-button
price-filterThe price range: from and to boxes and Apply, keeping the search, the sort and the filters. Nothing when every product costs the same.fromLabel The first box's label. (Price from); toLabel The second box's label. (Price to); button The button. (Apply); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-price-filter, fh-widget-field, fh-widget-label, fh-widget-input, fh-widget-button

Slots: catalog.toolbar Above the products: the Filters and sorting module (collection chips, filters with counts, sort, the count). catalog.below Under the products: the pager of the Filters and sorting module, and add-on blocks.

A body to adapt:

<h1 data-fh-el="title">{% if listing.kind == "all" %}{{ settings.heading }}{% else %}{{ listing.title }}{% endif %}</h1>
{% widget "sort" el: "sort" %}
{% slot "catalog.toolbar" %}
<ul class="grid" data-fh-el="grid">{% for product in listing.products %}<li><a href="{{ product.url }}">{{ product.title }} {{ product.price | money }}</a></li>{% endfor %}</ul>
{% if listing.nextUrl %}<a href="{{ listing.nextUrl }}">Next</a>{% endif %}
{% slot "catalog.below" %}

The cart page

cart (sections whose schema.pages is ["cart"]; elsewhere cart is the summary (itemCount, subtotal, currency)): The shopper's cart, whole: its lines and totals, the codes on it and what it still needs. Never cached, never shared.

FieldTypeMeaning
itemCountnumberUnits in the cart.
subtotalmoneyWhat the lines cost, with the store's rules and the discount code already taken off.
currencytextThe cart currency.
lineslist<cartLine>One line per product, or per option of a product.
emptybooleanTrue when it holds nothing.
totalmoneyWhat the cart shows as its total: the subtotal; shipping and tax come at checkout.
compareSubtotalmoneyThe subtotal before the store's rules and group prices.
savingsmoneycompareSubtotal minus subtotal; 0 when nothing came off.
discountcartDiscount?The discount code the shopper entered, when there is one.
appliedRuleslist<appliedRule>The store's price rules that took something off.
giftCardgiftCard?The gift card the shopper attached, when there is one.
freeShippingfreeShipping?How far the cart is from free shipping, when the store offers it.
shippingEstimatebooleanTrue when the store needs the destination before checkout: place the shipping-estimate widget.
destinationdestination?Where the shopper said it goes, when they did.
adjustedbooleanTrue when quantities were lowered to the stock on hand.
currencyMismatchbooleanTrue when lines are in different currencies and cannot be paid together.
canCheckoutbooleanTrue when the checkout button will take the shopper to pay.
checkoutOpenbooleanFalse while the store cannot take money yet.
orderNotebooleanTrue when the store asks for a note for the seller; the checkout button carries the box.
taxNotetextPrices include tax. or Tax is calculated at checkout. when the store charges tax, as the theme prints it under the subtotal; empty otherwise.
noteslist<text>The sentences the theme's cart shows about the cart itself: quantities lowered to the stock, lines in different currencies.
offerslist<offer>The offers in effect for this cart (also readable as offers): rules marked show on storefront, the cheapest free shipping, store functions' offers and the code on the cart. The offers widget draws them.
progressprogress?Progress towards the nearest threshold offer not reached yet, else the highest reached; empty without one. The progress widget draws it.

Example: {% for line in cart.lines %}{{ line.title }} × {{ line.quantity }}: {{ line.lineTotal \| money }}{% endfor %} renders Linen shirt × 2: £90.00.

cartLine (cart.lines): One line of the cart. key (text) The line's own key; per-line widgets read it. product (product) The product, at the chosen option's price. title (text) The product's name. variantTitle (text) The chosen options, such as M / Blue; empty for a plain product. quantity (number) How many. unitPrice (money) The price of one. lineTotal (money) The price of all of them. image (image?) The chosen option's image, else the product's. url (url) The product's page. maxQuantity (number) The most this line may hold. lowStock (boolean) True when 10 or fewer are left. label (text) The label a store function gave the line, such as Free tote; empty otherwise. addedBy (text) The store function that added the line or made it free; empty for the shopper's own. gift (boolean) True when some of its units are a free gift. giftUnits (number) How many of its units are free. removable (boolean) False for a gift the store keeps in the cart; line-remove then shows nothing. options (list<lineAttribute>) The chosen options by name, as key (Size) and value (M); empty for a plain product. attributes (list<lineAttribute>) The shopper's notes on the line (a gift message), as key and value; checkout carries them to the order.

lineAttribute (cartLine.attributes): One note on a cart line, such as a gift message. key (text) Its key, such as gift_message. value (text) What the shopper wrote.

offer (offers, cart.offers (the cart page)): One offer in effect for the cart: display only, from the same evaluation as the total. id (text) Its id: free-shipping, rule-<id>, code, or a store function's own. source (text) rule, shipping, function or code. kind (text) threshold, quantity_break, gift, bundle or custom. label (text) Its name, such as Free Standard shipping. detail (text) A second line; often empty. met (boolean) True when the cart has it. threshold (money?) The subtotal it needs, for a threshold. remaining (money?) What is left to add; 0 once met. progress (number?) How far along, 0 to 100, for a threshold. text (text) The sentence the offers widget writes: Add £10.00 more, or Unlocked.

progress (cart.progress (the cart page)): Progress towards a threshold offer, such as free shipping: for a bar of your own. offer (text) The offer's id. label (text) The offer's name. threshold (money) The subtotal it needs. remaining (money) What is left; 0 once reached. progress (number) How far along, 0 to 100, for a width or a progress value. reached (boolean) True once reached. text (text) The progress widget's sentence: Add £10.00 more for Free Standard shipping.

cartDiscount (cart.discount): The discount code on the cart. code (text) The code as entered. applied (boolean) True when it is taking money off. amount (money) What it took off. freeShipping (boolean) True when it makes shipping free. label (text) The code and what it does, as the theme's chip says it. message (text) The store's sentence about it, when there is one.

appliedRule (cart.appliedRules): A price rule that took something off the cart. name (text) The rule's name. amount (money) What it took off.

giftCard (cart.giftCard): The gift card on the cart: a way of paying, used at the payment step. applied (boolean) True when the store recognised it. last4 (text) Its last four characters. balance (money) What is on it. message (text) The store's sentence about it.

freeShipping (cart.freeShipping): How far the cart is from the store's free shipping. threshold (money) What a cart has to reach. remaining (money) What is left to add; 0 once it qualifies. rateName (text) The rate that becomes free. qualifies (boolean) True once it does. text (text) The theme's sentence, such as Add £10.00 more for free Standard shipping.

destination (cart.destination): Where the shopper said the order goes. country (text) The country code, such as GB. countryName (text) Its name. postalCode (text) The postal code.

WidgetWhat it isArgumentsClasses
line-quantityOne line's quantity and its Update button. Only inside {% for line in cart.lines %}, as line: line.line The loop's line.; label The quantity's label. (Qty); button The button. (Update); style "stepper": a minus button, the number and a plus button, each press saving; else a list and the button.; minusIcon The stepper's minus: an icon name; a minus sign when empty.; plusIcon The stepper's plus: an icon name; a plus sign when empty.fh-widget, fh-widget-line-quantity, fh-widget-label, fh-widget-select-wrap, fh-widget-select, fh-widget-button, fh-widget-stepper, fh-widget-minus, fh-widget-plus, fh-widget-quantity
line-removeOne line's Remove button. Only inside {% for line in cart.lines %}, as line: line.line The loop's line.; label Its text (read out when an icon shows it). (Remove); icon An icon name to show instead of the text; none when empty.fh-widget, fh-widget-line-remove, fh-widget-button, fh-widget-label
discount-codeThe discount code box: a field and Apply while no code holds, the code with what it takes off and Remove once one does, and the store's sentence about it.heading The line above it. (Discount code); placeholder The hint in the field. (Enter code); button Apply. (Apply); removeLabel Remove. (Remove); icon An icon name to show at the start of the field; none when empty.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-discount-code, fh-widget-heading, fh-widget-field, fh-widget-input, fh-widget-button, fh-widget-chip, fh-widget-note
gift-cardThe gift card box: the card shows as its last four characters and its balance, used at the payment step.heading The line above it. (Gift card); placeholder The hint in the field. (Enter gift card code); button Apply. (Apply); removeLabel Remove. (Remove); icon An icon name to show at the start of the field; none when empty.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-gift-card, fh-widget-heading, fh-widget-field, fh-widget-input, fh-widget-button, fh-widget-chip, fh-widget-note
shipping-estimateWhere the order goes (country and postal code), for the stores that need it before checkout: place it inside {% if cart.shippingEstimate %}.heading The line above it. (Ship to); button The button. (Update); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-shipping-estimate, fh-widget-heading, fh-widget-field, fh-widget-label, fh-widget-select, fh-widget-input, fh-widget-button, fh-widget-note
offersThe offers in effect for this cart, one item each: the automatic discounts you marked show on storefront, your cheapest free shipping, your store functions' offers and the code on the cart, with how far the cart is from each threshold. Drawn from the same evaluation as the cart's total. Nothing when there are none.heading A line above the list; none when empty.; source Only offers from these, comma separated: rule, shipping, function, code; every one when empty.; metLabel The status of an offer the cart already has. (Unlocked); pendingLabel The status of a threshold not reached yet; {amount} is what is left to add. (Add {amount} more); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-offers, fh-widget-heading, fh-widget-offers-list, fh-widget-offer, fh-widget-offer-label, fh-widget-offer-detail, fh-widget-offer-status; each item has data-fh-offer-kind and data-fh-offer-state (met or pending)
progressProgress towards a threshold offer, such as free shipping: a sentence, a bar and the reached state, from the same evaluation as the cart's total. Follows offer: when given, else the nearest threshold not reached yet, else the highest one reached. Nothing when the store has no threshold offer.offer The offer's id (free-shipping, rule-<id>, or a function's own id); the nearest when empty.; pendingLabel The sentence before it is reached; {amount} is what is left, {offer} the offer's name. (Add {amount} more for {offer}.); reachedLabel The sentence once it is reached; {offer} is its name. (You have unlocked {offer}.); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-progress, fh-widget-progress-text, fh-widget-progress-bar (a progress element); the wrapper has data-fh-progress-state (pending or reached) and --fh-progress, the share done as a percentage, for a bar of your own
checkout-buttonCheck out: always the store's own checkout, with the cart, the code and the gift card on it, and the note for the seller when the store asks for one. Disabled while the cart cannot be paid.label Its text. (Check out); icon An icon name after the text; none when empty.; noteLabel The note's label. (Note for the seller); notePlaceholder The hint in the note. (Anything we should know about this order?); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper).fh-widget, fh-widget-checkout-button, fh-widget-button, fh-widget-label, fh-widget-order-note

Slots: cart.below Under the cart: Add to your order (product recommendations), trust badges and add-on blocks.

A body to adapt:

<h1 data-fh-el="title">{% if cart.empty %}{{ settings.emptyHeading }}{% else %}{{ settings.heading }}{% endif %}</h1>
{% for line in cart.lines %}<div class="line">{{ line.title }} {{ line.variantTitle }} {{ line.lineTotal | money }}
  {% widget "line-quantity" line: line %}{% widget "line-remove" line: line icon: "trash" %}</div>{% endfor %}
{% if cart.empty == false %}<p data-fh-el="subtotal">{{ settings.subtotalLabel }} {{ cart.subtotal | money }}</p>
  {% widget "discount-code" el: "discount" %}{% widget "checkout-button" el: "checkout" %}{% endif %}
{% slot "cart.below" %}

A content page

page (sections whose schema.pages is ["page"]; elsewhere page has no html): The content page shown (/pages/<slug>), with its text: everything the theme's content page shows.

FieldTypeMeaning
titletextThe page's title.
slugtextIts slug (/pages/<slug>).
urlurlThis page's address.
htmlhtmlThe page's text as the merchant wrote it, formatted as the theme prints it (headings, paragraphs, links, lists, quotes); print it with | raw.
kindtextAlways "page".
productproduct?Empty here.
collectioncollection?Empty here.

Example: <h1 data-fh-el="title">{{ page.title }}</h1><div data-fh-el="text">{{ page.html \| raw }}</div> renders <h1 data-fh-el="title">About us</h1><div data-fh-el="text"><p>We make <strong>small</strong> batches.</p></div>.

A body to adapt:

<header class="band" data-fh-group="band">
  <h1 data-fh-el="title">{{ page.title }}</h1>
  <p data-fh-el="intro">{{ settings.intro }}</p>
</header>
<div class="text" data-fh-el="text">{{ page.html | raw }}</div>

The blog list

blog (sections whose schema.pages is ["blog"] or ["post"]; elsewhere blog is the latest posts (url, posts)): The blog: on the blog list (/blog), one page of posts with the paging; on a post, the other newest posts.

FieldTypeMeaning
urlurlThe blog list (/blog).
postslist<blogPost>On the blog list, this page's posts, newest first (12 a page). On a post, the other newest posts, without this one.
pagenumberThis page, from 1.
pagesnumberHow many pages.
previousUrlurlThe newer page's address, or empty on the first.
nextUrlurlThe older page's address, or empty on the last.

Example: {% for post in blog.posts %}<a href="{{ post.url }}">{{ post.title }}</a>{% endfor %}{% if blog.nextUrl %}<a href="{{ blog.nextUrl }}">Older</a>{% endif %} renders <a href="/blog/first-firing">The first firing</a><a href="/blog?page=2">Older</a>.

blogPost (blog.posts on the blog list and on a post): A published post, on the blog pages. title (text) Its title. url (url) Its page (/blog/<slug>). date (date) Published on. excerpt (text) Its summary. image (image?) Its cover. tags (list<text>) Its tags. author (text) Its author, or empty when not given.

A body to adapt:

<h1 data-fh-el="title">{{ settings.heading }}</h1>
<ul class="posts" data-fh-el="posts">{% for post in blog.posts %}<li>
  {% if post.image %}<img src="{{ post.image.src }}" alt="{{ post.image.alt }}">{% endif %}
  <a href="{{ post.url }}">{{ post.title }}</a> <time>{{ post.date | date: "long" }}</time>
  <p>{{ post.excerpt }}</p></li>{% endfor %}</ul>
<nav data-fh-el="pager">{% if blog.previousUrl %}<a href="{{ blog.previousUrl }}">Newer</a>{% endif %}
  {{ blog.page }} / {{ blog.pages }}{% if blog.nextUrl %} <a href="{{ blog.nextUrl }}">Older</a>{% endif %}</nav>

A post

post (sections whose schema.pages is ["post"]): The post shown (/blog/<slug>), with its text.

FieldTypeMeaning
titletextIts title.
urlurlIts page (/blog/<slug>).
datedatePublished on.
excerpttextIts summary.
imageimage?Its cover.
tagslist<text>Its tags.
authortextIts author, or empty when not given.
htmlhtmlThe post's text as the merchant wrote it, formatted as the theme prints it; print it with | raw.

Example: <h1 data-fh-el="title">{{ post.title }}</h1><p>{{ post.date \| date: "long" }}</p>{{ post.html \| raw }} renders <h1 data-fh-el="title">The first firing</h1><p>2 September 2026</p><p>How the new glaze came out.</p>.

blogPost (blog.posts on the blog list and on a post): A published post, on the blog pages. title (text) Its title. url (url) Its page (/blog/<slug>). date (date) Published on. excerpt (text) Its summary. image (image?) Its cover. tags (list<text>) Its tags. author (text) Its author, or empty when not given.

A body to adapt:

<p data-fh-el="tags">{% for tag in post.tags %}{{ tag }} {% endfor %}</p>
<h1 data-fh-el="title">{{ post.title }}</h1>
<p data-fh-el="date">{{ post.date | date: "long" }}{% if post.author %} · {{ post.author }}{% endif %}</p>
{% if post.image %}<img data-fh-el="cover" src="{{ post.image.src }}" alt="{{ post.image.alt }}">{% endif %}
<div class="text" data-fh-el="text">{{ post.html | raw }}</div>
<ul data-fh-el="more">{% for other in blog.posts limit: 3 %}<li><a href="{{ other.url }}">{{ other.title }}</a></li>{% endfor %}</ul>

What your store's code puts in the cart. Your store's own code can change a cart without a script on the page: a store function on cart.transform adds a free gift or a bundle's parts and sets notes on a line, one on cart.price lowers prices and takes amounts off the order, and the automatic discounts you mark show on storefront are offers every shopper may read. The cart page draws all of it from the one evaluation the checkout charges: a line a function added carries its label (line.label, "Free tote", also printed in the variant-title slot), its free units (line.gift, line.giftUnits) and whether the shopper may remove it (line.removable; line-remove draws nothing for a gift your store keeps in the cart); the shopper's own notes on a line are line.attributes and the checkout button carries them to the order exactly as the theme's own form does; order discounts show under their own labels in cart.appliedRules; and the offers in effect are offers (also cart.offers), with the nearest threshold as cart.progress. Store code names products and quantities, never prices: every number here is the store's own arithmetic.

Offers, progress and a one-click add on the cart page. {% widget "offers" %} lists the offers in effect for this cart: the rules you marked show on storefront, your cheapest free shipping, your store functions' offers and the code on the cart, each with its status ("Unlocked", or "Add £10.00 more"); source: "rule, function" keeps to some of them. {% widget "progress" %} draws progress towards a threshold offer: a sentence, a <progress> bar and the reached state, following offer: "free-shipping" (or rule-<id>, or a function's own offer id) or, left out, the nearest threshold not reached yet. Both are drawn by the store with the cart, from the same evaluation as its total (the cart page is never cached), and draw nothing when there is nothing to show, so a body that places them looks exactly like the theme's page for a store with no offers. To draw your own, loop over offers (offer.label, offer.met, offer.remaining, offer.progress from 0 to 100, offer.text) or read cart.progress (progress, remaining, reached, text). {% widget "quick-add" %} (§4) adds a suggested product. The Bold Street cart body places all three: the offers under the codes, a progress bar and a suggested product you switch on in its settings.

The stock line's state. The stock line (stock, and the buy box's) carries data-fh-stock-key="in", "low" or "out", written by the store and kept up to date as the shopper picks options, so your styles can colour it: [data-fh-stock-key="low"]{color:#b45309}.

Module slots inside your body. A slot the template places ({% slot "product.below" %}) is drawn inside your section, so your styles reach the modules in it: a rule such as [hidden]{display:none} also hides a module's own controls. Write rules for your own markup (.gallery [hidden]), or leave the slots out of a broad rule: every module in a slot sits in an element with data-fh-slot, so :scope [hidden]:not([data-fh-slot] *) touches only yours.

The gallery and the store's gallery settings. The gallery follows the store's gallery fit, corner radius and thumbnails. {% widget "gallery" fit: "section" radius: "section" %} leaves the fit, the corners or both to your styles (object-fit, border-radius); what you leave as store still follows the store.

7. Limits

WhatLimit
Template32 KB
Styles16 KB
Artwork drawings per section32, each 32 KB, 160 KB together
Settings per section40
Groups per section8
Presets per section12
Options in one select setting24
Fields in one list item8
Items in one list setting24
Instances on one page60
Instances in the header or footer slot40
The most maxInstancesPerPage may be60
Sections per store100
Versions kept per section20
Nested if and for tags8
Expressions and tags in one template1,500
Characters in one expression300
Filters in one expression6
Items one loop shows50
Loop steps in one render1,000
Products one collection offers a template50
Product and collection settings one instance may use24
Render time for one instance10 ms
Render time for every section on a page100 ms
Markup one instance may produce64 KB
Markup every section on a page may produce512 KB
Image widths image_url accepts64 to 2,400 pixels

A section over a render limit is left out of the page rather than slowing it down, and the store's event log records why.

8. Build one with an agent

  1. describe_sections: Read the rules: the package format, the setting types, the template language, the limits and the refusal codes.
  2. save_section: Save your package { package }, with data-fh-el on every element that prints a text, image or link setting (describe_sections { parts: ["editable"] }). It is checked whole; a refusal names the section, setting, preset or template line to fix, and the answer lists what the merchant can select (editable). Saving again with changes makes a new version.
  3. preview_section: Render one instance { id, settings } exactly as shoppers would see it, before it is on any page.
  4. apply_storefront_commands: Place it on the draft: { revision, commands: [{ type: "add-section", sectionId, position, settings }] }; update-section, apply-section-preset, duplicate-section, set-section-visibility and remove-section change it later.
  5. get_preview_link: Open the draft at desktop and phone width (about 390px).
  6. publish_storefront: Publish { revision }. Nothing a shopper sees changes before this.

A worked round, with a test token on the sandbox:

describe_sections
save_section { package: { schemaVersion: 1, id: "promo-band", name: "Promo band",
  schema: { settings: [...], pages: ["home", "catalog"] }, template: "...", styles: "..." } }
preview_section { id: "promo-band", settings: { heading: "20% off everything", code: "WINTER20" } }
get_storefront_draft
apply_storefront_commands { revision, commands: [
  { type: "add-section", sectionId: "promo-band",
    position: { placement: "after", target: "hero" },
    settings: { heading: "20% off everything", code: "WINTER20" } } ] }
get_preview_link
publish_storefront { revision }

A package that is not right is refused whole, with every problem on its own line, each naming exactly where it is:

Section promo-band, setting heading: the default does not fit the setting: use at most 100 characters.
Section promo-band, template line 4: "include" is not available in a section.

Fix them all and save again. preview_section takes a package you have not saved yet too, so a template can be tried before it is stored.

Instances are changed with editor commands on the draft, like everything else on a page: update-section { instanceId, settings } (a null value puts a setting back to its default), apply-section-preset, duplicate-section, set-section-visibility, move-section, set-layout and remove-section, and one element at a time with set-element-style { instanceId, key, … } and set-element-order { instanceId, group, order } (§5). Each refusal names its command: Command 2 (add-section, winter): Section promo-band, instance winter on page home: this section may appear at most 3 times on a page.

9. Examples

These packages are complete and pass every check; start from one.

A promo band, with presets and styles

{
  "schemaVersion": 1,
  "id": "promo-band",
  "name": "Promo band",
  "description": "A coloured strip with an offer, an optional code and a link.",
  "category": "promotion",
  "version": "1.2.0",
  "author": {
    "kind": "owner",
    "name": ""
  },
  "origin": "store",
  "schema": {
    "settings": [
      {
        "key": "heading",
        "type": "text",
        "label": "Offer",
        "default": "Free delivery over £50",
        "maxLength": 100,
        "inlineEditable": true
      },
      {
        "key": "text",
        "type": "text",
        "label": "Small print",
        "maxLength": 160,
        "inlineEditable": true
      },
      {
        "key": "code",
        "type": "text",
        "label": "Discount code",
        "help": "Shown in a box shoppers can copy by hand.",
        "maxLength": 32
      },
      {
        "key": "linkLabel",
        "type": "text",
        "label": "Link label",
        "default": "Shop the sale",
        "maxLength": 40
      },
      {
        "key": "link",
        "type": "link",
        "label": "Link",
        "default": "/collections/all"
      },
      {
        "key": "showBadge",
        "type": "boolean",
        "label": "Show a Sale badge",
        "default": true
      },
      {
        "key": "background",
        "type": "color",
        "label": "Background",
        "default": "#111111"
      },
      {
        "key": "foreground",
        "type": "color",
        "label": "Text colour",
        "default": "#ffffff"
      }
    ],
    "elements": [
      {
        "key": "cta",
        "label": "Link",
        "type": "link",
        "setting": "linkLabel"
      }
    ],
    "maxInstancesPerPage": 3,
    "pages": [
      "home",
      "catalog",
      "product",
      "page"
    ],
    "slots": [
      "header.top",
      "footer.top"
    ]
  },
  "template": "<div class=\"promo\" data-fh-group=\"band\">\n  {% if settings.showBadge %}<span class=\"promo__badge\">Sale</span>{% endif %}\n  <strong data-fh-el=\"heading\">{{ settings.heading }}</strong>\n  {% if settings.text %}<span data-fh-el=\"text\">{{ settings.text }}</span>{% endif %}\n  {% if settings.code %}<code data-fh-el=\"code\">{{ settings.code | upper }}</code>{% endif %}\n  {% if settings.link %}<a data-fh-el=\"cta\" href=\"{{ settings.link }}\">{{ settings.linkLabel }}</a>{% endif %}\n</div>\n",
  "styles": ":scope{background:var(--fh-s-background);color:var(--fh-s-foreground)}\n.promo{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;justify-content:center;padding:.75rem 1rem;font-size:.95rem}\n.promo a{color:inherit;text-decoration:underline}\n.promo code{border:1px dashed currentColor;padding:.1rem .4rem;border-radius:4px}\n.promo__badge{text-transform:uppercase;font-size:.75rem;letter-spacing:.08em;border:1px solid currentColor;padding:.1rem .5rem;border-radius:999px}\n",
  "presets": [
    {
      "id": "announcement",
      "name": "Announcement",
      "settings": {
        "showBadge": false,
        "background": "#f4f1ea",
        "foreground": "#1a1a1a"
      }
    },
    {
      "id": "code",
      "name": "With a code",
      "settings": {
        "heading": "20% off everything",
        "code": "WINTER20",
        "text": "Until Sunday."
      }
    }
  ]
}

Questions and answers, with a list setting

{
  "schemaVersion": 1,
  "id": "faq-accordion",
  "name": "Questions and answers",
  "description": "Questions shoppers ask, each opening to its answer.",
  "category": "text",
  "version": "1.0.0",
  "origin": "store",
  "schema": {
    "settings": [
      {
        "key": "heading",
        "type": "text",
        "label": "Heading",
        "default": "Questions",
        "maxLength": 120,
        "inlineEditable": true
      },
      {
        "key": "questions",
        "type": "list",
        "label": "Questions",
        "itemLabel": "Question",
        "minItems": 1,
        "maxItems": 24,
        "fields": [
          {
            "key": "question",
            "type": "text",
            "label": "Question",
            "maxLength": 200,
            "required": true,
            "inlineEditable": true
          },
          {
            "key": "answer",
            "type": "richtext",
            "label": "Answer",
            "maxLength": 2000,
            "inlineEditable": true
          }
        ],
        "default": [
          {
            "question": "How long does delivery take?",
            "answer": "<p>Two to four working days in the UK.</p>"
          },
          {
            "question": "Can I return something?",
            "answer": "<p>Yes, within 30 days. See our <a href=\"/pages/returns\">returns page</a>.</p>"
          }
        ]
      },
      {
        "key": "openFirst",
        "type": "boolean",
        "label": "Open the first answer",
        "default": false
      }
    ],
    "maxInstancesPerPage": 2,
    "pages": [
      "home",
      "product",
      "page"
    ]
  },
  "template": "<div class=\"faq\">\n  <h2 data-fh-el=\"heading\">{{ settings.heading }}</h2>\n  {% for item in settings.questions %}\n  <details class=\"faq__item\"{% if loop.first and settings.openFirst %} open{% endif %}>\n    <summary>{{ item.question }}</summary>\n    <div class=\"faq__answer\">{{ item.answer | raw }}</div>\n  </details>\n  {% endfor %}\n</div>\n",
  "styles": ".faq{max-width:760px;margin-inline:auto;padding:2rem 1rem}\n.faq__item{border-bottom:1px solid rgba(127,127,127,.3);padding:1rem 0}\n.faq__item summary{cursor:pointer;font-weight:600;list-style:none}\n.faq__item[open] summary{margin-bottom:.5rem}\n",
  "presets": []
}

10. Versions, drafts and copies

Every save is a version, and versions never change. Saving a section again writes a new version. Leave version out and save_section chooses it from what changed:

What changedThe new version
A setting removed or its type changed, a select option or list field removed, a kind of page or slot no longer allowed, or fewer instances allowed per pageA new major version (2.0.0)
A setting, option, list field, preset, kind of page or slot addedA new minor version (1.1.0)
The template, the styles, labels, help text or defaultsA patch (1.0.1)

If you give a version yourself, it has to be at least that step; a smaller one is refused with the version to use. The answer to every save says the version, the step and what changed.

Saving changes the draft, publishing changes the storefront. A new version is what the draft renders at once; the live storefront keeps the version it was published with until publish_storefront. Restoring an earlier storefront version (restore_storefront_version) brings back the section versions it used as well.

Nothing on a page breaks when a setting goes. An instance written against an older version keeps rendering: a value for a setting the new version dropped is ignored, and a value that no longer fits shows the default instead.

Rollback is a new version. rollback_section { id, version } saves that version's content again as the next version, so nothing is lost and you can roll forward the same way. The 20 most recent versions of each section are kept.

Make it mine. fork_section { id, newId } makes your own copy of an add-on's section (or of one of yours) under a new id, starting at 1.0.0 and remembering where it came from. The copy is yours to change. Pass repoint: true to move every instance in the draft onto the copy; otherwise they stay on the original. Built-in sections cannot be copied yet.

Deleting. delete_section is refused while the draft or the live storefront uses the section, and the refusal says where. Remove those instances first, or pass force: true: each instance then stays where it is and shows nothing, the builder marks it, and saving a section with the same id brings it back.

11. Sections from add-ons

An add-on can ship up to six sections. They are named after the add-on (faq--accordion, promo--band), checked exactly as save_section checks yours, and placeable as soon as the add-on is installed. They are read-only in your store: to change one, fork_section it. They need no grant, because a section only reads what every template reads.

Two add-ons in the catalogue ship sections today:

Add-onSections
Questions and answersfaq--accordion: a heading and questions that open to their answers
Promo bandspromo--band: an offer band with a code and a link, for any page and the header or footer; promo--stats: two to six big numbers with a line each

When you uninstall an add-on, every instance of its sections stays where you put it and shows nothing; the builder marks those places. Install the add-on again and they come back exactly as they were, settings and all.

If you write an app yourself, its manifest takes sections: an array of whole packages, each with origin: "app", app set to the app's name and an id of <app>--<name>. describe_apps and describe_sections have the details.

12. Refusals

Every refusal is one sentence per problem, naming the section and then, as they apply, the page or slot, the instance, the preset, the setting, the list item, the field, the template line or the styles. Each comes with its code:

CodeMeans
section_invalidThe section package does not match the section format.
section_id_invalidA section id is 2 to 40 lower-case letters, digits and single hyphens, starting with a letter.
section_id_reservedThat id belongs to a built-in section or a layout keyword.
section_id_takenYour store already has a section with that id.
section_not_foundNo section with that id is in your store.
section_read_onlyBuilt-in, add-on and catalogue sections cannot be edited; make your own copy first.
section_in_useThe section is still placed on a page; remove its instances first.
section_limitYour store already holds the most sections it can.
fork_not_allowedThis section cannot be copied.
version_invalidA version looks like 1.4.0.
version_not_newerA new version has to be higher than the current one.
version_bump_requiredThe change needs a bigger version step (for example a removed setting needs a new major version).
setting_invalidA setting value does not fit its definition.
setting_unknownThe section does not declare that setting.
setting_definition_invalidA setting definition is not valid.
setting_type_reservedThat setting type is only available to built-in sections.
setting_limitA section declares at most 40 settings.
group_unknownA setting names a group the section does not declare.
preset_invalidA preset holds a value its setting does not accept.
preset_limitA section has at most 12 presets.
template_too_largeA section template is at most 32 KB.
template_syntaxThe template has a syntax error.
template_unknown_tagThe template uses a tag sections do not have.
template_unknown_objectThe template names something sections cannot read.
template_unknown_settingThe template reads a setting the section does not declare.
template_unknown_filterThe template uses a filter sections do not have.
template_forbiddenThe template contains something sections never allow (scripts, event handlers, inline styles, other files).
template_nestingTags are nested more than 8 deep.
template_loop_limitA loop asks for more than 50 items.
behavior_invalidA section behaviour must be a self-contained default mount(api) function using only the worker bridge.
behavior_too_largeA section behaviour is at most 32 KB.
template_expression_limitThe template has too many expressions, or one that is too long.
template_missingThe section has no template; only built-in sections render without one.
element_unknownThe template marks an element (data-fh-el) that is neither a setting of the section nor declared in schema.elements.
element_invalidAn element declaration or a data-fh-el or data-fh-group mark is not valid.
elements_unmarkedThe section prints text, image or link settings but marks no element with data-fh-el, so the builder cannot select anything inside it.
element_unmarkedA text, image or link setting is printed only outside elements marked with data-fh-el; mark the element that prints it, or list the setting in schema.fieldOnly.
styles_too_largeSection styles are at most 16 KB.
styles_template_syntaxStyles cannot contain template expressions; read settings through the section's CSS variables.
artwork_invalidA drawing in the section's artwork has a name or markup sections do not accept; the message says what to remove.
artwork_limitA section carries at most 32 drawings, each at most 32 KB and 160 KB together.
instance_invalidThe instance does not match the instance format.
instance_id_invalidAn instance id is 1 to 32 lower-case letters, digits and hyphens, starting with a letter or digit.
instance_id_reservedThat instance id is a built-in section id or a layout keyword.
instance_id_takenAnother instance already uses that id.
instance_not_foundNo instance with that id is on the storefront.
instance_limitThe page already holds as many instances of this section as it allows.
page_instance_limitA page holds at most 60 section instances.
page_not_allowedThe section cannot be placed on that page.
slot_not_allowedThe section cannot be placed in that slot.
home_emptyThe home page has to show at least one visible section or block.
collection_requiredThe older name of home_empty: the home page has to show at least one visible section or block.
main_requiredEvery page other than home has its own body exactly once, and home has none.
render_timeoutThe section took too long to render and was left out of the page.
render_too_largeThe section produced too much markup and was left out of the page.
render_loop_limitThe section looped over too many items and was left out of the page.

13. Agent tools

ToolScopeDoes
describe_sectionsstorefront:readEverything needed to write a package, as data: the package fields, the setting types, the template language, the limits and the rules. parts fetches how to make a section editable in the builder, the JSON schema, the refusal codes, a complete example, the widgets with recipes, the icon names, every example with its output, or the font list
list_sectionsstorefront:readThe built-in sections and the store's own, add-on and catalogue sections, with versions, whether you may change them and where each is placed
get_sectionstorefront:readOne whole package, its kept versions and where it is placed; version for an older one
save_sectionstorefront:writeCreates a section or saves a new version, checked whole, including the editable elements rule (§5); answers the version, the step, what changed and what the merchant can select (editable, builder, notes)
preview_sectionstorefront:readRenders one instance of a saved section, or of a package not saved yet, with the settings you give; changes nothing
fork_sectionstorefront:writeYour own copy of a section under a new id, optionally moving the draft's instances onto it
list_section_versionsstorefront:readThe kept versions, newest first, and which one the draft and the live storefront render
rollback_sectionstorefront:writeSaves an earlier version's content as a new version
delete_sectionstorefront:writeDeletes a section; refused while it is placed, unless force

Placing, changing and removing instances is apply_storefront_commands on the draft, and publish_storefront (storefront:publish) is still the only thing that reaches a shopper. Every tool has a matching REST route: /api/sections and /api/sections/preview, in the API reference.