DEV Community

Karthik Gs
Karthik Gs

Posted on

# How I Built a Production-Ready CartDrawer in React — With Accessibility, Animations, and Tests

A complete walkthrough of building the CartDrawer component inside Cartlify — a React e-commerce UI kit.


Why CartDrawer Is Harder Than It Looks

On the surface, a cart drawer sounds simple.

A panel slides in from the right. Shows some items. Has a checkout button.

But when you start building it properly — for a real client project — the requirements stack up fast:

  • Smooth slide-in animation without an external library
  • Backdrop overlay with click-to-close
  • Focus trap — keyboard stays inside the drawer when it's open
  • ESC key closes it
  • Scrollable item list with fixed header and footer
  • Quantity stepper with min/max controls
  • Empty state
  • Auto-calculated subtotal
  • WCAG 2.1 accessible throughout
  • Fully tested

Most developers spend 2–3 days getting all of this right. This article shows you exactly how I built it inside Cartlify — so you don't have to figure it out from scratch.


What We're Building

By the end of this article you'll have a fully functional CartDrawer component with:

✅ Slide-in animation from right
✅ Backdrop overlay with fade
✅ Focus trap (keyboard accessibility)
✅ ESC key to close
✅ Scrollable items with fixed header/footer
✅ Quantity stepper (min 1)
✅ Remove item button
✅ Empty cart state
✅ Auto subtotal calculation
✅ TypeScript strict types
✅ Jest + React Testing Library tests
Enter fullscreen mode Exit fullscreen mode

Tech Stack

React 18
TypeScript (strict mode)
Tailwind CSS v3
Jest + React Testing Library
Enter fullscreen mode Exit fullscreen mode

Step 1 — Define Your Types

Start with TypeScript interfaces. Clear types make everything else easier.

// src/types/index.ts

export interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  category?: string;
}

export interface CartItem extends Product {
  quantity: number;
}

export interface CartDrawerProps {
  isOpen: boolean;
  onClose: () => void;
  items: CartItem[];
  title?: string;
  currency?: string;
  emptyStateMessage?: string;
  onQuantityChange: (id: string, quantity: number) => void;
  onRemoveItem: (id: string) => void;
  onCheckout: () => void;
  onContinueShopping?: () => void;
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Build the useDisclosure Hook

Every open/close interaction in Cartlify uses a shared hook. Build this once, use it everywhere.

// src/hooks/useDisclosure.ts

import { useState, useCallback } from 'react';

interface UseDisclosureReturn {
  isOpen: boolean;
  onOpen: () => void;
  onClose: () => void;
  onToggle: () => void;
}

export const useDisclosure = (
  initialState = false
): UseDisclosureReturn => {
  const [isOpen, setIsOpen] = useState(initialState);

  const onOpen = useCallback(() => setIsOpen(true), []);
  const onClose = useCallback(() => setIsOpen(false), []);
  const onToggle = useCallback(
    () => setIsOpen((prev) => !prev), 
    []
  );

  return { isOpen, onOpen, onClose, onToggle };
};
Enter fullscreen mode Exit fullscreen mode

Usage:

const { isOpen, onOpen, onClose } = useDisclosure();

<button onClick={onOpen}>Open Cart</button>
<CartDrawer isOpen={isOpen} onClose={onClose} ... />
Enter fullscreen mode Exit fullscreen mode

Step 3 — Build the Focus Trap

This is the most important accessibility feature — and the most commonly skipped.

When the drawer is open, pressing Tab should cycle through elements inside the drawer only. Not the page behind it.

// src/hooks/useFocusTrap.ts

import { useEffect, useRef } from 'react';

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

export const useFocusTrap = (isActive: boolean) => {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableElements = Array.from(
      container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
    );

    if (focusableElements.length === 0) return;

    const firstElement = focusableElements[0];
    const lastElement = 
      focusableElements[focusableElements.length - 1];

    // Focus first element when drawer opens
    firstElement.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        // Shift+Tab — going backwards
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab — going forwards
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    };

