ESC

Type to search the knowledge base.

Shopping Cart Drawer

Machine-coding brief for a cart drawer — line items, qty updates, totals, focus trap, and accessible dialog pattern.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • state
  • a11y

Problem statement

Build a shopping cart drawer: slide-over panel listing line items with quantity controls, remove, subtotal, and checkout CTA. Interviewers score cart state (merge by product id), derived totals, and drawer a11y (focus, escape, scroll lock) similar to a modal.

Requirements

Must have

  • Open/close cart drawer
  • List items: title, price, quantity, line total
  • Increment / decrement quantity (min 1 or remove at 0 — clarify)
  • Remove item
  • Subtotal = sum(price * qty)
  • Empty cart state
  • Escape closes; focus restore; basic focus trap

Should have

  • Badge count on cart trigger
  • Persist cart to localStorage
  • Currency formatting via Intl.NumberFormat

Nice to have

  • Promo code field
  • Free-shipping progress bar
  • Optimistic stock check

Planning (5 minutes out loud)

  1. Cart line model — productId, title, unitPrice, qty
  2. addItem merges qty for same productId
  3. Drawer = dialog pattern
  4. MVP — state + list + totals + open/close; then a11y + persist
  5. Money as integer cents if you want to avoid float bugs

Architecture

CartProvider (optional)
├── CartTrigger (badge)
└── CartDrawer
    ├── LineItem[]
    ├── Subtotal
    └── CheckoutButton

Types

type CartItem = {
  productId: string;
  title: string;
  unitPrice: number; // cents
  qty: number;
};

type CartState = {
  items: CartItem[];
};

type CartAction =
  | { type: "ADD"; item: Omit<CartItem, "qty"> & { qty?: number } }
  | { type: "SET_QTY"; productId: string; qty: number }
  | { type: "REMOVE"; productId: string }
  | { type: "CLEAR" };

Implementation sketch

Reducer

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD": {
      const qty = action.item.qty ?? 1;
      const existing = state.items.find((i) => i.productId === action.item.productId);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.productId === action.item.productId
              ? { ...i, qty: i.qty + qty }
              : i
          ),
        };
      }
      return {
        items: [
          ...state.items,
          {
            productId: action.item.productId,
            title: action.item.title,
            unitPrice: action.item.unitPrice,
            qty,
          },
        ],
      };
    }
    case "SET_QTY": {
      if (action.qty <= 0) {
        return {
          items: state.items.filter((i) => i.productId !== action.productId),
        };
      }
      return {
        items: state.items.map((i) =>
          i.productId === action.productId ? { ...i, qty: action.qty } : i
        ),
      };
    }
    case "REMOVE":
      return { items: state.items.filter((i) => i.productId !== action.productId) };
    case "CLEAR":
      return { items: [] };
    default:
      return state;
  }
}

function subtotalCents(items: CartItem[]) {
  return items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
}

function formatMoney(cents: number, currency = "USD") {
  return new Intl.NumberFormat(undefined, {
    style: "currency",
    currency,
  }).format(cents / 100);
}

Drawer shell

function CartDrawer({
  open,
  onClose,
  items,
  dispatch,
}: {
  open: boolean;
  onClose: () => void;
  items: CartItem[];
  dispatch: React.Dispatch<CartAction>;
}) {
  const panelRef = useRef<HTMLDivElement>(null);
  const titleId = useId();

  useEffect(() => {
    if (!open) return;
    const prev = document.activeElement as HTMLElement | null;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    panelRef.current?.querySelector<HTMLElement>("button, [href], input")?.focus();

    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") onClose();
    }
    document.addEventListener("keydown", onKey);
    return () => {
      document.body.style.overflow = prevOverflow;
      document.removeEventListener("keydown", onKey);
      prev?.focus?.();
    };
  }, [open, onClose]);

  if (!open) return null;

  const total = subtotalCents(items);

  return (
    <div className="cart-root">
      <div className="backdrop" onClick={onClose} />
      <div
        ref={panelRef}
        className="drawer"
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
      >
        <header>
          <h2 id={titleId}>Your cart</h2>
          <button type="button" aria-label="Close cart" onClick={onClose}>
            ×
          </button>
        </header>
        {items.length === 0 ? (
          <p>Your cart is empty.</p>
        ) : (
          <ul>
            {items.map((item) => (
              <li key={item.productId}>
                <div>
                  <p>{item.title}</p>
                  <p>{formatMoney(item.unitPrice)}</p>
                </div>
                <div>
                  <button
                    type="button"
                    aria-label={`Decrease quantity of ${item.title}`}
                    onClick={() =>
                      dispatch({
                        type: "SET_QTY",
                        productId: item.productId,
                        qty: item.qty - 1,
                      })
                    }
                  >
                    −
                  </button>
                  <span aria-live="polite">{item.qty}</span>
                  <button
                    type="button"
                    aria-label={`Increase quantity of ${item.title}`}
                    onClick={() =>
                      dispatch({
                        type: "SET_QTY",
                        productId: item.productId,
                        qty: item.qty + 1,
                      })
                    }
                  >
                    +
                  </button>
                  <button
                    type="button"
                    aria-label={`Remove ${item.title}`}
                    onClick={() =>
                      dispatch({ type: "REMOVE", productId: item.productId })
                    }
                  >
                    Remove
                  </button>
                </div>
                <p>{formatMoney(item.unitPrice * item.qty)}</p>
              </li>
            ))}
          </ul>
        )}
        <footer>
          <p>Subtotal {formatMoney(total)}</p>
          <button type="button" disabled={items.length === 0}>
            Checkout
          </button>
        </footer>
      </div>
    </div>
  );
}

Badge

const count = items.reduce((n, i) => n + i.qty, 0);
// trigger: aria-label={`Cart, ${count} items`}

Accessibility essentials

  • Dialog labeling + modal semantics
  • Escape + focus return + scroll lock (reuse modal skills)
  • Qty buttons named with product title
  • Live region for qty optional; don’t over-announce

Performance notes

  • Cart is small; reducer is enough
  • Persist with useEffect debounce write to localStorage
  • Don’t put entire product catalog into cart state — only line fields

Footguns

  1. Float money (0.1 + 0.2) — use cents
  2. Duplicate lines for same product
  3. Focus not restored when closing
  4. Checkout enabled on empty cart
  5. Stale prices if product price changes server-side — revalidate at checkout

Interview out-loud answer

Cart state is a list of lines keyed by productId with merge-on-add. Totals are derived from cents. The drawer is an accessible dialog: focus, escape, scroll lock. MVP is add/update/remove + subtotal; persistence and promo codes later. I’d keep price snapshots on the line and revalidate stock at checkout.

Further reading