A slide-over shopping cart shown open over a dimmed shop page. Each line has a thumbnail, the name and variant, a line total, a plus and minus stepper and a remove link. A bar at the top counts down to free shipping, and the footer holds the subtotal and the checkout button. Every number is worked out from the items, so changing a quantity updates the line, the count in the header, the subtotal and the shipping bar together. On phones the drawer takes the full width.
import { useState } from "react";
const FREE_SHIPPING_AT = 75;
const startingItems = [
{ id: "mint", name: "Garden Mint Loose Leaf", variant: "100 g tin", price: 14, qty: 2, shape: "tin", tint: "bg-emerald-100 text-emerald-700" },
{ id: "oolong", name: "Smoked Oolong", variant: "50 g tin", price: 18.5, qty: 1, shape: "tin", tint: "bg-amber-100 text-amber-700" },
{ id: "mug", name: "Stoneware Infuser Mug", variant: "Moss green", price: 26, qty: 1, shape: "mug", tint: "bg-stone-200 text-emerald-800" },
];
const money = (n: number) => `$${n.toFixed(2)}`;
export default function CartDrawerWithQuantitySteppers() {
const [open, setOpen] = useState(true);
const [items, setItems] = useState(startingItems);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const subtotal = items.reduce((sum, i) => sum + i.qty * i.price, 0);
const remaining = Math.max(0, FREE_SHIPPING_AT - subtotal);
const percent = Math.min(100, Math.round((subtotal / FREE_SHIPPING_AT) * 100));
const changeQty = (id: string, by: number) =>
setItems((list) => list.map((i) => (i.id === id ? { ...i, qty: Math.max(1, i.qty + by) } : i)));
const remove = (id: string) => setItems((list) => list.filter((i) => i.id !== id));
const focus = "focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2";
return (
<div className="relative h-[780px] overflow-hidden bg-stone-100 text-stone-900">
{/* The shop page behind the drawer */}
<div className="flex h-16 items-center justify-between border-b border-stone-200 bg-white px-4 sm:px-8">
<span className="text-base font-semibold tracking-tight">Fernhill Tea</span>
<button type="button" aria-expanded={open} aria-controls="cart-drawer" onClick={() => setOpen(true)} className={`flex h-10 items-center gap-2 rounded-full border border-stone-300 bg-white px-4 text-sm font-medium hover:bg-stone-50 ${focus}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="M4 7h12l-1 10H5L4 7ZM7 7V6a3 3 0 0 1 6 0v1" /></svg>
Cart <span className="tabular-nums">({count})</span>
</button>
</div>
<div aria-hidden="true" className="mx-auto grid max-w-5xl grid-cols-2 gap-5 p-4 sm:p-8 md:grid-cols-3">
{["bg-emerald-200", "bg-amber-200", "bg-stone-300", "bg-rose-200", "bg-lime-200", "bg-orange-200"].map((c) => (
<div key={c}>
<div className={`h-44 rounded-xl ${c}`} />
<div className="mt-3 h-3 w-2/3 rounded-full bg-stone-300" />
<div className="mt-2 h-3 w-1/4 rounded-full bg-stone-200" />
</div>
))}
</div>
{open && (
<>
<button type="button" aria-label="Close cart" tabIndex={-1} onClick={() => setOpen(false)} className="absolute inset-0 cursor-default bg-stone-900/50" />
<div id="cart-drawer" role="dialog" aria-modal="true" aria-labelledby="cart-title" className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-2xl">
<div className="flex flex-none items-center justify-between px-5 pt-5 sm:px-6">
<h2 id="cart-title" className="text-lg font-semibold tracking-tight">
Your cart <span className="font-normal text-stone-500">({count})</span>
</h2>
<button type="button" aria-label="Close cart" onClick={() => setOpen(false)} className={`-mr-2 flex h-10 w-10 items-center justify-center rounded-full text-stone-600 hover:bg-stone-100 ${focus}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="h-5 w-5"><path d="m5 5 10 10M15 5 5 15" /></svg>
</button>
</div>
{items.length > 0 && (
<div className="flex-none px-5 pt-4 sm:px-6">
<p className="text-sm text-stone-700" aria-live="polite">
{remaining > 0 ? (
<>You are <span className="font-semibold text-stone-900">{money(remaining)}</span> away from free shipping.</>
) : (
<span className="font-semibold text-emerald-800">Your order ships free.</span>
)}
</p>
<div role="progressbar" aria-label="Progress to free shipping" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="mt-2 h-1.5 overflow-hidden rounded-full bg-stone-200">
<div className="h-full rounded-full bg-emerald-600 transition-all duration-300" style={{ width: `${percent}%` }} />
</div>
</div>
)}
{items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<p className="text-base font-semibold">Your cart is empty</p>
<p className="mt-1 text-sm text-stone-600">Teas you add will show up here.</p>
<button type="button" onClick={() => setItems(startingItems)} className={`mt-5 h-11 rounded-full border border-stone-300 px-5 text-sm font-semibold hover:bg-stone-50 ${focus}`}>Put the items back</button>
</div>
) : (
<ul className="mt-5 flex-1 divide-y divide-stone-200 overflow-y-auto border-t border-stone-200 px-5 sm:px-6">
{items.map((item) => (
<li key={item.id} className="flex gap-4 py-5">
<div className={`flex h-20 w-20 flex-none items-center justify-center rounded-xl ${item.tint}`}>
<svg viewBox="0 0 48 48" fill="currentColor" aria-hidden="true" className="h-12 w-12">
{item.shape === "tin" ? (
<>
<rect x="11" y="6" width="26" height="6" rx="2" />
<rect x="13" y="13" width="22" height="29" rx="3" opacity=".75" />
<rect x="17" y="21" width="14" height="11" rx="1.5" fill="white" opacity=".9" />
</>
) : (
<>
<rect x="9" y="12" width="24" height="28" rx="5" />
<path d="M33 18h3a7 7 0 0 1 0 14h-3" fill="none" stroke="currentColor" strokeWidth="3.5" />
</>
)}
</svg>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold leading-snug">{item.name}</h3>
<p className="mt-0.5 text-sm text-stone-600">{item.variant}</p>
</div>
<p className="flex-none text-sm font-semibold tabular-nums">{money(item.price * item.qty)}</p>
</div>
<div className="mt-3 flex items-center justify-between">
<div role="group" aria-label={`Quantity of ${item.name}`} className="inline-flex items-center rounded-full border border-stone-300">
<button type="button" aria-label="Decrease quantity" disabled={item.qty === 1} onClick={() => changeQty(item.id, -1)} className={`flex h-10 w-10 items-center justify-center rounded-full text-stone-700 hover:bg-stone-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent ${focus}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="M5 10h10" /></svg>
</button>
<span aria-live="polite" className="w-7 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
<button type="button" aria-label="Increase quantity" onClick={() => changeQty(item.id, 1)} className={`flex h-10 w-10 items-center justify-center rounded-full text-stone-700 hover:bg-stone-100 ${focus}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="M10 5v10M5 10h10" /></svg>
</button>
</div>
<button type="button" onClick={() => remove(item.id)} aria-label={`Remove ${item.name}`} className={`-mr-2 h-10 rounded-full px-2 text-sm font-medium text-stone-600 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 ${focus}`}>
Remove
</button>
</div>
</div>
</li>
))}
</ul>
)}
{items.length > 0 && (
<div className="flex-none border-t border-stone-200 bg-stone-50 px-5 py-5 sm:px-6">
<div className="flex items-baseline justify-between">
<span className="text-sm font-medium text-stone-700">Subtotal</span>
<span className="text-xl font-semibold tabular-nums tracking-tight">{money(subtotal)}</span>
</div>
<p className="mt-1 text-xs text-stone-600">Shipping and taxes are worked out at checkout.</p>
<a href="#" className={`mt-4 flex h-12 items-center justify-center rounded-full bg-emerald-800 text-sm font-semibold text-white transition hover:bg-emerald-700 ${focus}`}>
Checkout
</a>
<button type="button" onClick={() => setOpen(false)} className={`mt-2 h-10 w-full rounded-full text-sm font-medium text-stone-700 hover:text-stone-900 ${focus}`}>
Keep shopping
</button>
</div>
)}
</div>
</>
)}
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/cart-drawer-with-quantity-steppers.json
The items live in `useState`. `changeQty` returns a new list with one item's quantity changed, never below one, and the totals are calculated from that list on every render, so nothing can fall out of step.
A drawer suits shops where people add several things in one visit, because they stay on the page they were browsing. A full cart page gives more room for discount codes, delivery estimates and upsells. Many shops use both: a drawer first, with the cart page behind a link.
Replace `startingItems` with your cart data and call your cart API inside `changeQty` and `remove`. Keep updating the local state straight away so the drawer feels instant, then correct it if the request fails.
Add a `keydown` listener in a `useEffect` that calls `setOpen(false)` when the key is Escape, and remove the listener when the drawer closes.
Removing an item by tapping minus one time too many is a common mistake on phones. Keeping removal on its own link makes it deliberate and easy to spot.
Related: Product Grid with Filter Chips, App Shell with Sidebar and Top Bar, Button Set: Primary, Secondary, Ghost, Icon and Loading, Pricing Table with Monthly and Yearly Toggle.
More ecommerce or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.