    container.addEventListener('keydown', handleKeyDown);
    return () => {
      container.removeEventListener('keydown', handleKeyDown);
    };
  }, [isActive]);

  return containerRef;
};
Enter fullscreen mode Exit fullscreen mode

Step 4 — Build the CartItem Sub-component

Keep concerns separated. CartItem handles a single item row.

// src/components/CartDrawer/CartItem.tsx

import React from 'react';
import { CartItem as CartItemType } from '../../types';

interface CartItemProps {
  item: CartItemType;
  currency?: string;
  onQuantityChange: (id: string, quantity: number) => void;
  onRemove: (id: string) => void;
}

export const CartItem: React.FC<CartItemProps> = ({
  item,
  currency = '$',
  onQuantityChange,
  onRemove,
}) => {
  return (
    <div
      className="flex items-start gap-3 py-4 border-b 
                 border-gray-100 last:border-0"
      role="listitem"
    >
      {/* Product image */}
      <img
        src={item.image}
        alt={item.name}
        className="w-16 h-16 object-cover rounded-lg 
                   flex-shrink-0 bg-gray-50"
      />

      {/* Product details */}
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-gray-900 
                      truncate">
          {item.name}
        </p>
        <p className="text-sm font-semibold text-gray-900 
                      mt-1">
          {currency}{(item.price * item.quantity).toFixed(2)}
        </p>

        {/* Quantity stepper */}
        <div className="flex items-center gap-2 mt-2">
          <button
            onClick={() =>
              onQuantityChange(item.id, item.quantity - 1)
            }
            disabled={item.quantity <= 1}
            className="w-7 h-7 rounded-md border border-gray-200 
                       flex items-center justify-center text-gray-600
                       hover:bg-gray-50 disabled:opacity-40 
                       disabled:cursor-not-allowed transition-colors"
            aria-label={`Decrease quantity of ${item.name}`}
          ></button>

          <span
            className="text-sm font-medium text-gray-900 
                       w-6 text-center"
            aria-label={`Quantity: ${item.quantity}`}
          >
            {item.quantity}
          </span>

          <button
            onClick={() =>
              onQuantityChange(item.id, item.quantity + 1)
            }
            className="w-7 h-7 rounded-md border border-gray-200 
                       flex items-center justify-center text-gray-600
                       hover:bg-gray-50 transition-colors"
            aria-label={`Increase quantity of ${item.name}`}
          >
            +
          </button>
        </div>
      </div>

      {/* Remove button */}
      <button
        onClick={() => onRemove(item.id)}
        className="text-gray-400 hover:text-red-500 
                   transition-colors flex-shrink-0 mt-0.5"
        aria-label={`Remove ${item.name} from cart`}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          className="w-4 h-4"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth={2}
          aria-hidden="true"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            d="M19 7l-.867 12.142A2 2 0 0116.138 
               21H7.862a2 2 0 01-1.995-1.858L5 7m5 
               4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 
               1 0 00-1 1v3M4 7h16"
          />
        </svg>
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Step 5 — Build the CartDrawer Component

Now assemble everything together:

// src/components/CartDrawer/CartDrawer.tsx

import React, { useEffect, useCallback } from 'react';
import { CartDrawerProps } from '../../types';
import { CartItem } from './CartItem';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { cn } from '../../utils/cn';

export const CartDrawer: React.FC<CartDrawerProps> = ({
  isOpen,
  onClose,
  items,
  title = 'Your Cart',
  currency = '$',
  emptyStateMessage = 'Your cart is empty',
  onQuantityChange,
  onRemoveItem,
  onCheckout,
  onContinueShopping,
}) => {
  const drawerRef = useFocusTrap(isOpen);

  // Total item count
  const itemCount = items.reduce(
    (sum, item) => sum + item.quantity, 0
  );

  // Auto-calculated subtotal
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity, 0
  );

  // ESC key closes drawer
  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    },
    [onClose]
  );

  useEffect(() => {
    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
      // Prevent body scroll when drawer is open
      document.body.style.overflow = 'hidden';
    }
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.style.overflow = '';
    };
  }, [isOpen, handleKeyDown]);

  return (
    <>
      {/* Backdrop overlay */}
      <div
        className={cn(
          'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
          isOpen
            ? 'opacity-100 pointer-events-auto'
            : 'opacity-0 pointer-events-none'
        )}
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Drawer panel */}
      <div
        ref={drawerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="cart-drawer-title"
        className={cn(
          `fixed top-0 right-0 h-full w-full sm:w-[400px] 
           bg-white shadow-2xl z-50 flex flex-col
           transform transition-transform duration-300 ease-in-out`,
          isOpen ? 'translate-x-0' : 'translate-x-full'
        )}
      >
        {/* Fixed Header */}
        <div className="flex items-center justify-between 
                        px-5 py-4 border-b border-gray-100 
                        flex-shrink-0">
          <div>
            <h2
              id="cart-drawer-title"
              className="text-lg font-semibold text-gray-900"
            >
              {title}
            </h2>
            <p className="text-sm text-gray-500 mt-0.5">
              {itemCount} {itemCount === 1 ? 'item' : 'items'}
            </p>
          </div>

          <button
            onClick={onClose}
            className="p-2 rounded-lg hover:bg-gray-100 
                       transition-colors text-gray-500 
                       hover:text-gray-700"
            aria-label="Close cart"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              className="w-5 h-5"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              aria-hidden="true"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                d="M6 18L18 6M6 6l12 12"
              />
            </svg>
          </button>
        </div>

        {/* Scrollable item list */}
        <div
          className="flex-1 overflow-y-auto px-5"
          role="list"
          aria-label="Cart items"
        >
          {items.length === 0 ? (
            // Empty state
            <div className="flex flex-col items-center 
                            justify-center h-full py-16 
                            text-center">
              <div className="w-16 h-16 rounded-full 
                              bg-gray-100 flex items-center 
                              justify-center mb-4">
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  className="w-8 h-8 text-gray-400"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.5}
                  aria-hidden="true"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M2.25 3h1.386c.51 0 .955.343 
                       1.087.835l.383 1.437M7.5 14.25a3 
                       3 0 00-3 3h15.75m-12.75-3h11.218
                       c1.121-2.3 2.1-4.684 2.924-7.138
                       a60.114 60.114 0 00-16.536-1.84
                       M7.5 14.25L5.106 5.272M6 20.25a
                       .75.75 0 11-1.5 0 .75.75 0 011.5 
                       0zm12.75 0a.75.75 0 11-1.5 0 
                       .75.75 0 011.5 0z"
                  />
                </svg>
              </div>
              <p className="text-gray-900 font-medium mb-1">
                {emptyStateMessage}
              </p>
              <p className="text-sm text-gray-500">
                Add some items to get started
              </p>
            </div>
          ) : (
            items.map((item) => (
              <CartItem
                key={item.id}
                item={item}
                currency={currency}
                onQuantityChange={onQuantityChange}
                onRemove={onRemoveItem}
              />
            ))
          )}
        </div>

        {/* Fixed Footer */}
        {items.length > 0 && (
          <div className="px-5 py-4 border-t border-gray-100 
                          flex-shrink-0 space-y-3">
            {/* Subtotal */}
            <div className="flex items-center 
                            justify-between">
              <span className="text-sm text-gray-600">
                Subtotal
              </span>
              <span className="text-base font-semibold 
                               text-gray-900">
                {currency}{subtotal.toFixed(2)}
              </span>
            </div>

            {/* Checkout CTA */}
            <button
              onClick={onCheckout}
              className="w-full bg-gray-900 text-white 
                         py-3 px-4 rounded-xl font-medium 
                         text-sm hover:bg-gray-700 
                         active:scale-[0.98] transition-all"
            >
              Checkout — {currency}{subtotal.toFixed(2)}
            </button>

            {/* Continue shopping */}
            <button
              onClick={onContinueShopping ?? onClose}
              className="w-full text-sm text-gray-500 
                         hover:text-gray-700 transition-colors 
                         py-1"
            >
              Continue Shopping
            </button>
          </div>
        )}
      </div>
    </>
  );
};

