Overview
Product components handle product display, variant selection, inventory management, and add-to-cart functionality.
ProductFormComponent
Manages the main product form with add-to-cart functionality, variant tracking, and quantity validation.
Class Definition
class ProductFormComponent extends Component<Refs> {
handleSubmit(event: Event): void;
get sectionId(): string;
}
Refs
Hidden input containing the selected variant ID
Container for the add-to-cart button
Error message display element
acceleratedCheckoutButtonContainer
Container for Shop Pay and other accelerated checkout buttons
ARIA live region for screen reader announcements
Label showing quantity already in cart
B2B quantity rules display
Container for form buttons and quantity selector
B2B volume pricing component
quantitySelector
QuantitySelectorComponent
Quantity selector component instance
Wrapper around quantity selector
Label for quantity selector
B2B price-per-item display
Methods
handleSubmit()
Handles form submission and add-to-cart action.
handleSubmit(event) {
event.preventDefault();
if (this.#variantChangeInProgress) {
const intendedVariantId = this.#getIntendedVariantId();
const quantity = this.#getQuantity();
if (intendedVariantId) {
this.#addToCartQueue.push({ variantId: intendedVariantId, quantity });
}
this.refs.addToCartButtonContainer?.animateAddToCart?.();
return;
}
this.#processAddToCart(undefined, undefined, event);
}
Validation:
if (this.refs.quantitySelector?.canAddToCart) {
const validation = this.refs.quantitySelector.canAddToCart();
if (!validation.canAdd) {
// Disable buttons
for (const container of allAddToCartContainers) {
container.disable();
}
// Show error
const errorMessage = errorTemplate.replace(
'{{ maximum }}',
validation.maxQuantity?.toString() || ''
);
addToCartTextError.textContent = errorMessage;
addToCartTextError.classList.remove('hidden');
// Re-enable after delay
setTimeout(() => {
for (const container of allAddToCartContainers) {
container.enable();
}
}, ERROR_BUTTON_REENABLE_DELAY);
return;
}
}
Events
The component orchestrates multiple event types:
Listens:
ThemeEvents.variantUpdate - Updates form when variant changes
ThemeEvents.variantSelected - Marks variant change as in progress
ThemeEvents.cartUpdate - Syncs quantity selector with cart state
Dispatches:
CartAddEvent - When item is added to cart
CartErrorEvent - When add-to-cart fails
Variant Change Handling
#onVariantUpdate = async (event) => {
if (event.detail.data.newProduct) {
this.dataset.productId = event.detail.data.newProduct.id;
}
const { variantId } = this.refs;
variantId.value = event.detail.resource?.id ?? '';
this.#variantChangeInProgress = false;
// Process queued add-to-cart requests
if (this.#addToCartQueue.length > 0) {
const queuedItems = [...this.#addToCartQueue];
this.#addToCartQueue = [];
this.#processBatchAddToCart(queuedItems);
}
// Update button state
if (event.detail.resource?.available === false) {
this.refs.addToCartButtonContainer?.disable();
} else {
this.refs.addToCartButtonContainer?.enable();
}
// Update quantity constraints for new variant
const newQuantityInput = event.detail.data.html.querySelector(
'quantity-selector-component input[ref="quantityInput"]'
);
if (this.refs.quantitySelector?.updateConstraints && newQuantityInput) {
this.refs.quantitySelector.updateConstraints(
newQuantityInput.min,
newQuantityInput.max || null,
newQuantityInput.step
);
}
// Fetch and update cart quantity for the new variant
await this.#fetchAndUpdateCartQuantity();
};
Example
<product-form-component data-product-id="123456">
<form on:submit="handleSubmit">
<input ref="variantId" type="hidden" name="id" value="789" />
<div ref="productFormButtons">
<quantity-selector-component ref="quantitySelector">
<!-- Quantity selector -->
</quantity-selector-component>
<add-to-cart-component ref="addToCartButtonContainer">
<button ref="addToCartButton" type="submit">Add to Cart</button>
</add-to-cart-component>
</div>
<div ref="addToCartTextError" class="hidden"></div>
<div ref="liveRegion" aria-live="polite" aria-atomic="true"></div>
</form>
</product-form-component>
AddToCartComponent
Manages the add-to-cart button with animations and visual feedback.
Class Definition
class AddToCartComponent extends Component<Refs> {
disable(): void;
enable(): void;
handleClick(event: MouseEvent): void;
animateAddToCart(): void;
}
Refs
addToCartButton
HTMLButtonElement
required
The add-to-cart button element
Methods
disable()
Disables the add-to-cart button.
disable() {
this.refs.addToCartButton.disabled = true;
}
enable()
Enables the add-to-cart button.
enable() {
this.refs.addToCartButton.disabled = false;
}
handleClick()
Handles button click with optional fly-to-cart animation.
handleClick(event) {
const form = this.closest('form');
if (!form?.checkValidity()) return;
// Check if adding would exceed max before animating
const productForm = this.closest('product-form-component');
const quantitySelector = productForm?.refs.quantitySelector;
if (quantitySelector?.canAddToCart) {
const validation = quantitySelector.canAddToCart();
if (!validation.canAdd) {
return; // Don't animate if it would exceed max
}
}
if (this.refs.addToCartButton.dataset.puppet !== 'true') {
const animationEnabled = this.dataset.addToCartAnimation === 'true';
if (animationEnabled && !event.target.closest('.quick-add-modal')) {
this.#animateFlyToCart();
}
this.animateAddToCart();
}
}
animateAddToCart()
Animates the button state change (“Add to Cart” → “Added”).
animateAddToCart = async function () {
const { addToCartButton } = this.refs;
// Clear existing timeouts
this.#resetTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
this.#resetTimeouts = [];
if (addToCartButton.dataset.added !== 'true') {
addToCartButton.dataset.added = 'true';
}
await yieldToMainThread();
await onAnimationEnd(addToCartButton);
// Reset after 800ms
const timeoutId = setTimeout(() => {
addToCartButton.removeAttribute('data-added');
const index = this.#resetTimeouts.indexOf(timeoutId);
if (index > -1) {
this.#resetTimeouts.splice(index, 1);
}
}, 800);
this.#resetTimeouts.push(timeoutId);
};
Data Attributes
data-add-to-cart-animation
Enable fly-to-cart animation
data-product-variant-media
URL of product image for fly-to-cart animation
Example
<add-to-cart-component
data-add-to-cart-animation="true"
data-product-variant-media="https://cdn.shopify.com/...jpg"
>
<button
ref="addToCartButton"
type="submit"
on:click="handleClick"
>
<span class="add-to-cart-text--default">Add to Cart</span>
<span class="add-to-cart-text--added">Added!</span>
</button>
</add-to-cart-component>
QuantitySelectorComponent
Quantity input with increment/decrement buttons and validation.
Class Definition
class QuantitySelectorComponent extends Component<Refs> {
getValue(): string;
setValue(value: string): void;
updateConstraints(min: string, max: string | null, step: string): void;
getCurrentValues(): { min: number; max: number | null; step: number; value: number; cartQuantity: number };
getEffectiveMax(): number | null;
updateButtonStates(): void;
canAddToCart(): { canAdd: boolean; maxQuantity: number | null; cartQuantity: number; quantityToAdd: number };
setCartQuantity(cartQty: number): void;
increaseQuantity(event: Event): void;
decreaseQuantity(event: Event): void;
setQuantity(event: Event): void;
selectInputValue(event: FocusEvent): void;
}
Refs
minusButton
HTMLButtonElement
required
Decrement button
plusButton
HTMLButtonElement
required
Increment button
Methods
getValue() / setValue()
Get or set the current quantity value.
component-quantity-selector.js
getValue() {
return this.refs.quantityInput.value;
}
setValue(value) {
this.refs.quantityInput.value = value;
}
updateConstraints()
Updates min/max/step and snaps value to valid increment.
component-quantity-selector.js
updateConstraints(min, max, step) {
const { quantityInput } = this.refs;
const currentValue = parseInt(quantityInput.value) || 0;
quantityInput.min = min;
if (max) {
quantityInput.max = max;
} else {
quantityInput.removeAttribute('max');
}
quantityInput.step = step;
const newMin = parseIntOrDefault(min, 1);
const newStep = parseIntOrDefault(step, 1);
const effectiveMax = this.getEffectiveMax();
// Snap to valid increment if not already aligned
let newValue = currentValue;
if ((currentValue - newMin) % newStep !== 0) {
// Snap DOWN to closest valid increment
newValue = newMin + Math.floor((currentValue - newMin) / newStep) * newStep;
}
// Ensure value is within bounds
newValue = Math.max(newMin, Math.min(effectiveMax ?? Infinity, newValue));
if (newValue !== currentValue) {
quantityInput.value = newValue.toString();
}
this.updateButtonStates();
}
getCurrentValues()
Reads current constraints and value from DOM.
component-quantity-selector.js
getCurrentValues() {
const { quantityInput } = this.refs;
return {
min: parseIntOrDefault(quantityInput.min, 1),
max: parseIntOrDefault(quantityInput.max, null),
step: parseIntOrDefault(quantityInput.step, 1),
value: parseIntOrDefault(quantityInput.value, 0),
cartQuantity: parseIntOrDefault(quantityInput.getAttribute('data-cart-quantity'), 0),
};
}
getEffectiveMax()
Calculates maximum quantity that can be added.
component-quantity-selector.js
getEffectiveMax() {
const { max, cartQuantity, min } = this.getCurrentValues();
if (max === null) return null;
// Product page: can only add what's left
return Math.max(max - cartQuantity, min);
}
canAddToCart()
Validates if current quantity can be added to cart.
component-quantity-selector.js
canAddToCart() {
const { max, cartQuantity, value } = this.getCurrentValues();
const quantityToAdd = value;
const wouldExceedMax = max !== null && cartQuantity + quantityToAdd > max;
return {
canAdd: !wouldExceedMax,
maxQuantity: max,
cartQuantity,
quantityToAdd,
};
}
setCartQuantity()
Updates the cart quantity and refreshes button states.
component-quantity-selector.js
setCartQuantity(cartQty) {
this.refs.quantityInput.setAttribute('data-cart-quantity', cartQty.toString());
this.updateCartQuantity();
}
increaseQuantity() / decreaseQuantity()
Increment or decrement the quantity.
component-quantity-selector.js
increaseQuantity(event) {
if (!(event.target instanceof HTMLElement)) return;
event.preventDefault();
this.updateQuantity(1);
}
decreaseQuantity(event) {
if (!(event.target instanceof HTMLElement)) return;
event.preventDefault();
this.updateQuantity(-1);
}
updateQuantity(stepMultiplier) {
const { quantityInput } = this.refs;
const { min, step, value } = this.getCurrentValues();
const effectiveMax = this.getEffectiveMax();
const newValue = Math.min(
effectiveMax ?? Infinity,
Math.max(min, value + step * stepMultiplier)
);
quantityInput.value = newValue.toString();
this.onQuantityChange();
this.updateButtonStates();
}
Events
Dispatches:
QuantitySelectorUpdateEvent - When quantity changes
component-quantity-selector.js
onQuantityChange() {
const { quantityInput } = this.refs;
const newValue = parseInt(quantityInput.value);
this.dispatchEvent(
new QuantitySelectorUpdateEvent(
newValue,
Number(quantityInput.dataset.cartLine) || undefined
)
);
}
Example
<quantity-selector-component data-variant-id="12345">
<button
ref="minusButton"
on:click="decreaseQuantity"
aria-label="Decrease quantity"
>
-
</button>
<input
ref="quantityInput"
type="number"
min="1"
max="10"
step="1"
value="1"
data-cart-quantity="0"
on:blur="setQuantity"
on:focus="selectInputValue"
/>
<button
ref="plusButton"
on:click="increaseQuantity"
aria-label="Increase quantity"
>
+
</button>
</quantity-selector-component>
VariantPicker
Manages product variant selection with option pickers.
Class Definition
class VariantPicker extends Component<Refs> {
variantChanged(event: Event): void;
updateSelectedOption(target: string | Element): void;
buildRequestUrl(selectedOption: HTMLElement, source?: string | null, sourceSelectedOptionsValues?: string[]): string;
fetchUpdatedSection(requestUrl: string, morphElementSelector?: string): void;
updateVariantPicker(newHtml: Document | Element): NewProduct | undefined;
updateElement(newHtml: Document, elementSelector: string): void;
updateMain(newHtml: Document): void;
get selectedOption(): HTMLInputElement | HTMLOptionElement | undefined;
get selectedOptionId(): string | undefined;
get selectedOptionsValues(): string[];
}
Refs
Array of fieldset elements containing variant options
Methods
variantChanged()
Handles variant option selection.
variantChanged(event) {
if (!(event.target instanceof HTMLElement)) return;
const selectedOption = event.target instanceof HTMLSelectElement
? event.target.options[event.target.selectedIndex]
: event.target;
if (!selectedOption) return;
this.updateSelectedOption(event.target);
this.dispatchEvent(new VariantSelectedEvent({
id: selectedOption.dataset.optionValueId ?? '',
}));
const isOnProductPage = this.dataset.templateProductMatch === 'true' &&
!event.target.closest('product-card') &&
!event.target.closest('quick-add-dialog');
// Determine if loading a new product (combined listings)
const currentUrl = this.dataset.productUrl?.split('?')[0];
const newUrl = selectedOption.dataset.connectedProductUrl;
const loadsNewProduct = isOnProductPage && !!newUrl && newUrl !== currentUrl;
const morphElementSelector = loadsNewProduct
? 'main'
: this.closest('featured-product-information')
? 'featured-product-information'
: undefined;
this.fetchUpdatedSection(this.buildRequestUrl(selectedOption), morphElementSelector);
// Update URL
const url = new URL(window.location.href);
const variantId = selectedOption.dataset.variantId || null;
if (isOnProductPage) {
if (variantId) {
url.searchParams.set('variant', variantId);
} else {
url.searchParams.delete('variant');
}
}
if (loadsNewProduct) {
url.pathname = newUrl;
}
if (url.href !== window.location.href) {
history.replaceState({}, '', url.toString());
}
}
fetchUpdatedSection()
Fetches variant HTML and updates the page.
fetchUpdatedSection(requestUrl, morphElementSelector) {
this.#abortController?.abort();
this.#abortController = new AbortController();
fetch(requestUrl, { signal: this.#abortController.signal })
.then((response) => response.text())
.then((responseText) => {
this.#pendingRequestUrl = undefined;
const html = new DOMParser().parseFromString(responseText, 'text/html');
const textContent = html.querySelector(
`variant-picker script[type="application/json"]`
)?.textContent;
if (!textContent) return;
let newProduct;
if (morphElementSelector === 'main') {
this.updateMain(html);
} else if (morphElementSelector) {
this.updateElement(html, morphElementSelector);
} else {
newProduct = this.updateVariantPicker(html);
}
// Dispatch variant update event
if (this.selectedOptionId) {
this.dispatchEvent(
new VariantUpdateEvent(JSON.parse(textContent), this.selectedOptionId, {
html,
productId: this.dataset.productId ?? '',
newProduct,
})
);
}
})
.catch((error) => {
if (error.name === 'AbortError') {
console.warn('Fetch aborted by user');
} else {
console.error(error);
}
});
}
Properties
selectedOption
HTMLInputElement | HTMLOptionElement
Currently selected variant option elementget selectedOption() {
const selectedOption = this.querySelector(
'select option[selected], fieldset input:checked'
);
if (!(selectedOption instanceof HTMLInputElement ||
selectedOption instanceof HTMLOptionElement)) {
return undefined;
}
return selectedOption;
}
Array of all selected option value IDsget selectedOptionsValues() {
const selectedOptions = Array.from(
this.querySelectorAll('select option[selected], fieldset input:checked')
);
return selectedOptions.map((option) => {
const { optionValueId } = option.dataset;
if (!optionValueId) throw new Error('No option value ID found');
return optionValueId;
});
}
Events
Listens:
change - Variant option changed
Dispatches:
VariantSelectedEvent - When option is selected
VariantUpdateEvent - After variant data is fetched
Example
<variant-picker
data-product-id="123456"
data-product-url="/products/my-product"
>
<fieldset ref="fieldsets[]">
<legend>Color</legend>
<input
type="radio"
name="Color"
value="Red"
data-option-value-id="opt-1"
data-variant-id="789"
data-fieldset-index="0"
data-input-index="0"
checked
/>
<input
type="radio"
name="Color"
value="Blue"
data-option-value-id="opt-2"
data-variant-id="790"
data-fieldset-index="0"
data-input-index="1"
/>
</fieldset>
<script type="application/json">
{
"id": 789,
"title": "Red",
"available": true,
"price": 1999
}
</script>
</variant-picker>
ProductPrice
Dynamic price display that updates when variants change.
Class Definition
class ProductPrice extends Component<Refs> {
updatePrice(event: VariantUpdateEvent): void;
}
Refs
Container for price display
Methods
updatePrice()
Updates price display when variant changes.
updatePrice = (event) => {
if (event.detail.data.newProduct) {
this.dataset.productId = event.detail.data.newProduct.id;
} else if (event.target instanceof HTMLElement &&
event.target.dataset.productId !== this.dataset.productId) {
return;
}
const { priceContainer, volumePricingNote } = this.refs;
// Find the new product-price element in the updated HTML
const newProductPrice = event.detail.data.html.querySelector(
`product-price[data-block-id="${this.dataset.blockId}"]`
);
if (!newProductPrice) return;
// Update price container
const newPrice = newProductPrice.querySelector('[ref="priceContainer"]');
if (newPrice && priceContainer) {
priceContainer.replaceWith(newPrice);
}
// Update volume pricing note
const newNote = newProductPrice.querySelector('[ref="volumePricingNote"]');
if (!newNote) {
volumePricingNote?.remove();
} else if (!volumePricingNote) {
newPrice?.insertAdjacentElement('afterend', newNote.cloneNode(true));
} else {
volumePricingNote.replaceWith(newNote);
}
};
Example
<product-price data-block-id="price-123" data-product-id="456">
<div ref="priceContainer" class="price">
<span class="price__regular">$19.99</span>
</div>
<div ref="volumePricingNote" class="volume-pricing-note">
Buy 10+ for $17.99 each
</div>
</product-price>
ProductInventory
Displays product inventory levels that update with variant changes.
Class Definition
class ProductInventory extends Component {
updateInventory(event: VariantUpdateEvent): void;
}
Methods
updateInventory()
Updates inventory display when variant changes.
updateInventory = (event) => {
if (event.detail.data.newProduct) {
this.dataset.productId = event.detail.data.newProduct.id;
} else if (event.target instanceof HTMLElement &&
event.target.dataset.productId !== this.dataset.productId) {
return;
}
const newInventory = event.detail.data.html.querySelector('product-inventory');
if (!newInventory) return;
morph(this, newInventory, { childrenOnly: true });
};
Example
<product-inventory data-product-id="123456">
<div class="inventory-status">
<span class="inventory-status__icon">✓</span>
<span class="inventory-status__text">In stock (47 available)</span>
</div>
</product-inventory>