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

src/components/cart/CartDrawer/CartDrawer.tsx
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

src/components/cart/CartDrawer/CartDrawer.module.css
/* 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

src/components/cart/CartDrawer/CartHeader.tsx
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>
);
}
src/components/cart/CartDrawer/CartHeader.module.css
.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

src/components/cart/CartDrawer/CartEmpty.tsx
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>
);
}
src/components/cart/CartDrawer/CartEmpty.module.css
.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 management
import { 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

src/entries/cart.tsx
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

  1. Portal rendering: Use createPortal to render the drawer at body level, avoiding z-index issues
  2. Focus trap: Trap focus inside the drawer for keyboard accessibility
  3. Body scroll lock: Prevent background scrolling when drawer is open
  4. Escape key: Always support closing with Escape key
  5. Loading states: Show skeleton while data loads for better UX
  6. Empty state: Provide clear messaging and action when cart is empty
  7. 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...