Skip to main content

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

variantId
HTMLInputElement
required
Hidden input containing the selected variant ID
addToCartButtonContainer
AddToCartComponent
Container for the add-to-cart button
addToCartTextError
HTMLElement
Error message display element
acceleratedCheckoutButtonContainer
HTMLElement
Container for Shop Pay and other accelerated checkout buttons
liveRegion
HTMLElement
required
ARIA live region for screen reader announcements
quantityLabelCartCount
HTMLElement
Label showing quantity already in cart
quantityRules
HTMLElement
B2B quantity rules display
productFormButtons
HTMLElement
Container for form buttons and quantity selector
volumePricing
HTMLElement
B2B volume pricing component
quantitySelector
QuantitySelectorComponent
Quantity selector component instance
quantitySelectorWrapper
HTMLElement
Wrapper around quantity selector
quantityLabel
HTMLElement
Label for quantity selector
pricePerItem
HTMLElement
B2B price-per-item display

Methods

handleSubmit()

Handles form submission and add-to-cart action.
product-form.js
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:
product-form.js
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

product-form.js
#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.
product-form.js
disable() {
  this.refs.addToCartButton.disabled = true;
}

enable()

Enables the add-to-cart button.
product-form.js
enable() {
  this.refs.addToCartButton.disabled = false;
}

handleClick()

Handles button click with optional fly-to-cart animation.
product-form.js
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”).
product-form.js
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
boolean
Enable fly-to-cart animation
data-product-variant-media
string
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

quantityInput
HTMLInputElement
required
The number input element
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

fieldsets
HTMLFieldSetElement[]
Array of fieldset elements containing variant options

Methods

variantChanged()

Handles variant option selection.
variant-picker.js
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.
variant-picker.js
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 element
get selectedOption() {
  const selectedOption = this.querySelector(
    'select option[selected], fieldset input:checked'
  );
  
  if (!(selectedOption instanceof HTMLInputElement || 
        selectedOption instanceof HTMLOptionElement)) {
    return undefined;
  }
  
  return selectedOption;
}
selectedOptionsValues
string[]
Array of all selected option value IDs
get 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

priceContainer
HTMLElement
required
Container for price display
volumePricingNote
HTMLElement
B2B volume pricing note

Methods

updatePrice()

Updates price display when variant changes.
product-price.js
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.
product-inventory.js
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>

Build docs developers (and LLMs) love