Product Page Components Intermediate 12 min read
Add to Cart Form with Quantity Controls
Build an add to cart form with quantity input, loading states, and success feedback. Handle sold-out products and error states gracefully.
The Add to Cart form is where customers commit to a purchase. It needs to be clear, responsive, and provide instant feedback.
Theme Integration
This component is part of the product page component hierarchy:
sections/product-main.liquid└── <div id="product-page-root"> └── ProductPage (React) └── AddToCartForm ← You are here └── QuantityInputSee Product Page Architecture for the complete Liquid section setup and variant availability data.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
variant | Parent (ProductPage) | Derived from selected options → product.variants |
variant.id | Liquid JSON | variant.id |
variant.inventoryQuantity | Liquid JSON | variant.inventory_quantity (if policy allows) |
quantity | Parent state | - (user input) |
isAvailable | Derived | variant.available |
isAdding | Local state | - |
justAdded | Local state | - |
error | Local state | - |
Add to Cart Form Component
import { useState } from 'react';import { useCart } from '@/stores/cart'; // Zustand cart store.import type { ProductVariant } from '@/types/product';import { QuantityInput } from './QuantityInput';import { Button } from '@/components/ui';import styles from './AddToCartForm.module.css';
interface AddToCartFormProps { variant: ProductVariant | null; // Currently selected variant. quantity: number; // Quantity to add. onQuantityChange: (quantity: number) => void; // Callback when quantity changes. isAvailable: boolean; // Whether the variant is in stock.}
/** * AddToCartForm handles adding products to the cart. * Includes quantity input, submit button with states, and error handling. */export function AddToCartForm({ variant, quantity, onQuantityChange, isAvailable,}: AddToCartFormProps) { const addItem = useCart((state) => state.addItem); const [isAdding, setIsAdding] = useState(false); // Loading state during API call. const [justAdded, setJustAdded] = useState(false); // Success feedback state. const [error, setError] = useState<string | null>(null); // Error message state.
// Handle form submission. const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); // Prevent page reload.
// Guard: don't submit if variant not selected or unavailable. if (!variant || !isAvailable) return;
setIsAdding(true); setError(null); // Clear previous errors.
try { await addItem(variant.id, quantity); // Call cart store action. setJustAdded(true); // Show success state. setTimeout(() => setJustAdded(false), 3000); // Reset after 3 seconds. } catch (err) { setError('Failed to add to cart. Please try again.'); } finally { setIsAdding(false); // Always reset loading state. } };
// Limit max quantity to inventory if tracked. const maxQuantity = variant?.inventoryQuantity ?? 99;
return ( <form className={styles.form} onSubmit={handleSubmit}> <div className={styles.controls}> {/* Quantity selector with +/- buttons */} <QuantityInput value={quantity} onChange={onQuantityChange} min={1} max={maxQuantity} disabled={!isAvailable || isAdding} />
{/* Submit button with dynamic state */} <Button type="submit" variant={justAdded ? 'secondary' : 'primary'} size="large" disabled={!variant || !isAvailable || isAdding} loading={isAdding} fullWidth > {getButtonText(isAvailable, isAdding, justAdded)} </Button> </div>
{/* Error message display */} {error && ( <div className={styles.error} role="alert"> {error} </div> )}
{/* Back-in-stock notification form for sold-out items */} {!isAvailable && variant && ( <NotifyForm variantId={variant.id} /> )} </form> );}
/** * Helper to determine button text based on current state. */function getButtonText(isAvailable: boolean, isAdding: boolean, justAdded: boolean): string { if (!isAvailable) return 'Sold Out'; if (isAdding) return 'Adding...'; if (justAdded) return 'Added to Cart!'; return 'Add to Cart';}.form { margin: 1.5rem 0;}
.controls { display: flex; gap: 1rem;}
.error { margin-top: 1rem; padding: 0.75rem; background: rgba(var(--color-error-rgb), 0.1); color: var(--color-error); font-size: 0.875rem; border-radius: 6px;}Quantity Input Component
import { useId } from 'react'; // React 18 hook for generating unique IDs.import styles from './QuantityInput.module.css';
interface QuantityInputProps { value: number; // Current quantity value. onChange: (value: number) => void; // Called when quantity changes. min?: number; // Minimum allowed quantity. max?: number; // Maximum allowed quantity (often based on inventory). disabled?: boolean; // Disables the entire input when true.}
/** * QuantityInput provides a number input with increment/decrement buttons. * Handles validation to keep value within min/max bounds. */export function QuantityInput({ value, onChange, min = 1, max = 99, disabled = false,}: QuantityInputProps) { // useId generates a unique ID for accessibility (label + input association). const inputId = useId();
// Decrement quantity, respecting minimum. const handleDecrease = () => { if (value > min) { onChange(value - 1); } };
// Increment quantity, respecting maximum. const handleIncrease = () => { if (value < max) { onChange(value + 1); } };
// Handle direct input changes with validation. const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const newValue = parseInt(event.target.value, 10); // Only update if valid number within bounds. if (!isNaN(newValue) && newValue >= min && newValue <= max) { onChange(newValue); } };
return ( <div className={styles.wrapper}> <label htmlFor={inputId} className={styles.label}> Quantity </label>
<div className={styles.input}> {/* Decrease button */} <button type="button" className={styles.button} onClick={handleDecrease} disabled={disabled || value <= min} // Disable at minimum. aria-label="Decrease quantity" > <MinusIcon /> </button>
{/* Number input for direct editing */} <input id={inputId} type="number" className={styles.field} value={value} onChange={handleInputChange} min={min} max={max} disabled={disabled} aria-label="Quantity" />
{/* Increase button */} <button type="button" className={styles.button} onClick={handleIncrease} disabled={disabled || value >= max} // Disable at maximum. aria-label="Increase quantity" > <PlusIcon /> </button> </div> </div> );}
/** * Minus icon for decrease button. */function MinusIcon() { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M3 8h10" /> </svg> );}
/** * Plus icon for increase button. */function PlusIcon() { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M8 3v10M3 8h10" /> </svg> );}.wrapper { flex-shrink: 0;}
.label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: 500;}
.input { display: inline-flex; align-items: center; border: 1px solid var(--color-border); border-radius: 6px;}
.button { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: none; background: transparent; cursor: pointer; transition: background 0.15s ease;}
.button:hover:not(:disabled) { background: var(--color-background-subtle);}
.button:disabled { opacity: 0.3; cursor: not-allowed;}
.field { width: 50px; height: 44px; padding: 0; border: none; border-left: 1px solid var(--color-border); border-right: 1px solid var(--color-border); background: transparent; text-align: center; font-size: 1rem; font-weight: 500; -moz-appearance: textfield;}
.field::-webkit-outer-spin-button,.field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0;}
.field:focus { outline: none; background: var(--color-background-subtle);}Notify When Available
For sold-out products, offer email notification:
import { useState } from 'react';import { Button, Input } from '@/components/ui';import styles from './NotifyForm.module.css';
interface NotifyFormProps { variantId: number; // The variant to watch for back-in-stock.}
/** * NotifyForm allows customers to subscribe to back-in-stock notifications. * Shown when a variant is sold out. */export function NotifyForm({ variantId }: NotifyFormProps) { const [email, setEmail] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => { event.preventDefault();
if (!email) return; // Guard: require email.
setIsSubmitting(true); setError(null);
try { // Call your back-in-stock notification API. // This could be a custom endpoint, Klaviyo, Mailchimp, etc. await fetch('/api/notify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, variantId }), });
setIsSubmitted(true); // Show success message. } catch (err) { setError('Failed to subscribe. Please try again.'); } finally { setIsSubmitting(false); } };
// Success state: show confirmation message. if (isSubmitted) { return ( <div className={styles.success}> <CheckIcon /> <span>We'll email you when this is back in stock!</span> </div> ); }
// Default state: show email signup form. return ( <div className={styles.notify}> <p className={styles.message}> This item is currently sold out. Enter your email to be notified when it's back. </p>
<form className={styles.form} onSubmit={handleSubmit}> <Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required disabled={isSubmitting} />
<Button type="submit" variant="secondary" loading={isSubmitting}> Notify Me </Button> </form>
{/* Error message */} {error && <p className={styles.error}>{error}</p>} </div> );}
/** * Checkmark icon for success state. */function CheckIcon() { return ( <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M16.5 5.5L7.5 14.5 3.5 10.5" /> </svg> );}Buy Now Button
Add a direct checkout option:
import { useState } from 'react';import { Button } from '@/components/ui';
interface BuyNowButtonProps { variantId: number; // Variant to purchase. quantity: number; // Quantity to add. disabled?: boolean; // Disable the button.}
/** * BuyNowButton provides a direct path to checkout. * Adds the item to cart and immediately redirects to checkout. */export function BuyNowButton({ variantId, quantity, disabled }: BuyNowButtonProps) { const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => { setIsLoading(true);
try { // Step 1: Add item to cart via Shopify's AJAX API. await fetch('/cart/add.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: variantId, quantity }), });
// Step 2: Redirect to checkout page. // This triggers a full page navigation to Shopify's checkout. window.location.href = '/checkout'; } catch (error) { console.error('Buy now failed:', error); setIsLoading(false); // Only reset on error (success navigates away). } };
return ( <Button variant="secondary" size="large" fullWidth onClick={handleClick} disabled={disabled} loading={isLoading} > Buy Now </Button> );}Sticky Add to Cart (Mobile)
Show a fixed add to cart bar on mobile:
import { useState, useEffect } from 'react';import type { ProductVariant } from '@/types/product';import { useCart } from '@/stores/cart';import { Button } from '@/components/ui';import { formatMoney } from '@/utils/money';import styles from './StickyAddToCart.module.css';
interface StickyAddToCartProps { variant: ProductVariant | null; // Currently selected variant. price: number; // Price to display. isAvailable: boolean; // Whether the variant is available. productTitle: string; // Product title to display.}
/** * StickyAddToCart shows a fixed bottom bar on mobile when the main add-to-cart * button is scrolled out of view. Provides quick access to purchase. */export function StickyAddToCart({ variant, price, isAvailable, productTitle,}: StickyAddToCartProps) { const [isVisible, setIsVisible] = useState(false); const addItem = useCart((state) => state.addItem); const [isAdding, setIsAdding] = useState(false);
// Show sticky bar only when main add-to-cart button is scrolled out of view. useEffect(() => { const handleScroll = () => { // Look for the main add-to-cart button (marked with data attribute). const addToCartButton = document.querySelector('[data-add-to-cart]'); if (addToCartButton) { const rect = addToCartButton.getBoundingClientRect(); // Show sticky bar when button's bottom edge is above viewport. setIsVisible(rect.bottom < 0); } };
// Use passive listener for scroll performance. window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, []);
// Handle add to cart from sticky bar. const handleClick = async () => { if (!variant || !isAvailable) return;
setIsAdding(true); try { await addItem(variant.id, 1); // Add 1 unit from sticky bar. } finally { setIsAdding(false); } };
// Don't render when main button is visible. if (!isVisible) return null;
return ( <div className={styles.bar}> {/* Product info summary */} <div className={styles.info}> <span className={styles.title}>{productTitle}</span> <span className={styles.price}>{formatMoney(price)}</span> </div>
{/* Compact add button */} <Button variant="primary" size="small" onClick={handleClick} disabled={!variant || !isAvailable} loading={isAdding} > {isAvailable ? 'Add' : 'Sold Out'} </Button> </div> );}.bar { display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 100; padding: 0.75rem 1rem; background: var(--color-background); border-top: 1px solid var(--color-border); box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); animation: slideUp 0.3s ease;}
@media (max-width: 767px) { .bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); }}
.info { flex: 1; min-width: 0;}
.title { display: block; font-size: 0.875rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
.price { font-size: 0.8125rem; color: var(--color-text-muted);}Key Takeaways
- Clear feedback: Show loading, success, and error states
- Quantity validation: Enforce min/max based on inventory
- Sold out handling: Offer email notification option
- Buy now option: Direct path to checkout
- Mobile sticky bar: Always-visible add to cart on mobile
- Success animation: Visual confirmation of successful add
In the next lesson, we’ll build Product Tabs and Accordions.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...