export default CartDrawer;
Enter fullscreen mode Exit fullscreen mode

Step 6 — The cn Utility

Used throughout for conditional Tailwind classes:

// src/utils/cn.ts

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export const cn = (...inputs: ClassValue[]) => {
  return twMerge(clsx(inputs));
};
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install clsx tailwind-merge
Enter fullscreen mode Exit fullscreen mode

Step 7 — Write the Tests

141 tests ship with Cartlify. Here are the key CartDrawer tests:

// src/components/CartDrawer/CartDrawer.test.tsx

import React from 'react';
import {
  render,
  screen,
  fireEvent,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartDrawer } from './CartDrawer';
import { CartItem } from '../../types';

const mockItems: CartItem[] = [
  {
    id: '1',
    name: 'Studio Headphones',
    price: 79.99,
    image: '/headphones.jpg',
    quantity: 2,
  },
  {
    id: '2',
    name: 'Keyboard TKL',
    price: 119.0,
    image: '/keyboard.jpg',
    quantity: 1,
  },
];

const defaultProps = {
  isOpen: true,
  onClose: jest.fn(),
  items: mockItems,
  onQuantityChange: jest.fn(),
  onRemoveItem: jest.fn(),
  onCheckout: jest.fn(),
};

describe('CartDrawer', () => {
  beforeEach(() => jest.clearAllMocks());

  it('renders when open', () => {
    render(<CartDrawer {...defaultProps} />);
    expect(
      screen.getByRole('dialog')
    ).toBeInTheDocument();
  });

  it('shows item count correctly', () => {
    render(<CartDrawer {...defaultProps} />);
    // 2 + 1 = 3 items total
    expect(screen.getByText('3 items')).toBeInTheDocument();
  });

  it('calculates subtotal correctly', () => {
    render(<CartDrawer {...defaultProps} />);
    // (79.99 * 2) + (119.00 * 1) = 278.98
    expect(
      screen.getByText('$278.98')
    ).toBeInTheDocument();
  });

  it('shows empty state when no items', () => {
    render(<CartDrawer {...defaultProps} items={[]} />);
    expect(
      screen.getByText('Your cart is empty')
    ).toBeInTheDocument();
  });

  it('calls onClose when backdrop clicked', () => {
    render(<CartDrawer {...defaultProps} />);
    const backdrop = document.querySelector(
      '[aria-hidden="true"]'
    );
    fireEvent.click(backdrop!);
    expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
  });

  it('calls onClose when ESC key pressed', () => {
    render(<CartDrawer {...defaultProps} />);
    fireEvent.keyDown(document, { key: 'Escape' });
    expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
  });

  it('calls onRemoveItem when remove button clicked', () => {
    render(<CartDrawer {...defaultProps} />);
    const removeButtons = screen.getAllByLabelText(
      /remove/i
    );
    fireEvent.click(removeButtons[0]);
    expect(
      defaultProps.onRemoveItem
    ).toHaveBeenCalledWith('1');
  });

  it('disables minus button at quantity 1', () => {
    const singleItem = [{
      ...mockItems[1], quantity: 1
    }];
    render(
      <CartDrawer {...defaultProps} items={singleItem} />
    );
    const decreaseBtn = screen.getByLabelText(
      /decrease quantity/i
    );
    expect(decreaseBtn).toBeDisabled();
  });

  it('has correct aria attributes', () => {
    render(<CartDrawer {...defaultProps} />);
    const dialog = screen.getByRole('dialog');
    expect(dialog).toHaveAttribute('aria-modal', 'true');
    expect(dialog).toHaveAttribute('aria-labelledby');
  });

  it('hides checkout section when cart is empty', () => {
    render(<CartDrawer {...defaultProps} items={[]} />);
    expect(
      screen.queryByText(/checkout/i)
    ).not.toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the tests:

npm run test
Enter fullscreen mode Exit fullscreen mode

Step 8 — Storybook Stories

Document every state so buyers (and teammates) can explore without running the app:

// src/components/CartDrawer/CartDrawer.stories.tsx

import type { Meta, StoryObj } from '@storybook/react';
import { CartDrawer } from './CartDrawer';

const meta: Meta<typeof CartDrawer> = {
  title: 'Components/CartDrawer',
  component: CartDrawer,
  parameters: { layout: 'fullscreen' },
};

export default meta;
type Story = StoryObj<typeof CartDrawer>;

const sampleItems = [
  {
    id: '1',
    name: 'Studio Headphones',
    price: 79.99,
    image: 'https://...',
    quantity: 2,
  },
];

export const EmptyCart: Story = {
  args: {
    isOpen: true,
    items: [],
    onClose: () => {},
    onQuantityChange: () => {},
    onRemoveItem: () => {},
    onCheckout: () => {},
  },
};

export const WithItems: Story = {
  args: {
    isOpen: true,
    items: sampleItems,
    onClose: () => {},
    onQuantityChange: () => {},
    onRemoveItem: () => {},
    onCheckout: () => {},
  },
};
Enter fullscreen mode Exit fullscreen mode

The Part Most Tutorials Skip

Body scroll lock. When the drawer opens, the page behind it should not scroll.

useEffect(() => {
  if (isOpen) {
    document.body.style.overflow = 'hidden';
  }
  return () => {
    document.body.style.overflow = '';
  };
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

Cleanup on unmount. Always remove event listeners:

useEffect(() => {
  document.addEventListener('keydown', handleKeyDown);
  return () => {
    document.removeEventListener('keydown', handleKeyDown);
  };
}, [handleKeyDown]);
Enter fullscreen mode Exit fullscreen mode

Mobile width. On small screens the drawer should be full width:

w-full sm:w-[400px]
Enter fullscreen mode Exit fullscreen mode

These three details separate a production component from a demo.


What We Built

✅ CartDrawer with slide-in animation
✅ Backdrop overlay with click-to-close
✅ Focus trap for keyboard accessibility
✅ ESC key dismiss
✅ Scrollable items, fixed header + footer
✅ Quantity stepper (disabled at min 1)
✅ Remove item with aria-label
✅ Empty state
✅ Auto subtotal
✅ Body scroll lock
✅ 10 Jest tests covering all states
✅ Storybook stories for every variant
✅ TypeScript strict throughout
✅ WCAG 2.1 AA accessible
Enter fullscreen mode Exit fullscreen mode

Get the Full Source

If you want the complete Cartlify source — including ProductCard, CheckoutStepper, PageLoader, all utility hooks, 141 tests, design tokens, and Storybook docs:

🔗 Live Storybook democartlify.vercel.app

🛒 Get on Gumroad ($29 one-time)karthiksoftengg.gumroad.com/l/cartlify-react-ui-kit


Built by Karthik G S — Senior Frontend Engineer with 10+ years in React, TypeScript, and React Native. Currently building Cartlify and SaaScase as indie products.


Tags: #react #typescript #webdev #javascript #tutorial #a11y #tailwindcss #testing

Top comments (1)

Collapse
 
karthik_gs_d8f016796a117c profile image
Karthik Gs

Update: Cartlify is now on npm!
npm install cartlify
npmjs.com/package/cartlify