# Cart drawer

> A React shopping cart drawer: a side panel on desktop, a bottom sheet on phones, with undo, free delivery progress, a promo code and totals.

- Type: React component (Commerce)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add cart-drawer`
- Docs: https://wingo-ui.com/components/cart-drawer

## Interaction

Tap or click the bag (or press the shortcut) and the cart slides in from the right on a laptop, or rises as a bottom sheet with a handle on a phone; drag it down, tap outside or press Escape to close it. Each line has its photo and title (both links), the variant, the unit price with the old one struck through, a quantity stepper and the line total. + and - change the quantity at once while the cart saves it (the stepper spins, the totals roll to the new amount); if the shop refuses, the line goes back and says why under it. On a trackpad or mouse the × removes a line; on a phone swipe it left, or press the trash the stepper shows at 1. A removed line folds into a thin row that says what went and offers Undo, with a bar counting down five seconds; hovering or focusing the row stops the clock, Undo puts the line back with focus on its stepper, and closing the cart keeps every removal. The free delivery bar fills as the cart grows and a check pops when you get there. "Have a promo code?" opens the code field; the summary (subtotal, sale savings, codes, delivery, VAT) sits above the checkout button, which carries the total and spins while checkout starts. On a phone, and in a short panel on a laptop screen, the summary folds to the total with a Details toggle, so the lines keep the room. Keyboard: Tab goes close, then each line (title, stepper, remove), the code and the checkout button; arrows, Page Up / Down, Home and End drive a focused stepper, Delete on it at 1 removes the line. The bag's badge bounces and the bag wiggles when something is added. Removals, restores, errors and the new total are read out politely. Under reduced motion everything fades instead of moving.

## Installation

```bash
npx wingo-ui@latest add cart-drawer
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/cart-drawer` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/cart-drawer.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Coupon input](https://wingo-ui.com/components/coupon-input) ([Markdown](https://wingo-ui.com/components/coupon-input.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))
- [Price](https://wingo-ui.com/components/price) ([Markdown](https://wingo-ui.com/components/price.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Quantity stepper](https://wingo-ui.com/components/quantity-stepper) ([Markdown](https://wingo-ui.com/components/quantity-stepper.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { CartDrawer, type CartItem } from "@/components/ui/cart-drawer"

const START: CartItem[] = [
  { id: "case", title: "iPhone 14 case, silicone, Midnight", price: 79.9, quantity: 2, image: "/demo/phone.jpg", variant: "Midnight" },
  { id: "book", title: "Rocky Mountains trail guide (book)", price: 64.9, quantity: 1, taxRate: 0.11, image: "/demo/mountains.jpg" },
]

export function ShopCart() {
  const [items, setItems] = useState(START)
  return (
    <CartDrawer
      items={items}
      onQuantityChange={(id, quantity) =>
        setItems((prev) => prev.map((item) => (item.id === id ? { ...item, quantity } : item)))
      }
      onRemove={(id) => setItems((prev) => prev.filter((item) => item.id !== id))}
      shipping={19.99}
      freeShippingThreshold={250}
      taxRate={0.08}
      tone="brand"
      currency="USD"
      note="Secure payment · Free 30-day returns"
      onCheckout={() => new Promise((resolve) => setTimeout(resolve, 1400))}
      labels={{
        titleCount: (_, n) => `Your bag (${n})`,
        checkout: (total) => `Pay ${total}`,
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `CartItem[]` |  | The lines, owned by the caller: CartLineInput from lib/commerce (id, price, quantity, compareAtPrice, taxRate) plus title, href, image, variant, maxQuantity, seller, note and unavailable. A line with quantity 0 counts as removed; an unavailable one is dimmed and left out of the totals. |
| `onQuantityChange?` | `(id: string, quantity: number) => (void \| Promise<unknown>)` |  | Called when a stepper moves. The new quantity shows at once; a returned promise shows the stepper loading, and a rejection puts the old quantity back and prints its message under the line. |
| `onRemove?` | `(id: string) => void` |  | Called when a removal is final: the undo window ended, the drawer closed, or at once with undoTimeout 0. |
| `undoTimeout?` | `number` | `5000` | Milliseconds a removed line can be brought back. Hovering or focusing the undo row pauses the clock. 0 removes at once. |
| `defaultOpen?` | `boolean` | `false` | Starts open while uncontrolled. |
| `open?` | `boolean` |  | Controlled open state; pair it with onOpenChange. Closing commits every pending removal. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when the cart opens or closes. |
| `trigger?` | `ReactNode` | `"<CartButton count={itemCount} />"` | The element that opens the cart, rendered with asChild. Defaults to a CartButton showing the number of units. |
| `coupons?` | `AppliedCoupon[]` |  | The applied codes (controlled). Leave it out and the drawer keeps them itself. |
| `onCouponsChange?` | `(coupons: AppliedCoupon[]) => void` |  | Called when a code is applied or removed. |
| `couponProps?` | `Partial<CouponInputProps>` |  | Passed to the CouponInput in the footer: the coupons catalogue (whose codes are recomputed as the cart changes), onApply for a server check, labels, maxCoupons. |
| `showCoupon?` | `boolean` | `true` | Shows the promo code link and field in the footer. |
| `shipping?` | `number \| null` | `"null"` | The delivery price before free-delivery rules. null prints labels.shippingLater ("Calculated at checkout"). The demo passes UPS Ground at $19.99. |
| `freeShippingThreshold?` | `number` | `0` | Spend this much (items after codes) and delivery is free. Shows a bar with "Add $45.10 more for free delivery" over the lines, and a check when it is reached. 0 hides it. |
| `taxRate?` | `number` | `0` | The cart's tax rate as a fraction (0.08 for an 8% sales tax, 0.21 for Romanian VAT). Lines with their own taxRate (a book at 0.11) keep it. Shows "Incl. VAT 8%" under the total, or a tax row added to it with pricesIncludeTax off. 0 hides the tax row. |
| `pricesIncludeTax?` | `boolean` | `true` | Prices already contain VAT (shops for people). Turn it off for B2B prices: VAT is added on top as its own row. |
| `totals?` | `CartTotals` |  | The server's totals (from computeCartTotals or your API). They win over the local preview everywhere, so the numbers never disagree. |
| `onCheckout?` | `() => (void \| Promise<unknown>)` |  | The checkout button. A returned promise shows it loading until it settles. |
| `checkoutHref?` | `string` | `""` | Makes the checkout button a link to this address instead of calling onCheckout. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | The desktop panel width: 320 / 400 / 540px. sm also uses 64px photos. Phones always get the full-width sheet. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The checkout button's color. Brand orange is the usual choice for a shop's one hero action. |
| `color?` | `string` | `""` | Any CSS color for the checkout button; replaces the tone. |
| `swipeToRemove?` | `boolean` | `true` | On touch screens a line swipes left to reveal Remove; a long swipe removes it straight away. |
| `haptics?` | `boolean` | `true` | Buzzes on phones only: a tick per stepper step, a firm buzz when a swipe removes a line and a success pattern when free delivery is reached. |
| `shortcut?` | `string` | `""` | A key combo in mod+k notation that opens and closes the cart from anywhere, e.g. "shift+c". Empty binds nothing. |
| `currency?` | `string` | `"USD"` | ISO 4217 code for every amount. One currency per cart. |
| `fractionDigits?` | `"auto" \| "0" \| "2" \| number` | `"auto"` | auto prints whole amounts without ",00" until one amount in a column has cents; then every line (or every summary row and the checkout button) shows them, so "$1,199.00" sits above "$159.80". A number (or "0" / "2") forces that many. |
| `locale?` | `string` | `""` | Formats prices, counts and VAT rates. Empty uses the UIProvider locale, then the browser's. The demo runs in en-US. |
| `note?` | `ReactNode` |  | A muted line under the checkout button ("Secure payment · Free 30-day returns"). |
| `children?` | `ReactNode` |  | Shown after the lines, e.g. a "Frequently bought together" rail. |
| `emptyState?` | `ReactNode` |  | Replaces the empty cart: by default an EmptyState with a bag, labels.emptyTitle and a "Continue shopping" button. |
| `onContinueShopping?` | `() => void` |  | The empty state's button. Defaults to closing the drawer. |
| `modal?` | `boolean` | `true` | false: no dimmed overlay and no focus trap, so the page behind stays usable (the docs examples use it). |
| `container?` | `HTMLElement \| null` |  | Portal target. An element with contain: layout keeps the panel inside it (previews, embedded shops). |
| `stepperProps?` | `Partial<QuantityStepperProps>` |  | Passed to every line's QuantityStepper: labels, variant, holdToRepeat. |
| `labels?` | `Partial<CartDrawerLabels>` |  | Every string, English by default (DEFAULT_CART_DRAWER_LABELS): title "Cart", titleCount(n) "Cart (3)", subtotal, savings, coupon(code) "Code FALL15", shipping "Delivery", shippingFree "Free", shippingLater, tax(rate) "VAT 8%", taxIncluded(rate) "Incl. VAT 8%", total, checkout(total) "Checkout · $1,284.90", freeShippingRemaining(amount), freeShippingReached, removed(title), removedHint, restored(title), undo, remove, continue, emptyTitle, emptyDescription, unavailable, each, details, cartButton(n), quantity(title), maxQuantity(max), quantityError, seller(name), close. |
| `classNames?` | `Partial<Record<"content" \| "shipping" \| "lines" \| "line" \| "summary" \| "checkout" \| "note" \| "empty", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the drawer panel, merged last. |

