Cart Components Intermediate 14 min read
Cart Drawer Component Architecture
Build a slide-out cart drawer with React. Learn how to structure the cart UI, handle animations, and integrate with your cart state management.
The cart drawer is a critical e-commerce component—it lets customers review their cart without leaving the current page. Let’s build a robust, accessible cart drawer that integrates with our Zustand cart store.
Cart Drawer Layout
┌────────────────────────────────────────────────────────────────────────────┐│ OVERLAY (semi-transparent backdrop) ││ ││ ┌────────────────────────────────────┐││ │ CART DRAWER (slides from right) │││ │ │││ │ ┌──────────────────────────────┐ │││ │ │ Header: "Your Cart (3)" X│ │││ │ └──────────────────────────────┘ │││ │ │││ │ ┌──────────────────────────────┐ │││ │ │ Cart Items (scrollable) │ │││ │ │ ┌────────────────────────┐ │ │││ │ │ │ Line Item 1 │ │ │││ │ │ └────────────────────────┘ │ │││ │ │ ┌────────────────────────┐ │ │││ │ │ │ Line Item 2 │ │ │││ │ │ └────────────────────────┘ │ │││ │ └──────────────────────────────┘ │││ │ │││ │ ┌──────────────────────────────┐ │││ │ │ Footer: Totals + Checkout │ │││ │ └──────────────────────────────┘ │││ └────────────────────────────────────┘│└────────────────────────────────────────────────────────────────────────────┘CartDrawer Component
import { useEffect, useRef } from 'react';import { createPortal } from 'react-dom'; // Render outside normal DOM hierarchy.import { useCart } from '@/stores/cart'; // Zustand cart store.import { useUI } from '@/stores/ui'; // Zustand UI store for drawer state.import { useBodyScrollLock } from '@/hooks/useBodyScrollLock'; // Prevent background scrolling.import { useFocusTrap } from '@/hooks/useFocusTrap'; // Keep focus inside drawer.import { CartHeader } from './CartHeader';import { CartItems } from './CartItems';import { CartFooter } from './CartFooter';import { CartEmpty } from './CartEmpty';import styles from './CartDrawer.module.css';
/** * CartDrawer is the main cart overlay component. * Features: * - Slides in from the right * - Traps focus for accessibility * - Locks body scroll when open * - Closes on Escape key or overlay click */export function CartDrawer() { const drawerRef = useRef<HTMLDivElement>(null);
// Get cart data from store. const { cart, isLoading } = useCart(); // Get drawer state and actions from UI store. const { isDrawerOpen, closeDrawer } = useUI((state) => ({ isDrawerOpen: state.activeDrawer === 'cart', closeDrawer: state.closeDrawer, }));
// Lock body scroll when drawer is open. useBodyScrollLock(isDrawerOpen);
// Trap focus inside the drawer for accessibility. useFocusTrap(drawerRef, isDrawerOpen);
// Handle Escape key to close drawer. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isDrawerOpen) { closeDrawer(); } };
document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isDrawerOpen, closeDrawer]);
// Don't render if drawer is closed. if (!isDrawerOpen) return null;
const hasItems = cart && cart.items.length > 0;
// Use portal to render at document body level. // This ensures proper stacking context and z-index handling. return createPortal( <div className={styles.overlay} onClick={closeDrawer}> <div ref={drawerRef} className={styles.drawer} role="dialog" aria-modal="true" aria-label="Shopping cart" onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside. > {/* Header with title and close button */} <CartHeader itemCount={cart?.itemCount ?? 0} onClose={closeDrawer} />
{/* Main content area */} <div className={styles.content}> {isLoading ? ( <CartSkeleton /> ) : hasItems ? ( <CartItems items={cart!.items} /> ) : ( <CartEmpty onClose={closeDrawer} /> )} </div>
{/* Footer with totals and checkout button - only show if items exist */} {hasItems && <CartFooter cart={cart!} />} </div> </div>, document.body );}
/** * Loading skeleton while cart data loads. */function CartSkeleton() { return ( <div className={styles.skeleton}> {/* Render 3 placeholder items */} {[1, 2, 3].map((i) => ( <div key={i} className={styles.skeletonItem}> <div className={styles.skeletonImage} /> <div className={styles.skeletonInfo}> <div className={styles.skeletonTitle} /> <div className={styles.skeletonPrice} /> </div> </div> ))} </div> );}CartDrawer Styles
/* Overlay covers entire screen with semi-transparent background. */.overlay { position: fixed; inset: 0; z-index: var(--z-drawer-overlay, 100); background-color: rgba(0, 0, 0, 0.5); animation: fadeIn 0.2s ease-out;}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; }}
/* Drawer panel slides in from right. */.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer, 101); display: flex; flex-direction: column; width: 100%; max-width: 420px; background-color: var(--color-background); box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); animation: slideIn 0.3s ease-out;}
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); }}
/* Scrollable content area between header and footer. */.content { flex: 1; overflow-y: auto; padding: 1rem; /* Custom scrollbar styling. */ scrollbar-width: thin; scrollbar-color: var(--color-border) transparent;}
.content::-webkit-scrollbar { width: 6px;}
.content::-webkit-scrollbar-thumb { background-color: var(--color-border); border-radius: 3px;}
/* Loading skeleton styles. */.skeleton { display: flex; flex-direction: column; gap: 1rem;}
.skeletonItem { display: flex; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid var(--color-border);}
.skeletonImage { width: 80px; height: 80px; 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);}
.skeletonInfo { flex: 1; display: flex; flex-direction: column; gap: 0.5rem;}
.skeletonTitle { height: 1rem; width: 70%; 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; }}
/* Mobile: full width drawer */@media (max-width: 480px) { .drawer { max-width: 100%; }}CartHeader Component
import styles from './CartHeader.module.css';
interface CartHeaderProps { itemCount: number; // Number of items in cart. onClose: () => void; // Close button callback.}
/** * CartHeader displays the drawer title and close button. * Uses proper accessibility attributes. */export function CartHeader({ itemCount, onClose }: CartHeaderProps) { return ( <header className={styles.header}> <h2 className={styles.title}> Your Cart {/* Item count badge */} {itemCount > 0 && ( <span className={styles.count}>({itemCount})</span> )} </h2>
<button type="button" className={styles.closeButton} onClick={onClose} aria-label="Close cart" > <CloseIcon /> </button> </header> );}
/** * Close icon (X) SVG component. */function CloseIcon() { return ( <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> );}.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--color-border); /* Sticky header stays at top when scrolling. */ position: sticky; top: 0; background-color: var(--color-background); z-index: 1;}
.title { margin: 0; font-size: 1.125rem; font-weight: 600;}
.count { font-weight: 400; color: var(--color-text-muted); margin-left: 0.25rem;}
.closeButton { display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; padding: 0; border: none; background: transparent; color: var(--color-text); cursor: pointer; border-radius: var(--radius-full); transition: background-color 0.15s ease;}
.closeButton:hover { background-color: var(--color-surface);}
.closeButton:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px;}CartEmpty Component
import { Button } from '@/components/ui';import styles from './CartEmpty.module.css';
interface CartEmptyProps { onClose: () => void; // Close drawer and return to shopping.}
/** * CartEmpty displays when the cart has no items. * Encourages the user to continue shopping. */export function CartEmpty({ onClose }: CartEmptyProps) { return ( <div className={styles.empty}> {/* Empty cart illustration */} <div className={styles.illustration}> <CartIcon /> </div>
<h3 className={styles.title}>Your cart is empty</h3> <p className={styles.message}> Looks like you haven't added anything yet. </p>
<Button variant="primary" onClick={onClose}> Continue Shopping </Button> </div> );}
/** * Empty cart illustration SVG. */function CartIcon() { return ( <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" > <circle cx="9" cy="21" r="1" /> <circle cx="20" cy="21" r="1" /> <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" /> </svg> );}.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 3rem 1.5rem; min-height: 300px;}
.illustration { color: var(--color-text-muted); margin-bottom: 1.5rem;}
.title { margin: 0 0 0.5rem; font-size: 1.25rem; font-weight: 600;}
.message { margin: 0 0 1.5rem; color: var(--color-text-muted);}Integrating with UI Store
// src/stores/ui.ts - Cart drawer state managementimport { create } from 'zustand';
type DrawerType = 'cart' | 'menu' | 'filter' | null;
interface UIState { activeDrawer: DrawerType;
// Actions openDrawer: (drawer: DrawerType) => void; closeDrawer: () => void; toggleDrawer: (drawer: DrawerType) => void;}
export const useUI = create<UIState>((set, get) => ({ activeDrawer: null,
// Open a specific drawer, closing any other open drawer. openDrawer: (drawer) => set({ activeDrawer: drawer }),
// Close the currently open drawer. closeDrawer: () => set({ activeDrawer: null }),
// Toggle a drawer open/closed. toggleDrawer: (drawer) => { const current = get().activeDrawer; set({ activeDrawer: current === drawer ? null : drawer }); },}));
// Convenience hook for cart drawer specifically.export function useCartDrawer() { return useUI((state) => ({ isOpen: state.activeDrawer === 'cart', open: () => state.openDrawer('cart'), close: state.closeDrawer, toggle: () => state.toggleDrawer('cart'), }));}Liquid Mount Point
{% comment %} snippets/cart-drawer.liquid {% endcomment %}{% comment %} This snippet provides the mount point for the React cart drawer. Include it in theme.liquid before the closing </body> tag.{% endcomment %}
<div id="cart-drawer-root"></div>
{% comment %} Initial cart data for React to read on mount. This ensures the cart displays immediately without waiting for an API call.{% endcomment %}<script type="application/json" id="cart-data"> { "token": {{ cart.token | json }}, "itemCount": {{ cart.item_count }}, "totalPrice": {{ cart.total_price }}, "totalDiscount": {{ cart.total_discount }}, "currency": {{ cart.currency.iso_code | json }}, "items": [ {%- for item in cart.items -%} { "key": {{ item.key | json }}, "productId": {{ item.product_id }}, "variantId": {{ item.variant_id }}, "title": {{ item.product.title | json }}, "variantTitle": {{ item.variant.title | json }}, "quantity": {{ item.quantity }}, "price": {{ item.price }}, "linePrice": {{ item.final_line_price }}, "discountedPrice": {{ item.final_price }}, "image": {{ item.image | image_url: width: 200 | json }}, "url": {{ item.url | json }}, "handle": {{ item.product.handle | json }}, "available": {{ item.variant.available }}, "properties": {{ item.properties | json }} }{% unless forloop.last %},{% endunless %} {%- endfor -%} ] }</script>Mounting the Cart Drawer
import { createRoot } from 'react-dom/client';import { CartDrawer } from '@/components/cart/CartDrawer';import { initializeCart } from '@/stores/cart';
/** * Initialize and mount the cart drawer. * Called from the main entry point. */export function mountCartDrawer() { const root = document.getElementById('cart-drawer-root'); if (!root) return;
// Initialize cart state from Liquid-provided JSON. initializeCart();
// Mount React component. createRoot(root).render(<CartDrawer />);}Key Takeaways
- Portal rendering: Use
createPortalto render the drawer at body level, avoiding z-index issues - Focus trap: Trap focus inside the drawer for keyboard accessibility
- Body scroll lock: Prevent background scrolling when drawer is open
- Escape key: Always support closing with Escape key
- Loading states: Show skeleton while data loads for better UX
- Empty state: Provide clear messaging and action when cart is empty
- Animations: Use CSS animations for smooth slide-in effect
In the next lesson, we’ll build the individual cart line item components.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...