Theme events
Hooks for custom JavaScript. The theme announces what it has just done, and your code listens — no need to fork a theme file, patch a snippet, or poll the DOM for changes.
Written for developers adding custom scripts to the theme. For the settings a merchant sees in the theme editor, see the theme guide.
To find the theme’s elements from your code — product cards, the cart, the header — see stable selectors.
Listening
Section titled “Listening”window.theme.on(name, handler) is available on every page:
<script> window.theme.on("cart:added", (event) => { const { items, variantId, quantity, source } = event.detail; console.log("added", variantId, "x", quantity, "via", source); });</script>Every event is dispatched on document, so a plain listener works just as well:
document.addEventListener("theme:cart:added", (event) => { … });theme.on is worth preferring for two reasons: the theme: prefix is optional ("cart:added" and "theme:cart:added" are the same subscription), and it replays the events listed under Late listeners. It returns an unsubscribe function:
const stop = theme.on("cart:updated", render);stop(); // removes the listenerEvents
Section titled “Events”theme:ready
Section titled “theme:ready”Every theme module is wired and Alpine is running. The place to put initialisation code.
detail |
|
|---|---|
version |
the event contract version — currently 1 |
theme:cart:add
Section titled “theme:cart:add”Cancelable. Fires immediately before a line is sent to cart/add.js, from both the product form and a product card’s quick add. Calling preventDefault() stops the add, and the theme resets the button as if nothing happened.
This is the hook for a validation gate — a required line-item property, an age confirmation, bundle rules — without touching product-form.js.
detail |
|
|---|---|
item |
the line about to be sent: { id, quantity, properties?, selling_plan? } |
variantId, quantity |
convenience copies of the above |
productId |
null if the markup didn’t carry one |
source |
"product-form" or "quick-add" |
form |
the <form> element, or null for quick add |
theme.on("cart:add", (event) => { if (event.detail.source === "quick-add" && !confirmedAge()) { event.preventDefault(); }});Mutating event.detail.item also works — it is the object that gets posted — but treat that as a sharp tool.
theme:cart:added
Section titled “theme:cart:added”The add succeeded. Fires before the cart drawer opens.
detail |
|
|---|---|
items |
the added lines, as cart/add.js returned them |
variantId, quantity, productId |
what was requested |
source |
"product-form" or "quick-add" |
Note that cart/add.js answers with the added lines, not the whole cart. For cart totals, listen for cart:updated, which follows shortly after.
theme:cart:addFailed
Section titled “theme:cart:addFailed”The add was rejected — sold out, exceeds available stock, or the request failed.
detail |
|
|---|---|
variantId, quantity |
what was attempted |
message |
the error text shown to the customer |
source |
"product-form" or "quick-add" |
theme:cart:updated
Section titled “theme:cart:updated”The important one. The cart markup has been re-fetched and swapped in — after an add, a quantity change, a note edit, or a discount change.
Anything you inject into the cart drawer or the cart page is destroyed by that swap, so this is where you re-attach it.
detail |
|
|---|---|
cart |
the full cart object, as /cart.js returns it |
itemCount |
total item count, for badges |
theme.on("cart:updated", ({ detail }) => { document.querySelector("#free-shipping-bar").textContent = detail.cart.total_price >= 5000 ? "Free shipping unlocked" : "…";});theme:cart:lineChanged
Section titled “theme:cart:lineChanged”A line’s quantity changed to something other than zero.
detail |
|
|---|---|
lineKey |
the line item key |
quantity, previousQuantity |
after and before |
line |
the updated line from the cart, or null |
cart |
the full cart after the change |
theme:cart:lineRemoved
Section titled “theme:cart:lineRemoved”A line was removed, either by the remove link or by a quantity of zero.
detail |
|
|---|---|
lineKey |
the removed line’s key |
previousQuantity |
what it held before removal |
cart |
the full cart after the removal |
theme:cart:noteUpdated
Section titled “theme:cart:noteUpdated”The order note was saved. Debounced by the theme, so this fires once the customer stops typing, not per keystroke.
detail |
|
|---|---|
note |
the saved text |
cart |
the full cart |
theme:cart:discountApplied / discountRejected / discountRemoved
Section titled “theme:cart:discountApplied / discountRejected / discountRemoved”The outcome of a discount code. Shopify answers an unusable code with a 200 and an unchanged cart, so discountRejected is the theme’s own verdict — reached by looking for the code in the returned cart.
detail |
|
|---|---|
code |
the code entered or removed |
cart |
the full cart |
theme:cart:error
Section titled “theme:cart:error”A cart request failed. Emitted alongside the more specific events above, so a single listener can catch every cart failure.
detail |
|
|---|---|
action |
"lineChange", "lineRemove", "noteUpdate" or "discount" |
lineKey |
present for line actions |
message |
the error text |
theme:form:submitted
Section titled “theme:form:submitted”Shopify accepted a form the theme submits in the background: newsletter sign-ups, the contact form, back-in-stock requests and blog comments. The page doesn’t reload, so this replaces watching for ?customer_posted=true in the URL — the place to fire a sign-up conversion, for example.
detail |
|
|---|---|
form |
the <form> element |
type |
Shopify’s form type: "customer" (newsletter), "contact" (contact and back in stock) or "new_comment" |
formData |
the FormData that was sent, read before the form was cleared |
theme.on("form:submitted", ({ detail }) => { if (detail.type === "customer") { gtag("event", "sign_up", { method: "newsletter" }); }});theme:form:submitFailed
Section titled “theme:form:submitFailed”Shopify rejected the submission (an invalid email, say), or the request failed.
detail |
|
|---|---|
form |
the <form> element |
type |
as above |
message |
the error text shown to the customer |
If Shopify’s spam protection asks for an interactive challenge, the form falls back to a normal submission and neither event fires.
Late listeners
Section titled “Late listeners”A script loaded with defer, or injected by an app block, can easily register after the theme has already fired theme:ready. To avoid the resulting silence, theme.on replays the most recent occurrence of the events that describe a state rather than a moment:
theme:readytheme:cart:updated
The replay is delivered asynchronously (on a microtask), so your handler never runs before theme.on has returned. Every other event only fires forwards — theme.on("cart:added", …) after an add has happened will not hear about it.
This only applies to theme.on. A raw document.addEventListener gets no replay.