Section scripts
Code a section runs in the shopper's browser, such as a popup: the switch that allows it, what a script gets, and where scripts never run.
Written 2026-09-27. A section can carry a script: a small JavaScript module your store runs in the shopper's browser, once for each place the section appears on a page. That is how a section becomes interactive: a popup that opens after a few seconds, a card reveal, a countdown, a slider of your own.
This is the one place on your storefront where code you or your agent wrote runs in a shopper's browser. It is off until you switch it on. A [custom HTML block](custom-code.md) and your own stylesheet keep their rules: a block still cannot hold a script, and the stylesheet still cannot fix anything to the screen. A section's script is the way to do both, on purpose, under the rules below.
1. What a section script is
A section package has an optional fifth part beside its settings, template, styles and presets: script, the source of a JavaScript module. It is saved, versioned, forked and rolled back with the rest of the package, and every change to it is a new version like a change to the template.
export default function mount(root, api) {
const button = document.createElement("button");
button.type = "button";
button.textContent = api.settings.label;
button.addEventListener("click", () => root.classList.toggle("open"));
root.append(button);
}
The module's default export is called once per instance of the section on the page. Nothing else in the module is called by the store.
2. The switch
Section scripts run only when the store has Scripts in sections switched on, under Online Store → Custom code. It is off for every store until someone switches it on.
- Only a person can switch it. The store's owner or an admin, signed in to the dashboard. An API token, an app and an agent cannot switch it on or off, whatever their permissions: letting code run in your shoppers' browsers is your decision.
- Agents use isolated behaviours. Agents and apps can save a section's
behaviorwhile the switch is off; it stays inert until a person enables it. New or changed legacyscriptsource requires a dashboard session. Existing unchanged legacy source can be retained. The save answer anddescribe_readiness { feature: "section_scripts" }explain the switch. - It takes effect at once. The switch is not part of the draft. With it on, the scripts of the sections your storefront shows run on the next page a shopper opens, on the live storefront and on a draft preview alike. Switched off, they stop on the next page.
- Your test store and your live store each have their own switch.
After taking a showcase look on /claim, the signed-in person can choose Enable section scripts in one click before continuing to their store. Taking the look alone never enables scripts. The step changes the claimed live store, even when the dashboard was last in test mode. If that store is still being prepared, the step says so and can be retried, or the person can enable scripts later under Online Store → Custom code.
3. The mount contract
export default function mount(root, api) { … }
root is the section's own element on the page: the one that carries data-fh-section="<instance id>". Change anything inside it.
api is a small, frozen object:
| Field | What it is |
|---|---|
api.instanceId | This instance's id |
api.sectionId | The section's id |
api.version | The section version the page is showing |
api.settings | This instance's settings as the template saw them, defaults filled in, frozen. An image is its /media/ path, a product or a collection its handle, a colour its #rrggbb |
api.storage.get(key) | A value this section stored in this browser, or null |
api.storage.set(key, value) | Stores a value (anything JSON can hold); false when it does not fit or the browser keeps nothing |
api.storage.remove(key) | Forgets a value |
api.overlay.open(node or name, { label }?) | Shows part of the section as a popup (§4); true when it opened |
api.overlay.close() | Closes this section's popup |
api.overlay.isOpen() | Whether this section's popup is open |
api.cart | The shopper's cart, as the store prices it (§3a) |
api.offers.get() | The offers in effect on this cart: thresholds, gifts, quantity breaks (§3a) |
api.shopper.get() | { signedIn }: whether the shopper is signed in. Never who |
api.shopper.fromApp(name, path?) | What one of your apps answers for this shopper (§3a) |
api.app(name).fetch(path, init?) | A request to one of your store's own apps, at /apps/<name>/<path> |
api.onCleanup(callback) | Called when the shopper leaves the page |
Storage keys are 1 to 64 letters, digits, _, . and -. A value is at most 4 KB and a section keeps at most 16 KB in one browser. Values are kept in the browser's own storage under a name that starts with the section's id, so two sections never overwrite each other's keys.
Settings reach the script as data the store writes itself, never as code: a setting a merchant typed can never become part of a script.
If a script throws, or its module cannot load, the store notes it in the browser's console and carries on: every other section on the page still mounts, and the page itself is untouched. A mount function may be async.
The script runs after the page has been read, like any module. It can use everything a page script can: the DOM, events, timers, fetch to your own store.
3a. The cart, offers, the shopper and your apps
A section's script can do for the shopper what your theme's own buttons and forms do, through the store: read the cart, add to it, change and remove lines, apply or remove a code, and read the offers in effect. Every call goes to your store's own address and every answer is the store's own evaluation of the cart, the same one the payment page charges with. So a progress bar never says more than the checkout will do, and nothing a script sends can set a price.
| Call | Answers (a promise) |
|---|---|
api.cart.get() | The cart: { currency, lines, itemCount, subtotalMinor, discountMinor, compareSubtotalMinor, discounts, code, freeShipping, shippingDiscount, offers, shopper } |
api.cart.add({ productSlug, variantId?, quantity?, attributes? }) | The cart after adding. A product with options needs its variantId |
api.cart.update({ productSlug, variantId?, quantity }) | The cart with that line at that quantity; 0 removes it |
api.cart.remove({ productSlug, variantId? }) | The cart without it. Removing a line a store function added means "no thank you", and it is not added back |
api.cart.setAttributes({ productSlug, variantId? }, { key: "text" | null }) | Up to 5 short notes on a line (a gift message, an engraving), 80 characters each |
api.cart.applyCode(code) / api.cart.removeCode() | The cart with the code's verdict in code.message, the same sentence the cart box shows |
api.cart.prices([handles]) | What one of each would cost this shopper now, with their group's prices and every automatic discount, and the offers your store functions make about it: { handle: { unitPriceMinor, listPriceMinor, offers } }. A quantity-break table on a product page reads its tiers here, from the same function that gives the discount |
api.cart.open() | Opens this section's own data-fh-overlay="cart" as the accessible cart dialog, or takes the shopper to /cart when the section has no such overlay. The overlay's markup and presentation stay in the section; use api.cart.get() and api.cart.onChange() to fill it with the current cart. Resolves true when opened or sent to the cart page, false when another overlay is open, an automatic open is not allowed, or the shopper is already on /cart |
api.cart.onChange(callback) | Called with the new cart after every change a script makes |
api.search.suggest(q) | Up to eight matching products in the active storefront publication, with shopper-specific priceMinor, listPriceMinor, currency and offers from the cart's own price evaluation. An empty query returns []; a failed request rejects |
api.offers.get() | [{ id, source, kind, label, thresholdMinor?, remainingMinor?, progress?, met, percent?, amountMinor?, productIds?, endsAt? }] |
api.shopper.get() | { signedIn: boolean } |
api.shopper.fromApp(name, path?) | What one of your apps answers for this shopper, as JSON: a GET to /apps/<name>/<path>. Rejects with the app's own error when it answers with one |
Each line carries productSlug, variantId, title, quantity, unitPriceMinor (after every discount), listUnitPriceMinor, lineTotalMinor, attributes, and, for a line a store function added or made free, addedBy, giftUnits, label and removable.
Offers come from three places: your store functions' own offers, the automatic discounts you marked show on storefront, and your cheapest free-shipping threshold. A secret rule for one customer group is never listed.
A write names a product, a quantity, a code or a note, never a price. It is checked exactly as the add-to-cart button is (the product must be for sale, a quantity stops at what can be sold) and refused with a sentence the promise rejects with. Codes tried through a script are rationed per shopper, like the code box's. The store answers only its own pages: a request from another site is refused, and every answer is private to the shopper and never cached.
api.app("loyalty").fetch("balance") calls your own app, at /apps/loyalty/balance, with the path relative to the app; an address, a leading / or a .. is refused. It is how a section shows what an app keeps: a points balance, a personal code, a list.
What an app knows about this shopper. A script never learns who the shopper is: api.shopper.get() says only whether someone is signed in. An app you granted Know which signed-in shopper is using it is told, by your store, an id for the signed-in shopper on every call a script makes to it (apps §4c), so api.shopper.fromApp("loyalty", "balance") answers with that shopper's own balance, { points: 120 }, while the script itself still sees no id, no email and no cookie. The same call to an app without the grant reaches it as an anonymous visitor.
The same cart is GET /__fh/cart.json on your store, for a script of your own that is not a section's. The store's own one-click add ({% widget "quick-add" %}, sections) announces its adds with the same fh:cart event, so api.cart.onChange hears them too.
api.cart.open() gives a section a familiar way to open its own cart treatment without imposing one platform design on every theme. Mark the section's dialog content data-fh-overlay="cart"; the platform supplies the modal layer, close button, backdrop, Escape handling, focus trap and scroll lock. Without that marked content, the same call navigates to the store's cart page.
4. The overlay
A section's styles cannot fix anything to the screen, so a section cannot draw a popup over the page by itself. The overlay does it for you, the same way for every section:
- Mark the part of your template that is the popup with
data-fh-overlay="<name>". The store keeps it out of the page until your script opens it, so a shopper never sees it inline, even where scripts do not run. - In the script, call
api.overlay.open("<name>")(or pass the element).
What the store does then:
- It moves that element into a layer over the whole window, with a dimmed backdrop, and keeps it inside an element that carries your section's own
data-fh-sectionattribute and classes, so your section's styles still apply (a:scoperule applies to that element too). - It draws a close button of its own, always in the top right corner of the window. Your styles cannot reach it. Escape and a click on the backdrop close the popup as well.
- It keeps keyboard focus inside the popup while it is open, moves focus into it when it opens and puts focus back where it was when it closes. The page behind cannot be scrolled or reached. The popup is announced as a dialog, named after its first heading, or the
labelyou pass. - When it closes, your element goes back where it was, with everything your script did to it.
- The section's element gets an
fh-overlay-openevent when the popup opens andfh-overlay-closewhen it closes.
Two limits keep a popup polite. One popup at a time on a page. And one automatic open per page view: a second open needs the shopper's own click or key press to have just happened.
In the builder the popup's content is shown in place, where you can edit its words and pictures like any other part of the section.
5. Where scripts never run
Section scripts run on your home page, your collections, the catalog and search, product pages, your content pages and your blog: the pages you edit in the builder. They never run on:
- the cart, if you switched Scripts on the cart page off (below);
- the checkout and the payment page;
- the order confirmation, order tracking and "How to pay" pages;
- shopper account pages and sign-in;
- the "Coming soon" page, and the password page;
- "Page not found" and every other message or error page;
- the builder itself.
A header or footer section with a script still shows on every page; its script runs only on the pages above.
The cart page
Scripts on the cart page is a second switch under Online Store → Custom code, beside Scripts in sections. It is on for every store: wherever your sections' scripts run, they run on the cart page too, with the same api, so a progress bar or a one-click add written as a script works there without another step. Like the first switch, only the store's owner or an admin, signed in, can change it; turn it off to keep the cart page free of your own code. With it off, the cart page still shows what your store code does: a free gift a store function added, with its label and its price, order discounts under their own names, and the offers, progress and one-click adds the cart widgets draw (sections). Never on the payment page, the order confirmation or account pages, whatever either switch says.
6. Limits and what a save refuses
| What | Limit |
|---|---|
| Script size | 64 KB |
| Storage per value / per section, in one browser | 4 KB / 16 KB |
| Automatic popups per page view | 1 |
When you save, the store refuses a script that:
- has no
export default; - has a string, comment, template or bracket that never closes;
- uses
importin any form,importScripts,eval,new Functionordocument.write; - mentions
document.cookie, a service worker, the page'snonce, orwindow.parent,window.toporwindow.opener; - contains
</script.
Each refusal is one sentence with the line, like a template's: Section offer-popup: script line 12: eval is not available: ….
These checks read the text, comments and strings included, so a comment that mentions import is refused too. They are there to catch mistakes early. They are not what keeps your shoppers safe, and they are not a review of what a script does: §8 says what is.
7. How your store serves a script
Each script is a file on your storefront's own address:
/__fh/section-scripts/<section id>/<version>.<fingerprint>.js
The fingerprint is taken from the script itself, so an address always means exactly one script: a new version is a new address. The file is sent as JavaScript, with instructions to keep it for a year, and only while Scripts in sections is on. The page loads each section version it shows once, however many times the section appears, together with Formahand's own small script that calls each mount.
8. What a section script can and cannot reach
A section script runs on your storefront's own address, with the same powers as any script on a web page there. Be clear about what that means.
It can:
- read and change everything on the pages it runs on: prices shown, text, links, what a shopper types into a field on those pages;
- use your storefront's public read address, read the shopper's cart and the offers in effect, and add to or change the cart (§3a);
- send what it sees elsewhere: its own requests may only go to your store, but it can put information into the address of an image, which may come from anywhere, or send the shopper to another site.
It cannot:
- reach the Formahand dashboard, your account, your orders or your customers: they live on a different address that shares nothing with your storefront;
- reach another store;
- run on the payment page, where the card form lives, or on any other page in §5;
- read the shopper's sign-in or any other cookie your store sets: every one of them is kept out of reach of scripts (it can still add to the cart by asking your store, as the add-to-cart button does);
- run at all unless you switched it on.
That is why the switch is yours alone and why the lexical checks in §6 are not the protection: a script can do on your storefront anything its author wants it to do there. **Switch scripts on only if you trust whoever wrote your sections' scripts**, and that includes an agent working for you. Read a script before you publish a section that carries one, or ask your agent to explain it.
9. Worked example: an offer popup
The offer-popup section opens a popup a few seconds after a shopper arrives, once, and not again for the number of days you choose. The shopper turns over three of nine cards; the cards reveal your offer, and the newsletter sign-up asks for their email. It is a reveal, not a game of chance: every visitor gets the offer you set.
Its settings: headline, body, image and imageAlt, prize (the offer), code (the discount code shown after signing up), ctaLabel and ctaLink, delaySeconds (0 to 60), rememberDays (1 to 365), and three colours.
The template, with every editable part marked for the builder:
<div class="offer" data-fh-overlay="offer">
<div class="offer__panel" data-fh-group="content">
{% if settings.image %}<img class="offer__image" data-fh-el="image" src="{{ settings.image | image_url: 800 }}" alt="{{ settings.imageAlt }}">{% endif %}
<h2 data-fh-el="headline">{{ settings.headline }}</h2>
{% if settings.body %}<p data-fh-el="body">{{ settings.body }}</p>{% endif %}
<div class="offer__cards" data-fh-cards></div>
<div class="offer__result">
<p class="offer__prize" data-fh-el="prize">{{ settings.prize }}</p>
{% if settings.code %}<p class="offer__code">Your code: <code data-fh-el="code">{{ settings.code }}</code></p>{% endif %}
</div>
{% widget "newsletter" button: "Send me the code" %}
{% if settings.ctaLabel %}<a class="offer__cta" data-fh-el="ctaLabel" href="{{ settings.ctaLink }}">{{ settings.ctaLabel }}</a>{% endif %}
</div>
</div>
This example makes its nine card buttons in the script. A section template can also author safe type="button" controls with class and ARIA attributes. The heart of it:
export default function mount(root, api) {
const settings = api.settings;
const node = root.querySelector('[data-fh-overlay="offer"]');
if (!node) return;
const days = Math.max(1, Number(settings.rememberDays) || 30);
const seen = api.storage.get("seen");
if (typeof seen === "number" && Date.now() - seen < days * DAY) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!still) node.classList.add("offer--animate");
buildCards(node);
wireSignUp(node);
const delay = Math.min(60, Math.max(0, Number(settings.delaySeconds) || 0));
const timer = setTimeout(() => {
if (api.overlay.open(node)) api.storage.set("seen", Date.now());
}, delay * 1000);
api.onCleanup(() => clearTimeout(timer));
}
buildCards adds nine <button> cards to [data-fh-cards] and, after the third is turned, adds is-revealed to the popup, which the styles use to show the offer and the sign-up. wireSignUp sends the newsletter form to your store's own sign-up address as JSON, so the shopper stays in the popup, and adds is-signed-up, which shows the code. Without the script (the builder, a page where scripts do not run) the sign-up form still works as a plain form. Motion is switched off for a shopper who asked their device for less of it. describe_sections { parts: ["examples"] } has the whole package.
About the code. In this version the code is part of the page, so anyone who looks can read it: use a code you are happy to share, created under Discounts first.
A personal code for every sign-up
The offer-popup-code example gives each visitor who signs up a code of their own instead, single use and bound to their address, shown in the popup and emailed by the store. It is built only from what your store already has:
- A template discount:
upsert_discount { code: "WELCOME", kind: "percent", value: 15, template: true, perCustomerLimit: 1 }. Every code is made from it and is never worth more. - A small app, Welcome code, granted Make one-off discount codes and Keep its own records. Its one address,
POST /apps/welcome-code/claim { email }, makes a code bound to that email and answers the same code if it is asked again from the same browser or for the same address. - The popup's script, after the sign-up succeeds:
const response = await api.app("welcome-code").fetch("claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const { code } = await response.json();
node.querySelector("[data-fh-code]").textContent = code;
- A flow on
discount.createdwith the filterdata.template == "WELCOME" and data.boundEmail != ""sends yourwelcome-codeemail template to{{ data.boundEmail }}, with{{ data.code }}and a link that applies it,{{ store.url }}/discount/{{ data.code }}.
9b. Worked example: a spend-progress bar with a one-click add
The spend-progress section shows how close the cart is to your next offer (free shipping over a threshold, or an automatic discount you marked show on storefront) and adds a suggested product in one click. The heart of its script:
export default function mount(root, api) {
const fill = root.querySelector("[data-fh-fill]");
const status = root.querySelector("[data-fh-status]");
function draw(cart) {
const offer = cart.offers
.filter((item) => item.kind === "threshold" && !item.met)
.sort((a, b) => a.remainingMinor - b.remainingMinor)[0];
if (!offer) return;
fill.style.width = Math.round(offer.progress * 100) + "%";
status.textContent = `${offer.remainingMinor / 100} more: ${offer.label}`;
}
api.cart.get().then(draw);
api.cart.onChange(draw);
// A script-created control; a template can also author type="button".
const button = document.createElement("button");
button.textContent = api.settings.addLabel;
button.addEventListener("click", () =>
api.cart.add({ productSlug: api.settings.suggestion, quantity: 1 }));
root.querySelector("[data-fh-suggest]").append(button);
}
The full package formats the amount in the store's currency and follows a chosen offer by id.
10. For agents
save_sectiontakes isolatedbehaviorin the package; use the authoring guide, not legacy full-DOMscript. The answer carriessectionScripts: { enabled, href, readiness, note }whenever the package has one: whether scripts run on this store, and if not, that only the merchant can switch them on, athref.describe_readiness { feature: "section_scripts" }lists what has to be true. The switch has no tool: tell the merchant where it is.- Test a behaviour with a preview link on a draft before you publish.