JavaScript and forms
Status: scripts run, in a sandbox#
A section's <script> block is compiled and checked when the file is saved, and run on the page, and in the builder, inside a sandbox. It works for sliders that advance, counters, tabs with custom behaviour, and anything else built from listeners, classes, styles and text.
Prefer HTML and CSS where they do the job alone. They work before any script has loaded, they are faster, and they come with keyboard support. The patterns in the next part cover accordions, tabs, carousels, pop-ups and motion with no script at all. Reach for a script for what they cannot do: autoplay, a counter, a slider's dots.
How the sandbox works#
A section's script does not run in the page it is on. That page may have a visitor who is signed in, has a cart, or is halfway through a checkout, and a section's code is written by whoever wrote the section. So it runs in one hidden <iframe sandbox="allow-scripts"> per page, whose origin is opaque: it cannot read the visitor's cookies, storage or session, and it cannot see the page.
Inside, root is a copy of your section's own markup, in a real document. querySelector, classList, addEventListener, style and textContent all work the ordinary way. Every change you make to the copy is sent to the page, checked, and applied to the real section. Every click, key and input on the real section is sent to the copy, so your listeners run.
Show and hide what the template drew. A script cannot add an element, because the page's copy belongs to the renderer and would be put back. Draw every slide, message or panel in the template, and switch between them with hidden or a class.
Scroll with storelib.scrollTo. Setting scrollLeft changes the copy, which is not what the visitor sees:
storelib.scrollTo(track, { left: slideWidth * index, behavior: "smooth" })Know where you are. storelib.inEditor is true in the builder's canvas, so an autoplaying slider can hold still while somebody is designing it.
A script that throws stops on its own, and the section keeps whatever it had drawn. A script that changes the page thousands of times a second is stopped.
Interaction without script#
Accordion#
<details> and <summary> are an accordion with no script, keyboard support included. Give several the same name and opening one closes the others. Build the name from section.id so two FAQ sections on one page stay independent.
{% for block in blocks %}
<details class="faq__item" name="{{ section.id }}-faq" data-block-id="{{ block.id }}">
<summary class="faq__q">{{ block.settings.question }}</summary>
<div class="faq__a">{{ block.settings.answer | raw }}</div>
</details>
{% endfor %}.faq__q { cursor: pointer; list-style: none; padding: 18px 0; font-weight: 600; }
.faq__q::-webkit-details-marker { display: none; }
.faq__q::after { content: "+"; float: right; transition: transform 200ms ease; }
.faq__item[open] .faq__q::after { transform: rotate(45deg); }Tabs#
Radio inputs sharing a name, each followed by its label and panel. order puts the labels in a row and the panels beneath them; :checked shows the chosen one.
<div class="tabs">
{% for block in blocks %}
<input class="tabs__radio" type="radio" name="{{ section.id }}-tab" id="{{ section.id }}-{{ block.id }}"{% if forloop.first %} checked{% endif %}>
<label class="tabs__label" for="{{ section.id }}-{{ block.id }}" data-block-id="{{ block.id }}">{{ block.settings.title }}</label>
<div class="tabs__panel">{{ block.settings.content | raw }}</div>
{% endfor %}
</div>.tabs { display: flex; flex-wrap: wrap; }
.tabs__radio { position: absolute; opacity: 0; pointer-events: none; }
.tabs__label { order: 0; padding: 10px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: var(--scheme-text); }
.tabs__panel { order: 1; width: 100%; display: none; padding-top: 20px; }
.tabs__radio:checked + .tabs__label { border-color: var(--scheme-accent); color: var(--scheme-heading); }
.tabs__radio:checked + .tabs__label + .tabs__panel { display: block; }
.tabs__radio:focus-visible + .tabs__label { outline: 2px solid var(--scheme-accent); outline-offset: 2px; }Carousel#
A row that scrolls sideways and snaps to each slide. It swipes on a phone, scrolls with a trackpad and a keyboard, and needs no script.
.slides {
display: flex; gap: 16px;
overflow-x: auto; overscroll-behavior-x: contain;
scroll-snap-type: x mandatory; scrollbar-width: none;
}
.slides::-webkit-scrollbar { display: none; }
.slide { flex: 0 0 85%; scroll-snap-align: start; }
@container (min-width: 768px) { .slide { flex-basis: calc((100% - 32px) / 3); } }Pop-up#
The popover attribute gives a panel the browser opens, closes and dismisses, with Escape and a tap outside, and no script.
<button class="more" popovertarget="{{ section.id }}-details">Size guide</button>
<div class="sheet" popover id="{{ section.id }}-details">
{{ settings.size_guide | raw }}
<button popovertarget="{{ section.id }}-details" popovertargetaction="hide">Close</button>
</div>Motion#
CSS transitions and @keyframes, on opacity and transform, which are cheap to animate. Height from 0 to auto does not transition; animate grid-template-rows from 0fr to 1fr instead. Every animation stops for a visitor who has asked for reduced motion; see Styling.
Forms#
An email box on a Storelib site collects emails for that site's creator. A form in a section is submitted by the page that hosts it, not by a script, and every address lands in the creator's Subscribers, where their welcome email and automations pick it up. The section's author wires nothing, and the creator sets nothing up.
<form class="signup" data-storelib-form="subscribe">
<label class="signup__label" for="{{ section.id }}-email">Email</label>
<input id="{{ section.id }}-email" type="email" name="email" required autocomplete="email" placeholder="you@example.com">
<button type="submit">{{ settings.button_text }}</button>
<p class="signup__done" data-storelib-success hidden>{{ settings.success_message }}</p>
</form>How the page decides what a form means#
The email is read from the form's <input type="email">, and an optional name from name="name".
States#
While it sends, the form carries data-state="sending" and its buttons are disabled. It then carries data-state="done" or data-state="error", so your CSS can style each:
.signup[data-state="sending"] button { opacity: 0.6; }
.signup[data-state="error"] input { border-color: #b91c1c; }On success, an element inside the form marked data-storelib-success is shown and the rest of the form is hidden. Without one, a plain "Thanks, you're on the list." is shown instead. On error, the reason is shown under the form, which stays filled in so nobody has to type their address again.
In the builder#
Nothing is sent from the builder's canvas. A sign-up form there shows its success state with the line "Sign-ups go to Subscribers on your live site", because designing a page is not signing up to it.
Sending to another mailing service#
To post to Mailchimp, Kit, Buttondown or anything else instead, give the form that service's action and mark it native. A setting lets the creator choose:
<form method="post"
action="{% if settings.list_url %}{{ settings.list_url | url }}{% endif %}"
data-storelib-form="{% if settings.list_url %}native{% else %}subscribe{% endif %}">Consent#
A sign-up form is a request to be emailed, and the platform records where on the page it was given. A contact form is not: somebody asking a question has not agreed to a mailing list. Never make a contact form add its sender to Subscribers. Offer an unticked "Keep me updated" checkbox instead, and add the sender only when it was ticked.
Sign-ups are rate limited per visitor and per address, so a script cannot fill a creator's list.
Embeds#
An <iframe> hands part of the page to somebody else's code, so a section does not get one by asking. It declares the embed capability, and every frame takes its address from the embed filter:
"capabilities": ["embed"]{% if settings.video_url != "" %}
<iframe class="player" src="{{ settings.video_url | embed }}" title="{{ settings.video_title }}"
loading="lazy" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe>
{% endif %}.player { display: block; width: 100%; aspect-ratio: 16 / 9; border: 0; border-radius: {{ theme.radius.medium }}px; }The filter turns what people actually paste, a youtu.be link, a watch?v= link, a Vimeo page, into the player's address, and returns an empty string for everything else. A setting therefore cannot point a frame at an arbitrary site, whatever somebody types into it. It answers for:
youtube.comyoutube-nocookie.comyoutu.bevimeo.complayer.vimeo.comopen.spotify.comw.soundcloud.com
YouTube links are served from youtube-nocookie.com. An <iframe> without the capability, or whose src comes from anywhere but the filter, is refused when the file is saved.
The scripting API#
The script is wrapped as a function and handed six things:
"use strict";
(function (root, settings, blocks, theme, page, storelib) {
// your <script> block
})A slider that advances on its own and on a click. Every slide is in the template; the script only chooses which one shows. It declares "capabilities": ["timers"] for setInterval:
const slides = root.querySelectorAll(".slide")
const dots = root.querySelectorAll(".dot")
let current = 0
function show(i) {
current = (i + slides.length) % slides.length
slides.forEach((el, j) => el.toggleAttribute("hidden", j !== current))
dots.forEach((el, j) => el.classList.toggle("is-on", j === current))
}
root.querySelector("[data-next]")?.addEventListener("click", () => show(current + 1))
root.querySelector("[data-prev]")?.addEventListener("click", () => show(current - 1))
dots.forEach((el, j) => el.addEventListener("click", () => show(j)))
show(0)
if (!storelib.inEditor && settings.autoplay) setInterval(() => show(current + 1), settings.interval * 1000)What a script cannot name#
These are refused by name when the file is saved. The check skips strings, comments and property access, so "window" in a string and obj.window are fine; only the bare name is refused.
window, document, globalThis, self, top, parent, frames, opener, eval, Function, import, require, WebAssembly, fetch, XMLHttpRequest, WebSocket, EventSource, navigator, sendBeacon, localStorage, sessionStorage, indexedDB, cookie, location, history, postMessage, Worker, SharedWorker, ServiceWorker, setTimeout, setInterval, requestAnimationFrame, queueMicrotask
Querying inside root, never the document, is also what makes a section safe to place twice on one page.
Capabilities#
A section declares what it needs in its schema, and the builder shows the creator what each section has been granted. Everything a capability exposes goes to Storelib's own endpoints for that site; a section never gets an arbitrary host.
An unknown capability is refused, listing the ones that exist. A script is limited in size: