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
└── QuantityInput

See Product Page Architecture for the complete Liquid section setup and variant availability data.

Data Source

Prop/StateSourceLiquid Field
variantParent (ProductPage)Derived from selected options → product.variants
variant.idLiquid JSONvariant.id
variant.inventoryQuantityLiquid JSONvariant.inventory_quantity (if policy allows)
quantityParent state- (user input)
isAvailableDerivedvariant.available
isAddingLocal state-
justAddedLocal state-
errorLocal state-

Add to Cart Form Component

src/components/product/AddToCartForm/AddToCartForm.tsx
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';
}
src/components/product/AddToCartForm/AddToCartForm.module.css
.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

src/components/product/AddToCartForm/QuantityInput.tsx
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>
);
}
src/components/product/AddToCartForm/QuantityInput.module.css
.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:

src/components/product/AddToCartForm/NotifyForm.tsx
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"
placeholder="[email protected]"
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:

src/components/product/AddToCartForm/BuyNowButton.tsx
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:

src/components/product/StickyAddToCart/StickyAddToCart.tsx
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>
);
}
src/components/product/StickyAddToCart/StickyAddToCart.module.css
.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

  1. Clear feedback: Show loading, success, and error states
  2. Quantity validation: Enforce min/max based on inventory
  3. Sold out handling: Offer email notification option
  4. Buy now option: Direct path to checkout
  5. Mobile sticky bar: Always-visible add to cart on mobile
  6. 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...