Skip to content

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.

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 listener

Every theme module is wired and Alpine is running. The place to put initialisation code.

detail
version the event contract version — currently 1

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.

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.

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"

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" : "…";
});

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

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

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

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

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" });
}
});

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.

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:ready
  • theme: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.