Empty Cart State and Cross-Sells
Create engaging empty cart experiences and implement cross-sell recommendations to boost average order value. Turn abandonment into opportunity.
An empty cart isnβt the endβitβs an opportunity. A well-designed empty state can guide customers back to shopping, while cross-sells can increase order value. Letβs build both.
Theme Integration
This component is part of the cart drawer component hierarchy:
snippets/cart-drawer.liquid (included in theme.liquid)βββ <div id="cart-drawer-root"> βββ CartDrawer (React) βββ CartEmpty β You are here (when cart.items.length === 0)Cross-sell products are fetched client-side via the Shopify AJAX API. See Cart Drawer Architecture for the complete Liquid setup.
Data Source
| Prop/State | Source | Origin |
|---|---|---|
cart.items | Zustand store | cart.items from Liquid JSON |
products (empty state) | Shopify API | GET /collections/{handle}/products.json |
recommendations (cross-sells) | Shopify API | GET /recommendations/products.json?intent=complementary |
isLoading | Local state | - |
addedIds | Local state | - (tracks which items were added) |
currentTotal | Zustand store | cart.total_price |
threshold | Config / Metafield | Store settings or hardcoded |
Note: Featured products and cross-sell recommendations are fetched client-side via Shopify APIs, not from Liquid JSON.
Empty Cart Component
import { useEffect, useState } from 'react';import { Button } from '@/components/ui';import { ProductCard } from '@/components/collection/ProductCard';import type { Product } from '@/types/product';import styles from './CartEmpty.module.css';
interface CartEmptyProps { onClose?: () => void; // Optional close callback for drawer context.}
/** * CartEmpty displays when the cart has no items. * Features: * - Friendly messaging * - Clear call-to-action * - Popular products suggestions */export function CartEmpty({ onClose }: CartEmptyProps) { // Fetch featured products to show as suggestions. const [products, setProducts] = useState<Product[]>([]); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { // Fetch featured/popular products to display. fetchFeaturedProducts() .then(setProducts) .finally(() => setIsLoading(false)); }, []);
return ( <div className={styles.container}> {/* Empty state illustration and message */} <div className={styles.message}> <div className={styles.illustration}> <EmptyCartIcon /> </div>
<h2 className={styles.title}>Your cart is empty</h2> <p className={styles.description}> Looks like you haven't added anything yet. Start shopping to fill it up! </p>
<div className={styles.actions}> <Button as="a" href="/collections/all" variant="primary" size="large" onClick={onClose} > Start Shopping </Button> </div> </div>
{/* Featured products suggestions */} {products.length > 0 && ( <div className={styles.suggestions}> <h3 className={styles.suggestionsTitle}>Popular Right Now</h3> <div className={styles.suggestionsGrid}> {products.slice(0, 4).map((product) => ( <ProductCard key={product.id} product={product} compact /> ))} </div> </div> )}
{/* Loading skeleton for suggestions */} {isLoading && ( <div className={styles.suggestions}> <div className={styles.suggestionsTitle}>Popular Right Now</div> <div className={styles.suggestionsGrid}> {[1, 2, 3, 4].map((i) => ( <ProductCardSkeleton key={i} /> ))} </div> </div> )} </div> );}
/** * Fetch featured products from a collection or API. */async function fetchFeaturedProducts(): Promise<Product[]> { try { // Fetch from a "featured" collection or bestsellers. const response = await fetch('/collections/bestsellers/products.json?limit=4'); if (!response.ok) { // Fallback to all products if collection doesn't exist. const fallback = await fetch('/collections/all/products.json?limit=4'); const data = await fallback.json(); return data.products || []; } const data = await response.json(); return data.products || []; } catch (error) { console.error('Failed to fetch featured products:', error); return []; }}
/** * Empty cart SVG illustration. */function EmptyCartIcon() { return ( <svg width="120" height="120" viewBox="0 0 120 120" fill="none" className={styles.icon} > {/* Shopping bag outline */} <path d="M30 40h60v60a8 8 0 0 1-8 8H38a8 8 0 0 1-8-8V40z" stroke="currentColor" strokeWidth="2" fill="none" /> {/* Bag handles */} <path d="M45 40V30a15 15 0 0 1 30 0v10" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" /> {/* Decorative stars */} <circle cx="60" cy="70" r="3" fill="currentColor" opacity="0.3" /> <circle cx="50" cy="80" r="2" fill="currentColor" opacity="0.2" /> <circle cx="70" cy="85" r="2" fill="currentColor" opacity="0.2" /> </svg> );}
/** * Skeleton loading state for product cards. */function ProductCardSkeleton() { return ( <div className={styles.skeleton}> <div className={styles.skeletonImage} /> <div className={styles.skeletonTitle} /> <div className={styles.skeletonPrice} /> </div> );}.container { display: flex; flex-direction: column; min-height: 100%;}
/* Main empty message section */.message { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 3rem 1.5rem; flex: 1;}
.illustration { color: var(--color-text-muted); margin-bottom: 1.5rem;}
.icon { opacity: 0.6;}
.title { margin: 0 0 0.5rem; font-size: 1.5rem; font-weight: 600; color: var(--color-text);}
.description { margin: 0 0 1.5rem; color: var(--color-text-muted); max-width: 280px;}
.actions { display: flex; gap: 0.75rem;}
/* Suggestions section */.suggestions { padding: 1.5rem; border-top: 1px solid var(--color-border); background-color: var(--color-surface);}
.suggestionsTitle { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; text-align: center;}
.suggestionsGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;}
/* Skeleton loading states */.skeleton { display: flex; flex-direction: column; gap: 0.5rem;}
.skeletonImage { aspect-ratio: 1; background: linear-gradient( 90deg, var(--color-surface) 25%, var(--color-border) 50%, var(--color-surface) 75% ); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
.skeletonTitle { height: 1rem; width: 80%; background: linear-gradient( 90deg, var(--color-surface) 25%, var(--color-border) 50%, var(--color-surface) 75% ); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
.skeletonPrice { height: 0.875rem; width: 40%; background: linear-gradient( 90deg, var(--color-surface) 25%, var(--color-border) 50%, var(--color-surface) 75% ); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; }}
/* Single column on mobile */@media (max-width: 360px) { .suggestionsGrid { grid-template-columns: 1fr; }}Cross-Sell Component
import { useEffect, useState } from 'react';import { useCart } from '@/stores/cart';import type { Product } from '@/types/product';import { formatMoney } from '@/utils/money';import { Button } from '@/components/ui';import styles from './CartCrossSells.module.css';
/** * CartCrossSells displays product recommendations based on cart contents. * Shows complementary products to increase average order value. */export function CartCrossSells() { const { cart, addItem, isAdding } = useCart(); const [recommendations, setRecommendations] = useState<Product[]>([]); const [isLoading, setIsLoading] = useState(true); const [addedIds, setAddedIds] = useState<Set<number>>(new Set());
// Fetch recommendations based on cart items. useEffect(() => { if (!cart?.items.length) { setRecommendations([]); setIsLoading(false); return; }
// Use the first cart item to get recommendations. const productId = cart.items[0].productId; fetchRecommendations(productId, cart.items.map(i => i.productId)) .then(setRecommendations) .finally(() => setIsLoading(false)); }, [cart?.items]);
// Handle adding a cross-sell to cart. const handleAdd = async (product: Product) => { if (!product.variants[0]) return;
await addItem({ id: product.variants[0].id, quantity: 1, });
// Mark as added for visual feedback. setAddedIds((prev) => new Set(prev).add(product.id)); };
// Don't render if no recommendations. if (!isLoading && recommendations.length === 0) return null;
return ( <div className={styles.crossSells}> <h3 className={styles.title}>Complete Your Order</h3>
{isLoading ? ( <CrossSellSkeleton /> ) : ( <div className={styles.list}> {recommendations.slice(0, 3).map((product) => { const isAdded = addedIds.has(product.id); const variant = product.variants[0];
return ( <div key={product.id} className={styles.item}> {/* Product image */} <a href={product.url} className={styles.imageLink}> <img src={product.featuredImage?.url} alt={product.title} className={styles.image} width={60} height={60} loading="lazy" /> </a>
{/* Product info */} <div className={styles.info}> <a href={product.url} className={styles.name}> {product.title} </a> <span className={styles.price}> {formatMoney(variant?.price ?? 0)} </span> </div>
{/* Add button */} <Button size="small" variant={isAdded ? 'secondary' : 'primary'} onClick={() => handleAdd(product)} disabled={isAdded || isAdding} className={styles.addButton} > {isAdded ? 'Added β' : 'Add'} </Button> </div> ); })} </div> )} </div> );}
/** * Fetch product recommendations from Shopify's Recommendations API. * Filters out products already in cart. */async function fetchRecommendations( productId: number, excludeIds: number[]): Promise<Product[]> { try { const response = await fetch( `/recommendations/products.json?product_id=${productId}&limit=6&intent=complementary` );
if (!response.ok) { throw new Error('Failed to fetch recommendations'); }
const data = await response.json(); const products: Product[] = data.products || [];
// Filter out products already in cart. return products.filter((p) => !excludeIds.includes(p.id)); } catch (error) { console.error('Failed to fetch recommendations:', error); return []; }}
/** * Loading skeleton for cross-sells. */function CrossSellSkeleton() { return ( <div className={styles.list}> {[1, 2, 3].map((i) => ( <div key={i} className={styles.skeletonItem}> <div className={styles.skeletonImage} /> <div className={styles.skeletonInfo}> <div className={styles.skeletonName} /> <div className={styles.skeletonPrice} /> </div> <div className={styles.skeletonButton} /> </div> ))} </div> );}.crossSells { padding: 1rem 1.25rem; border-top: 1px solid var(--color-border); background-color: var(--color-surface);}
.title { margin: 0 0 0.75rem; font-size: 0.875rem; font-weight: 600; color: var(--color-text);}
.list { display: flex; flex-direction: column; gap: 0.75rem;}
/* Individual cross-sell item */.item { display: grid; grid-template-columns: 60px 1fr auto; gap: 0.75rem; align-items: center;}
.imageLink { display: block;}
.image { width: 60px; height: 60px; object-fit: cover; border-radius: var(--radius-sm); background-color: var(--color-background);}
.info { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0;}
.name { font-size: 0.875rem; font-weight: 500; color: var(--color-text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.name:hover { text-decoration: underline;}
.price { font-size: 0.8125rem; color: var(--color-text-muted);}
.addButton { flex-shrink: 0;}
/* Skeleton loading states */.skeletonItem { display: grid; grid-template-columns: 60px 1fr auto; gap: 0.75rem; align-items: center;}
.skeletonImage { width: 60px; height: 60px; background: linear-gradient(90deg, var(--color-background) 25%, var(--color-border) 50%, var(--color-background) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
.skeletonInfo { display: flex; flex-direction: column; gap: 0.25rem;}
.skeletonName { height: 0.875rem; width: 80%; background: linear-gradient(90deg, var(--color-background) 25%, var(--color-border) 50%, var(--color-background) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
.skeletonPrice { height: 0.75rem; width: 40%; background: linear-gradient(90deg, var(--color-background) 25%, var(--color-border) 50%, var(--color-background) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
.skeletonButton { width: 50px; height: 32px; background: linear-gradient(90deg, var(--color-background) 25%, var(--color-border) 50%, var(--color-background) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; }}Integrating Cross-Sells in Cart Drawer
// src/components/cart/CartDrawer/CartDrawer.tsx - Updated with cross-sellsimport { CartCrossSells } from '../CartCrossSells';
export function CartDrawer() { const { cart, isLoading } = useCart(); const { isOpen, close } = useCartDrawer();
if (!isOpen) return null;
const hasItems = cart && cart.items.length > 0;
return createPortal( <div className={styles.overlay} onClick={close}> <div className={styles.drawer} onClick={(e) => e.stopPropagation()}> <CartHeader itemCount={cart?.itemCount ?? 0} onClose={close} />
<div className={styles.content}> {isLoading ? ( <CartSkeleton /> ) : hasItems ? ( <> <CartItems items={cart!.items} /> {/* Cross-sells below cart items */} <CartCrossSells /> </> ) : ( <CartEmpty onClose={close} /> )} </div>
{hasItems && <CartFooter cart={cart!} />} </div> </div>, document.body );}Free Shipping Progress Bar
import { formatMoney } from '@/utils/money';import styles from './FreeShippingBar.module.css';
interface FreeShippingBarProps { currentTotal: number; // Current cart total in cents. threshold: number; // Free shipping threshold in cents.}
/** * FreeShippingBar shows progress toward free shipping. * Motivates customers to add more items to qualify. */export function FreeShippingBar({ currentTotal, threshold }: FreeShippingBarProps) { // Calculate progress percentage. const progress = Math.min((currentTotal / threshold) * 100, 100); const remaining = threshold - currentTotal; const qualified = remaining <= 0;
return ( <div className={styles.container}> {qualified ? ( // Customer qualifies for free shipping. <div className={styles.qualified}> <span className={styles.icon}>π</span> <span className={styles.message}> Congrats! You've unlocked <strong>free shipping</strong>! </span> </div> ) : ( // Show progress toward free shipping. <> <p className={styles.message}> Add <strong>{formatMoney(remaining)}</strong> more for{' '} <strong>free shipping</strong> </p> <div className={styles.track}> <div className={styles.progress} style={{ width: `${progress}%` }} role="progressbar" aria-valuenow={progress} aria-valuemin={0} aria-valuemax={100} /> </div> </> )} </div> );}.container { padding: 0.75rem 1.25rem; background-color: var(--color-surface); border-bottom: 1px solid var(--color-border);}
.message { margin: 0 0 0.5rem; font-size: 0.8125rem; text-align: center; color: var(--color-text);}
.track { height: 4px; background-color: var(--color-border); border-radius: 2px; overflow: hidden;}
.progress { height: 100%; background-color: var(--color-success); border-radius: 2px; transition: width 0.3s ease;}
/* Qualified state */.qualified { display: flex; align-items: center; justify-content: center; gap: 0.5rem;}
.qualified .message { margin: 0; color: var(--color-success-dark, #065f46);}
.icon { font-size: 1.25rem;}Key Takeaways
- Engaging empty state: Use friendly messaging and clear CTAs
- Product suggestions: Show popular items to encourage shopping
- Cross-sells: Display complementary products to increase AOV
- Free shipping bar: Motivate customers to reach threshold
- Visual feedback: Show βAddedβ state for cross-sell items
- Loading states: Always show skeletons while fetching data
In the next lesson, weβll explore sharing logic between the cart page and cart drawer.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...