## Parts

### CartButton (component)

The bag button with a count badge: a ghost icon Button whose badge bounces and whose bag wiggles when the count rises (never on mount). Props: count, max (99), size (icon-sm | icon | icon-lg), icon, label(count) for the accessible name, badgeTone, badgeColor, ringColor, locale, plus every Button prop. Props: CartButtonProps.

### CartSummary (component)

The totals block on its own (checkout pages): items or totals, coupons, shipping, freeShippingThreshold, taxRate, pricesIncludeTax, extraRows ({ label, amount }[] added to the total, a cash on delivery fee), collapsible (phones, and a short cart panel: the total with a Details toggle), expanded / defaultExpanded / onExpandedChange, announce (true), currency, fractionDigits (auto: cents on every row once one row has them), locale, size, labels, classNames. Props: CartSummaryProps.

### CartLineItem (component)

One line as an <li>: item, quantity (an optimistic one), onQuantityChange, onRemove, removing with onUndo / onUndoExpire / undoTimeout for the undo row, error, readOnly (a quantity instead of the stepper, for order reviews), swipeToRemove, size, haptics, currency, fractionDigits, locale, labels, stepperProps, classNames. Set --list-bg to the surface it sits on so a swipe paints the right background. Props: CartLineItemProps.

### DEFAULT_CART_DRAWER_LABELS (constant)

The English strings, to spread under a translation. Type: CartDrawerLabels.

## Examples

- **Empty cart**: Nothing in it yet: a bag, one line and a way back to the shop.
- **Brand checkout with a rail**: tone="brand", the trust note, and "Often bought together" pushing toward free delivery from $250.
- **B2B prices**: pricesIncludeTax={false}: net prices, sales tax added as its own row.
- **Narrow panel**: size="sm": a 320px panel with 64px photos.
- **Stock errors**: Raise the case above 3 and the shop refuses; a delisted stand is dimmed and left out of the total.
- **Summary on a checkout page**: CartSummary on its own, with a cash on delivery fee; on a phone it folds to the total with Details.
- **No undo**: undoTimeout={0}: remove means gone.

## See also

- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Quantity stepper](https://wingo-ui.com/components/quantity-stepper) ([Markdown](https://wingo-ui.com/components/quantity-stepper.md))
- [Coupon input](https://wingo-ui.com/components/coupon-input) ([Markdown](https://wingo-ui.com/components/coupon-input.md))
- [Price](https://wingo-ui.com/components/price) ([Markdown](https://wingo-ui.com/components/price.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))

---

Canonical: https://wingo-ui.com/components/cart-drawer
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
