Product Page Implementation Intermediate 12 min read

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:

  1. Submits via AJAX (no page reload)
  2. Shows a loading spinner during submission
  3. Displays error messages on failure
  4. Updates button text on success
  5. Triggers a cart drawer or notification

Test scenarios:

  • Adding available variants
  • Trying to add sold out variants
  • Adding multiple quantities
  • Network errors

Key Takeaways

  1. Use {% form 'product' %} for proper form structure
  2. AJAX submission prevents page reload
  3. Handle errors from the API gracefully
  4. Show loading states during submission
  5. Update cart UI after successful add
  6. Dynamic checkout button via {{ form | payment_button }}
  7. Listen for variant changes to update button state
  8. 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...