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
Tech Stack
React 18
TypeScript (strict mode)
Tailwind CSS v3
Jest + React Testing Library
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;
}
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 };
};
Usage:
const { isOpen, onOpen, onClose } = useDisclosure();
<button onClick={onOpen}>Open Cart</button>
<CartDrawer isOpen={isOpen} onClose={onClose} ... />
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;
};
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>
);
};
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;
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));
};
Install dependencies:
npm install clsx tailwind-merge
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();
});
});
Run the tests:
npm run test
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: () => {},
},
};
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]);
Cleanup on unmount. Always remove event listeners:
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
Mobile width. On small screens the drawer should be full width:
w-full sm:w-[400px]
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
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 demo → cartlify.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)
Update: Cartlify is now on npm!
npm install cartlify
npmjs.com/package/cartlify