Cart drawer React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
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.
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.
Cart drawer is a Pro React component in the React commerce components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add cart-drawer, together with Badge, Button, Coupon input, Drawer, Empty State and 12 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add cart-drawerCart drawer is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import CartDrawer from @/components/ui/cart-drawer.
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
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) => voidCalled when a removal is final: the undo window ended, the drawer closed, or at once with undoTimeout 0.
undoTimeoutdefault5000numberMilliseconds a removed line can be brought back. Hovering or focusing the undo row pauses the clock. 0 removes at once.
defaultOpendefaultfalsebooleanStarts open while uncontrolled.
openbooleanControlled open state; pair it with onOpenChange. Closing commits every pending removal.
onOpenChange(open: boolean) => voidCalled when the cart opens or closes.
triggerdefault"<CartButton count={itemCount} />"ReactNodeThe element that opens the cart, rendered with asChild. Defaults to a CartButton showing the number of units.
couponsAppliedCoupon[]The applied codes (controlled). Leave it out and the drawer keeps them itself.
onCouponsChange(coupons: AppliedCoupon[]) => voidCalled when a code is applied or removed.
couponPropsPartial<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.
showCoupondefaulttruebooleanShows the promo code link and field in the footer.
shippingdefault"null"number | nullThe delivery price before free-delivery rules. null prints labels.shippingLater ("Calculated at checkout"). The demo passes UPS Ground at $19.99.
freeShippingThresholddefault0numberSpend 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.
taxRatedefault0numberThe 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.
pricesIncludeTaxdefaulttruebooleanPrices already contain VAT (shops for people). Turn it off for B2B prices: VAT is added on top as its own row.
totalsCartTotalsThe 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.
checkoutHrefdefault""stringMakes the checkout button a link to this address instead of calling onCheckout.
sizedefault"md""sm" | "md" | "lg"The desktop panel width: 320 / 400 / 540px. sm also uses 64px photos. Phones always get the full-width sheet.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The checkout button's color. Brand orange is the usual choice for a shop's one hero action.
colordefault""stringAny CSS color for the checkout button; replaces the tone.
swipeToRemovedefaulttruebooleanOn touch screens a line swipes left to reveal Remove; a long swipe removes it straight away.
hapticsdefaulttruebooleanBuzzes 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.
shortcutdefault""stringA key combo in mod+k notation that opens and closes the cart from anywhere, e.g. "shift+c". Empty binds nothing.
currencydefault"USD"stringISO 4217 code for every amount. One currency per cart.
fractionDigitsdefault"auto""auto" | "0" | "2" | numberauto 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.
localedefault""stringFormats prices, counts and VAT rates. Empty uses the UIProvider locale, then the browser's. The demo runs in en-US.
noteReactNodeA muted line under the checkout button ("Secure payment · Free 30-day returns").
childrenReactNodeShown after the lines, e.g. a "Frequently bought together" rail.
emptyStateReactNodeReplaces the empty cart: by default an EmptyState with a bag, labels.emptyTitle and a "Continue shopping" button.
onContinueShopping() => voidThe empty state's button. Defaults to closing the drawer.
modaldefaulttruebooleanfalse: no dimmed overlay and no focus trap, so the page behind stays usable (the docs examples use it).
containerHTMLElement | nullPortal target. An element with contain: layout keeps the panel inside it (previews, embedded shops).
stepperPropsPartial<QuantityStepperProps>Passed to every line's QuantityStepper: labels, variant, holdToRepeat.
labelsPartial<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.
classNamesPartial<Record<"content" | "shipping" | "lines" | "line" | "summary" | "checkout" | "note" | "empty", string>>Extra classes for the inner parts.
classNamestringExtra classes for the drawer panel, merged last.
Parts
CartButton
componentThe 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
componentThe 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
componentOne 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
constantThe 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.
Dependencies
Files
- components/ui/cart-drawer.tsx
FAQ
Is the React Cart drawer component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Cart drawer?
Run npx wingo-ui@latest add cart-drawer. It also adds the Wingo UI items it builds on (Badge, Button, Coupon input, Drawer, Empty State and 12 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/cart-drawer.
How does Cart drawer behave on phones?
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. On a trackpad or mouse the × removes a line; on a phone swipe it left, or press the trash the stepper shows at 1.
See also
Used in
More React commerce components
Articles using this component
- Shadcn Ecommerce Template: Five Screens That Work on Phones
Build it: screens and apps11 min read
- React Checkout Page: Cart, Steps, Coupons and a Pay Bar
Build it: screens and apps12 min read