Shopping Cart UI in React: Slide-Over Drawer, Quantity, Promo Code
Build a full shopping cart UI in React - slide-over drawer, quantity stepper, promo code input, and animated item removal - with Tailwind CSS and Framer Motion.
What You're Actually Building
A cart UI is one of those components that looks simple on a Figma mockup and turns into a three-day rabbit hole the moment you start wiring it up. State has to live somewhere accessible from the entire tree. The drawer needs to animate open *and* closed without remounting. Quantity steppers have to prevent going below 1. And the promo code input? It has to give real feedback - not just sit there doing nothing when a user types a bad code.
This guide walks you through the whole thing from scratch: a slide-over drawer built with Framer Motion, a cart state with Zustand (64 bytes of setup, not 400 lines of Redux), quantity controls, animated line-item removal, and a promo code system with validation states. Every piece of code here ships directly into a Next.js 14 or Vite project with zero modifications.
Honestly, the most underrated part of cart UI is the *micro-interactions*. When someone removes an item, it should slide out. When quantity changes, the subtotal should update with a quick scale pulse. These 200ms details are what separate a cart that feels built from one that feels bought. We'll handle all of it.
Worth noting: if you want a head start before writing anything, Empire UI ships pre-built ecommerce components including drawer shells and animated card variants you can pull straight into your project.
Cart State with Zustand
Before touching a single JSX tag, you need your state sorted. Zustand is the right tool here - it's a 1KB store with no provider required, which means any component in your tree can call useCartStore() without prop drilling or context gymnastics. If you're still reaching for Redux Toolkit for a cart in 2026, ask yourself why.
// store/cart.ts
import { create } from 'zustand';
export interface CartItem {
id: string;
name: string;
price: number; // in cents
quantity: number;
image: string;
}
interface CartStore {
items: CartItem[];
isOpen: boolean;
promoCode: string | null;
discount: number; // 0-1 (e.g., 0.15 = 15% off)
openCart: () => void;
closeCart: () => void;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQty: (id: string, qty: number) => void;
applyPromo: (code: string) => 'ok' | 'invalid';
}
const PROMO_CODES: Record<string, number> = {
EMPIRE15: 0.15,
SAVE20: 0.20,
};
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
isOpen: false,
promoCode: null,
discount: 0,
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
addItem: (item) => set((s) => {
const exists = s.items.find((i) => i.id === item.id);
if (exists) {
return {
items: s.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...s.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
updateQty: (id, qty) => set((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, quantity: Math.max(1, qty) } : i)),
})),
applyPromo: (code) => {
const discount = PROMO_CODES[code.toUpperCase()];
if (!discount) return 'invalid';
set({ promoCode: code.toUpperCase(), discount });
return 'ok';
},
}));The Math.max(1, qty) call in updateQty is the simplest guard against someone hammering the minus button to 0 or typing a negative number into the input. You can debate whether 0 should remove the item automatically - In practice, keep them separate. Let the user remove explicitly so they don't accidentally nuke something trying to reduce quantity.
One more thing - prices are stored in cents (integers) to dodge floating-point rounding. Display them by dividing by 100. (item.price / 100).toFixed(2) is all you need.
The Slide-Over Drawer
The drawer is the centrepiece. It slides in from the right, dims the page behind it, and clicking the backdrop closes it. Framer Motion's AnimatePresence handles mounting and unmounting with exit animations - without it, the cart disappears the frame you close it instead of sliding away gracefully.
// components/CartDrawer.tsx
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { useCartStore } from '@/store/cart';
import { CartItem } from './CartItem';
import { PromoInput } from './PromoInput';
import { X } from 'lucide-react';
export function CartDrawer() {
const { isOpen, closeCart, items, discount } = useCartStore();
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const discounted = subtotal * (1 - discount);
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 bg-black/50 z-40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={closeCart}
/>
{/* Drawer panel */}
<motion.aside
className="fixed right-0 top-0 h-full w-full max-w-md bg-gray-950 z-50
flex flex-col shadow-2xl"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">
Cart ({items.length})
</h2>
<button
onClick={closeCart}
className="p-2 rounded-lg text-gray-400 hover:text-white
hover:bg-white/10 transition-colors"
aria-label="Close cart"
>
<X size={20} />
</button>
</div>
{/* Items list */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{items.length === 0 ? (
<p className="text-gray-400 text-sm text-center mt-16">
Your cart is empty.
</p>
) : (
<AnimatePresence initial={false}>
{items.map((item) => (
<CartItem key={item.id} item={item} />
))}
</AnimatePresence>
)}
</div>
{/* Footer */}
{items.length > 0 && (
<div className="border-t border-white/10 px-6 py-5 space-y-4">
<PromoInput />
<div className="flex justify-between text-sm text-gray-400">
<span>Subtotal</span>
<span>${(subtotal / 100).toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-sm text-emerald-400">
<span>Discount ({(discount * 100).toFixed(0)}% off)</span>
<span>-${((subtotal - discounted) / 100).toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-base font-semibold text-white">
<span>Total</span>
<span>${(discounted / 100).toFixed(2)}</span>
</div>
<button className="w-full bg-indigo-600 hover:bg-indigo-500 text-white
font-medium py-3 rounded-xl transition-colors">
Checkout
</button>
</div>
)}
</motion.aside>
</>
)}
</AnimatePresence>
);
}The spring config - damping: 30, stiffness: 300 - gives you a snappy 180ms open that doesn't bounce. If you want a bouncier feel (great for playful brands), drop damping to 20 and stiffness to 200. The max-w-md cap keeps it from becoming a full-screen takeover on wide monitors, which always looks wrong.
Quick aside: the drawer uses z-50 so it sits above your nav. If your navbar is already at z-50, bump the drawer to z-[60] and the backdrop to z-[59]. Don't fight the stacking context - declare it explicitly.
For style variations, you could skin this drawer with a glassmorphism surface instead of the solid bg-gray-950. Replace the background with bg-white/5 backdrop-blur-xl border-l border-white/10 and put a gradient canvas behind it. Looks phenomenal for fashion or tech brands.
CartItem with Quantity Controls and Animated Removal
Each line item needs three interactive elements: a minus button, a quantity display, and a plus button. The removal animation is what makes this feel alive - a height-collapse combined with an x-slide so the items below don't jump up abruptly.
// components/CartItem.tsx
'use client';
import { motion } from 'framer-motion';
import { Minus, Plus, Trash2 } from 'lucide-react';
import { useCartStore, CartItem as CartItemType } from '@/store/cart';
interface Props { item: CartItemType; }
export function CartItem({ item }: Props) {
const { removeItem, updateQty } = useCartStore();
return (
<motion.div
layout
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 40, height: 0, marginBottom: 0 }}
transition={{ duration: 0.22 }}
className="flex gap-4 overflow-hidden"
>
{/* Thumbnail */}
<img
src={item.image}
alt={item.name}
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{item.name}</p>
<p className="text-gray-400 text-sm">
${(item.price / 100).toFixed(2)}
</p>
<div className="flex items-center gap-2 mt-2">
{/* Minus */}
<button
onClick={() => updateQty(item.id, item.quantity - 1)}
className="w-7 h-7 rounded-md bg-white/10 flex items-center justify-center
text-white hover:bg-white/20 transition-colors disabled:opacity-40"
disabled={item.quantity <= 1}
aria-label="Decrease quantity"
>
<Minus size={14} />
</button>
<span className="w-6 text-center text-white text-sm tabular-nums">
{item.quantity}
</span>
{/* Plus */}
<button
onClick={() => updateQty(item.id, item.quantity + 1)}
className="w-7 h-7 rounded-md bg-white/10 flex items-center justify-center
text-white hover:bg-white/20 transition-colors"
aria-label="Increase quantity"
>
<Plus size={14} />
</button>
</div>
</div>
{/* Line total + remove */}
<div className="flex flex-col items-end justify-between">
<span className="text-white text-sm font-semibold">
${((item.price * item.quantity) / 100).toFixed(2)}
</span>
<button
onClick={() => removeItem(item.id)}
className="text-gray-500 hover:text-red-400 transition-colors"
aria-label={`Remove ${item.name}`}
>
<Trash2 size={16} />
</button>
</div>
</motion.div>
);
}The layout prop on the motion div tells Framer Motion to animate position changes when sibling items reorder after a removal. Without it, the remaining items snap to their new position instantly - it looks broken. One prop, massive improvement.
Look, the tabular-nums font feature is a small detail most devs skip. It stops the layout from shifting by a pixel or two as quantity changes from 9 to 10. Use it everywhere numbers change dynamically.
The disabled state on the minus button when quantity <= 1 prevents underflow without any extra logic in the store. Visual feedback is your first line of defence - don't make users find out their action did nothing by checking the subtotal.
Promo Code Input with Validation States
Promo code inputs are notorious for terrible UX. You've seen them: you type a code, hit Apply, nothing happens or it just says 'Invalid' with zero explanation. We can do better with three distinct states: idle, loading (fake 600ms delay for realism), success, and error.
// components/PromoInput.tsx
'use client';
import { useState } from 'react';
import { useCartStore } from '@/store/cart';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
type Status = 'idle' | 'loading' | 'ok' | 'invalid';
export function PromoInput() {
const applyPromo = useCartStore((s) => s.applyPromo);
const promoCode = useCartStore((s) => s.promoCode);
const [value, setValue] = useState(promoCode ?? '');
const [status, setStatus] = useState<Status>(promoCode ? 'ok' : 'idle');
async function handleApply() {
if (!value.trim()) return;
setStatus('loading');
// Simulate network round-trip
await new Promise((r) => setTimeout(r, 600));
const result = applyPromo(value);
setStatus(result);
}
const borderColor = {
idle: 'border-white/10 focus-within:border-indigo-500',
loading: 'border-white/10',
ok: 'border-emerald-500',
invalid: 'border-red-500',
}[status];
return (
<div className="space-y-1">
<div className={`flex rounded-xl border transition-colors ${borderColor} overflow-hidden`}>
<input
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value.toUpperCase());
if (status !== 'idle') setStatus('idle');
}}
placeholder="Promo code"
className="flex-1 bg-transparent px-4 py-2.5 text-sm text-white
placeholder-gray-500 outline-none"
disabled={status === 'ok'}
/>
<button
onClick={handleApply}
disabled={status === 'loading' || status === 'ok'}
className="px-4 text-sm font-medium text-white bg-white/10
hover:bg-white/20 disabled:opacity-50 transition-colors"
>
{status === 'loading' ? (
<Loader2 size={16} className="animate-spin" />
) : status === 'ok' ? (
<CheckCircle size={16} className="text-emerald-400" />
) : (
'Apply'
)}
</button>
</div>
{status === 'invalid' && (
<p className="flex items-center gap-1 text-xs text-red-400">
<XCircle size={12} /> That code isn't valid. Try EMPIRE15.
</p>
)}
{status === 'ok' && (
<p className="flex items-center gap-1 text-xs text-emerald-400">
<CheckCircle size={12} /> Promo applied!
</p>
)}
</div>
);
}The 600ms fake delay isn't just aesthetic. It trains users to wait for a response before assuming something broke, which means fewer rage-clicks. In a real app you'd replace the setTimeout with an actual API call to validate the code server-side - but the state machine stays identical.
Notice that onChange resets status to 'idle' the moment the user edits the field. That clears error and success states so they don't get confused stale feedback when they correct a typo. That one line fixes probably 40% of the frustration people feel with promo inputs.
That said, if you're building this inside a bigger design system and want matching input styles, pull the Empire UI form components directly - they already handle focus rings, error states, and accessible labels in a consistent token-based system so you're not reinventing inputs every project.
Wiring It All Together
With the store, drawer, item, and promo components done, wiring everything up takes about 20 lines. Mount the CartDrawer once at the layout level - not inside any page component - so it persists across navigation.
// app/layout.tsx (Next.js 14)
import { CartDrawer } from '@/components/CartDrawer';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-950 text-white">
{children}
<CartDrawer />
</body>
</html>
);
}Triggering the cart from a product page or navbar is one line:
``tsx
import { useCartStore } from '@/store/cart';
function AddToCartButton({ product }) {
const { addItem, openCart } = useCartStore();
return (
<button
onClick={() => {
addItem(product);
openCart();
}}
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 rounded-xl
text-white font-medium transition-colors"
>
Add to Cart
</button>
);
}
``
That's the whole integration. The product doesn't need to know anything about the drawer. The drawer doesn't know anything about products. Zustand keeps the two sides decoupled, which is the whole point.
If you want to persist cart state across page refreshes, add the Zustand persist middleware:
``tsx
import { persist } from 'zustand/middleware';
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({ /* same implementation */ }),
{ name: 'cart-storage' }
)
);
`
This writes the cart to localStorage automatically. The only gotcha is hydration mismatch in SSR - wrap any component that reads from the store in a <ClientOnly> guard or use Zustand's useStore hook with the skipHydration` option introduced in Zustand 4.4.
Styling Variations and Visual Themes
The default dark theme works for almost any product. But a cart drawer is a conversion-critical UI surface - it's worth spending an afternoon on the visual treatment. A few directions worth exploring depending on your brand.
For a premium SaaS or tech product, glass-skin the drawer. Swap bg-gray-950 for bg-black/40 backdrop-blur-2xl and add a subtle gradient border on the left edge: border-l border-t border-white/10. It pairs naturally with any of the glassmorphism components you might already be using for modals and cards. The depth effect makes the cart feel like it's floating over the page rather than covering it.
For a fashion or lifestyle brand, try a light theme with 16px rounded corners on everything and a warm off-white bg-[#fafaf8] background. Swap the Lucide icons for a thinner custom set. Look at the claymorphism style tokens on Empire UI - the soft shadows and pastel accents translate directly into a cart that feels approachable and tactile.
For something more aggressive - streetwear, gaming, energy - go neobrutalism. Hard black borders, high-contrast color blocks, no border-radius. The quantity stepper becomes two big flat buttons with thick borders. The promo input sits in a box with a 2px solid black outline and an offset drop shadow. It's polarising by design, which is the point.
One more thing - whatever visual direction you pick, use the gradient generator to dial in the background canvas and the box shadow generator for the drawer's drop shadow. Having the exact CSS values rather than eyeballing them saves a surprising amount of back-and-forth.
FAQ
Use Zustand's persist middleware - it automatically syncs your store to localStorage. Add it by wrapping your store definition: create(persist((set, get) => ({ ... }), { name: 'cart-storage' })). Watch for SSR hydration mismatches in Next.js by using the skipHydration option added in Zustand 4.4.
Wrap your list in Framer Motion's AnimatePresence and add layout plus an exit prop to each item. The exit: { opacity: 0, x: 40, height: 0 } combination collapses the item's space so sibling items slide up smoothly rather than snapping.
Always validate server-side before applying any real discount. The client-side check in this guide is fine for demos, but in production you'd hit an API endpoint with the code - the same state machine (idle/loading/ok/invalid) works identically either way.
Yes. Zustand doesn't require a provider, so you can drop useCartStore into any component tree without touching your existing context or Redux setup. Mount <CartDrawer /> once in your root layout and you're done.