Product Form and Add-to-Cart Mechanics
Build product forms with proper submission handling, AJAX add-to-cart, dynamic checkout buttons, and cart drawer integration.
The product form is where browsing becomes purchasing. A well-designed form handles variant selection, quantity, and submission while providing clear feedback to customers.
Basic Product Form
At its simplest, a product form needs a variant ID and a submit button:
{# The form must post to /cart/add #}{% form 'product', product %} <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
<button type="submit">Add to Cart</button>{% endform %}Complete Product Form Structure
{# snippets/product-form.liquid #}
{%- assign current_variant = product.selected_or_first_available_variant -%}
<product-form class="product-form"> {% form 'product', product, id: 'product-form', class: 'product-form__form', novalidate: 'novalidate', data-type: 'add-to-cart-form' %} {# Error messages #} <div class="product-form__error" role="alert" hidden></div>
{# Variant picker #} {%- unless product.has_only_default_variant -%} {% render 'product-variant-picker', product: product %} {%- else -%} <input type="hidden" name="id" value="{{ current_variant.id }}"> {%- endunless -%}
{# Quantity #} <div class="product-form__quantity"> <label for="quantity" class="product-form__label">Quantity</label> {% render 'quantity-selector', value: 1 %} </div>
{# Add to cart button #} <div class="product-form__buttons"> <button type="submit" name="add" class="product-form__submit button button--primary" {% unless current_variant.available %}disabled{% endunless %} data-add-to-cart > <span data-add-to-cart-text> {%- if current_variant.available -%} Add to Cart {%- else -%} Sold Out {%- endif -%} </span> <span class="product-form__spinner" hidden> {% render 'loading-spinner' %} </span> </button>
{# Dynamic checkout button #} {%- if section.settings.show_dynamic_checkout -%} {{ form | payment_button }} {%- endif -%} </div> {% endform %}</product-form>AJAX Add to Cart
Prevent page reload and update cart dynamically:
class ProductForm extends HTMLElement { constructor() { super(); this.form = this.querySelector('form'); this.submitButton = this.querySelector('[data-add-to-cart]'); this.errorMessage = this.querySelector('.product-form__error'); }
connectedCallback() { this.form.addEventListener('submit', this.onSubmit.bind(this)); }
async onSubmit(event) { event.preventDefault();
if (this.submitButton.disabled) return;
this.setLoading(true); this.hideError();
try { const formData = new FormData(this.form);
const response = await fetch('/cart/add.js', { method: 'POST', body: formData, });
const result = await response.json();
if (response.ok) { // Success: Update cart and show feedback this.onSuccess(result); } else { // Error from Shopify (e.g., sold out) throw new Error(result.description || 'Could not add to cart'); } } catch (error) { this.showError(error.message); } finally { this.setLoading(false); } }
onSuccess(item) { // Dispatch event for cart drawer/icon document.dispatchEvent( new CustomEvent('cart:added', { detail: { item }, }) );
// Optional: Show success message this.showSuccessMessage();
// Optional: Open cart drawer document.querySelector('cart-drawer')?.open(); }
setLoading(loading) { this.submitButton.disabled = loading; this.submitButton.querySelector('[data-add-to-cart-text]').hidden = loading; this.submitButton.querySelector('.product-form__spinner').hidden = !loading; }
showError(message) { this.errorMessage.textContent = message; this.errorMessage.hidden = false; }
hideError() { this.errorMessage.hidden = true; }
showSuccessMessage() { const text = this.submitButton.querySelector('[data-add-to-cart-text]'); const originalText = text.textContent;
text.textContent = 'Added!';
setTimeout(() => { text.textContent = originalText; }, 2000); }}
customElements.define('product-form', ProductForm);Form Styles
.product-form { display: flex; flex-direction: column; gap: var(--spacing-lg);}
.product-form__error { padding: var(--spacing-sm) var(--spacing-md); background: #fee2e2; color: #dc2626; border-radius: var(--border-radius); font-size: 0.875rem;}
.product-form__quantity { max-width: 150px;}
.product-form__label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: var(--spacing-sm);}
.product-form__buttons { display: flex; flex-direction: column; gap: var(--spacing-sm);}
.product-form__submit { position: relative; width: 100%; padding: var(--spacing-md) var(--spacing-lg); font-size: 1rem; font-weight: 600;}
.product-form__submit:disabled { opacity: 0.6; cursor: not-allowed;}
.product-form__spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;}
/* Shopify dynamic checkout button */.shopify-payment-button { margin-top: var(--spacing-xs);}
.shopify-payment-button__button { border-radius: var(--border-radius) !important;}Dynamic Checkout Button
Shopify’s dynamic checkout shows accelerated payment options:
{%- if section.settings.show_dynamic_checkout -%} {{ form | payment_button }}{%- endif -%}Style it to match your theme:
.shopify-payment-button__button { min-height: 50px !important; border-radius: var(--border-radius) !important;}
.shopify-payment-button__button--unbranded { background: var(--color-secondary) !important; color: var(--color-text) !important;}Updating Button State
Listen for variant changes:
document.addEventListener('variant:changed', (event) => { const variant = event.detail.variant; const button = document.querySelector('[data-add-to-cart]'); const buttonText = button?.querySelector('[data-add-to-cart-text]');
if (!button || !buttonText) return;
if (variant && variant.available) { button.disabled = false; buttonText.textContent = 'Add to Cart'; } else { button.disabled = true; buttonText.textContent = variant ? 'Sold Out' : 'Unavailable'; }});Cart Notification
Show a notification after adding:
{# snippets/cart-notification.liquid #}
<div class="cart-notification" id="cart-notification" hidden> <div class="cart-notification__content"> <div class="cart-notification__product"> <img class="cart-notification__image" data-notification-image src="" alt=""> <div class="cart-notification__details"> <p class="cart-notification__title" data-notification-title></p> <p class="cart-notification__variant" data-notification-variant></p> </div> </div>
<div class="cart-notification__buttons"> <a href="{{ routes.cart_url }}" class="button button--secondary"> View Cart </a> <button class="button button--primary" data-notification-checkout> Checkout </button> </div> </div>
<button class="cart-notification__close" data-notification-close aria-label="Close"> {% render 'icon-close' %} </button></div>class CartNotification extends HTMLElement { connectedCallback() { document.addEventListener('cart:added', this.onCartAdd.bind(this));
this.querySelector('[data-notification-close]')?.addEventListener('click', () => { this.hide(); });
this.querySelector('[data-notification-checkout]')?.addEventListener('click', () => { window.location.href = '/checkout'; }); }
onCartAdd(event) { const item = event.detail.item;
this.querySelector('[data-notification-image]').src = item.image; this.querySelector('[data-notification-title]').textContent = item.product_title; this.querySelector('[data-notification-variant]').textContent = item.variant_title;
this.show();
// Auto-hide after 5 seconds setTimeout(() => this.hide(), 5000); }
show() { this.hidden = false; this.classList.add('is-visible'); }
hide() { this.classList.remove('is-visible'); setTimeout(() => { this.hidden = true; }, 300); }}
customElements.define('cart-notification', CartNotification);Pre-Order Form
Handle products that aren’t available yet:
{%- if product.tags contains 'pre-order' -%} {%- assign is_preorder = true -%}{%- endif -%}
<button type="submit" class="product-form__submit" {% unless current_variant.available or is_preorder %}disabled{% endunless %}> {%- if is_preorder -%} Pre-Order Now {%- elsif current_variant.available -%} Add to Cart {%- else -%} Sold Out {%- endif -%}</button>Buy Now Button
Skip the cart and go directly to checkout:
<button type="submit" name="add" formaction="/cart/add" class="button button--primary"> Add to Cart</button>
<button type="submit" name="add" formaction="/checkout" class="button button--secondary"> Buy It Now</button>Or with JavaScript:
buyNowButton.addEventListener('click', async (e) => { e.preventDefault();
const formData = new FormData(form);
await fetch('/cart/add.js', { method: 'POST', body: formData, });
window.location.href = '/checkout';});Inventory Warnings
Show low stock warnings:
{%- if current_variant.inventory_management and current_variant.inventory_policy == 'deny' -%} {%- if current_variant.inventory_quantity > 0 and current_variant.inventory_quantity <= 5 -%} <p class="product-form__inventory-warning"> Only {{ current_variant.inventory_quantity }} left in stock! </p> {%- endif -%}{%- endif -%}Complete Product Form
{# snippets/product-form.liquid #}
{%- assign current_variant = product.selected_or_first_available_variant -%}
<product-form class="product-form" data-section="{{ section.id }}"> {% form 'product', product, id: 'product-form-{{ section.id }}', class: 'product-form__form', novalidate: 'novalidate' %}
{# Error message #} <div class="product-form__error" role="alert" hidden data-error-message></div>
{# Variant picker #} {%- render 'product-variant-picker', product: product -%}
{# Quantity selector #} <div class="product-form__group"> <label class="product-form__label" for="quantity-{{ section.id }}"> Quantity </label> {%- render 'quantity-selector', id: section.id, value: 1, min: 1, max: current_variant.inventory_quantity | default: 99 -%} </div>
{# Inventory warning #} {%- if current_variant.inventory_quantity > 0 and current_variant.inventory_quantity <= 5 -%} <p class="product-form__stock-warning" data-stock-warning> Only {{ current_variant.inventory_quantity }} left! </p> {%- endif -%}
{# Buttons #} <div class="product-form__buttons"> <button type="submit" name="add" class="product-form__submit button button--primary button--full" {% unless current_variant.available %}disabled{% endunless %} data-add-button > <span data-button-text> {%- if current_variant.available -%} Add to Cart - {{ current_variant.price | money }} {%- else -%} Sold Out {%- endif -%} </span> <span class="product-form__loading" hidden data-loading> {%- render 'loading-spinner' -%} </span> </button>
{%- if section.settings.show_dynamic_checkout -%} {{ form | payment_button }} {%- endif -%} </div>
{% endform %}
{# Pickup availability #} <div data-pickup-availability> {%- render 'pickup-availability', variant: current_variant -%} </div></product-form>
<script type="application/json" id="product-data-{{ section.id }}"> { "id": {{ product.id }}, "variants": {{ product.variants | json }} }</script>Practice Exercise
Build a product form that:
- Submits via AJAX (no page reload)
- Shows a loading spinner during submission
- Displays error messages on failure
- Updates button text on success
- Triggers a cart drawer or notification
Test scenarios:
- Adding available variants
- Trying to add sold out variants
- Adding multiple quantities
- Network errors
Key Takeaways
- Use
{% form 'product' %}for proper form structure - AJAX submission prevents page reload
- Handle errors from the API gracefully
- Show loading states during submission
- Update cart UI after successful add
- Dynamic checkout button via
{{ form | payment_button }} - Listen for variant changes to update button state
- Provide feedback through notifications or drawer
What’s Next?
The next lesson covers Quantity Controls and Line-Item Properties for advanced form functionality.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...