Cart Drawer with Quantity Steppers

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.

Code

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

When to use it

How to customize it

Accessibility

Questions

How do the quantity steppers work?

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.

Should a cart be a drawer or a separate page?

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.

How do I connect it to my real cart?

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.

How do I close the drawer with the Escape key?

Add a `keydown` listener in a `useEffect` that calls `setOpen(false)` when the key is Escape, and remove the listener when the drawer closes.

Why can the quantity not go below one?

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.

More in Ecommerce

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.

Product

Who It's For

Features

Resources

Company

View Sitemap