Guides · September 1, 2023
Cart Drawers & Mini-Carts with shadcn + Tailwind
Building a shadcn cart component means composing Sheet or Popover primitives with your own cart state, optimistic quantity updates, and careful focus management. This tutorial walks through both patterns end to end.
By Polo Themes
A shadcn cart component is not a single component at all — it is a composition. You take the Sheet primitive (for a full slide-in cart drawer) or Popover (for a lightweight mini-cart dropdown), wire it to your own cart state, and layer in line-item rows, quantity steppers, and a checkout call-to-action yourself. shadcn/ui does not ship a cart out of the box, which is exactly why so many teams get the details wrong: focus traps that do not close on route change, quantity buttons that fire five requests in a row, and empty states that look like a bug. This tutorial builds both patterns properly, from the primitive up.
If you are building on Next.js and Tailwind and have not touched shadcn/ui before: it is not a component library you install as a dependency. The CLI copies the source of each component (Sheet, Popover, Dialog, Button, ScrollArea) directly into your repo, typically under a components/ui directory. That matters for a cart drawer specifically, because you will be editing the copied Sheet source to change how it animates, how wide it opens, and how it handles nested scroll — none of which is exposed as a prop on a typical installed package.
Cart Drawer vs. Mini-Cart: Pick the Right Primitive First
These are two different UI jobs and conflating them is the most common design mistake in this space. A cart drawer is a full side panel, usually triggered by an add-to-cart action or a persistent cart icon, that shows the entire cart contents, subtotal, and a path to checkout. It needs real estate, scrolling, and a solid focus trap because the user is meant to stop and review before moving on. A mini-cart is a lighter, often transient dropdown that appears briefly after an add-to-cart click to confirm the action and show a running count — it is a confirmation surface, not a review surface.
Map each to the shadcn primitive that already solves its core interaction problem instead of building either from scratch on top of a plain div. The cart drawer should be built on Sheet, which is a styled wrapper around Radix's Dialog primitive — it already handles the focus trap, the ESC-to-close, the backdrop, and body-scroll locking. The mini-cart should be built on Popover, which anchors to a trigger element, handles outside-click dismissal, and does not steal focus the way a modal dialog does. Reaching for Dialog/Sheet for a two-second confirmation popover is why so many "added to cart" toasts feel heavier than they should.
Building the Cart Drawer on Sheet
Start from the shadcn Sheet component and treat its side prop as your first real decision — cart drawers should open from the side prop set to right in left-to-right layouts, matching the reading direction shoppers already expect from every major ecommerce site. Set a fixed width rather than letting it size to content; something in the 380 to 440 pixel range on desktop reads as a proper review surface without swallowing the whole viewport, and on mobile you generally want the sheet to expand closer to full width so line items do not wrap awkwardly.
Structure the inside of the sheet in three fixed regions rather than one scrolling column: a header with the cart title and item count, a scrollable middle region wrapped in the shadcn ScrollArea component for the line items, and a pinned footer with subtotal, shipping note, and the checkout button. This is the single biggest UX fix over a naive implementation — if the whole drawer scrolls as one block, the checkout button disappears off-screen the moment a shopper has more than three or four items in the cart, which is precisely the shopper you most want to convert.
- Header: cart title, item count badge, and a close button — Sheet gives you close-on-ESC and close-on-backdrop-click for free, so the explicit button is a redundant but expected affordance, not a functional necessity.
- Line items: image thumbnail, product title and variant text, quantity stepper, line total, and a remove action — each row should be its own component so quantity updates do not force a re-render of the whole list.
- Footer: subtotal (not the final total — taxes and shipping are usually resolved at checkout), a short reassurance line about shipping or returns, and a single, unambiguous checkout button as the only primary action in the drawer.
Building the Mini-Cart on Popover
The mini-cart's job is narrower, so keep its content narrower too. Anchor a Popover to the cart icon in the header, and open it programmatically right after a successful add-to-cart mutation rather than only on click — that immediate, unrequested appearance is what makes it read as a confirmation rather than a menu the shopper has to go find. Auto-dismiss it after a few seconds, but only if the shopper has not moved their pointer into it; cancel the timer on hover so a shopper who is actually looking at what got added is not interrupted mid-read.
Content-wise, a mini-cart should show the item that was just added, a running subtotal, and two calls to action at most: view cart (which opens the full drawer) and checkout. Resist the urge to duplicate the entire cart drawer's line-item list inside the mini-cart — if a shopper wants to review everything, that is exactly the moment to hand off to the Sheet-based drawer rather than growing the popover into a second, competing cart surface.
Where Cart State Actually Lives
The Sheet and Popover primitives only manage open/closed state — they know nothing about your cart. That state needs to live somewhere both the drawer, the mini-cart, and the header badge can read from without prop-drilling through every layout. In a Next.js App Router setup, a small client-side store (Zustand is the common, low-ceremony choice) synced against your commerce backend's cart object works well: the store holds an optimistic copy of line items and quantities, and every mutation both updates the store immediately and fires the network call to persist it.
If you are building on a headless commerce backend like Medusa, the cart itself is server-authoritative — the client store should be treated as a fast, optimistic mirror of that server cart, not a second source of truth. Reconcile the store with the server response after every mutation completes, and on a failure, roll the optimistic change back and surface a small inline error on the affected row rather than a page-level alert that yanks focus away from the drawer.
Optimistic Updates Without the Jank
Quantity steppers are the part of a cart drawer most likely to feel broken if you get this wrong. Debounce the network call behind a stepper by a few hundred milliseconds so a shopper clicking the plus button five times in a row fires one request with the final quantity, not five requests racing each other. Update the displayed quantity and line total instantly on each click regardless of debounce — the optimistic number should always be what is on screen, and the debounced request is purely about what gets sent to the server.
Removing a line item deserves its own small transition rather than an instant disappearance — a brief fade or height collapse before the row leaves the list gives the shopper a moment to register what happened, which matters more than it sounds because remove actions are one of the few destructive actions inside a cart drawer. Pair it with a short-lived undo affordance if your backend makes that cheap to support; it meaningfully reduces the anxiety of an accidental tap on a small touch target.
Accessibility Checklist
Because Sheet is built on Radix's Dialog primitive, a cart drawer gets a real focus trap, ARIA role of dialog, and labelled-by wiring largely for free — do not fight that by manually managing focus yourself. What you do need to add: a visually meaningful aria-label on the sheet itself (something like "Shopping cart" rather than the default), a live region that announces quantity or subtotal changes so screen reader users are not left re-reading the whole drawer after every update, and enough contrast on quantity buttons and remove icons that they read clearly against your surface tokens rather than blending in as decoration.
- Trigger buttons (cart icon, add-to-cart) need accessible names that describe the action, not just an icon.
- The mini-cart's auto-dismiss timer must be pausable — WCAG's timing guidance applies even to a small popover.
- Quantity steppers should be real buttons with type="button", not clickable divs, so they are reachable by keyboard and have a default disabled state at quantity one.
- Checkout buttons inside a loading state need an accessible "processing" announcement, not just a visual spinner.
Theming It to Match Your Storefront, Not the shadcn Defaults
The most common tell that a storefront shipped its cart drawer straight from a shadcn example is that it uses raw gray and slate utilities instead of the store's own design tokens — the drawer looks like a different product than the rest of the page. Route every color decision in the drawer through your project's semantic tokens (surface, foreground, muted-foreground, border, accent) instead of hex values or shadcn's default gray scale, so a dark-mode toggle or a seasonal theme swap applies to the cart the same way it applies to everything else. This is exactly the kind of system-level consistency a well-structured Figma UI kit is built to carry over into code — if you are designing the drawer before you build it, working from a kit like our Figma UI kits collection gives you token-consistent spacing and color decisions to translate directly into the component, rather than guessing at padding and hex values as you go.
Common Mistakes to Avoid
- Building the mini-cart as a second Dialog instead of a Popover — it steals focus and feels heavier than the two-second confirmation it is meant to be.
- Letting the entire drawer scroll as one column, which pushes the checkout button off-screen on carts with more than a handful of items.
- Firing a network request on every single stepper click instead of debouncing to the final quantity.
- Skipping the live region, so screen reader users get no feedback when a quantity or subtotal changes.
- Hardcoding gray/slate colors instead of the storefront's semantic tokens, so the cart visually disagrees with the rest of the site.
Frequently Asked Questions
Does shadcn/ui have a built-in cart component?
No. shadcn/ui ships primitives — Sheet, Popover, Dialog, Button, ScrollArea — that you compose into a cart drawer or mini-cart yourself. There is no installable "cart" component, which is intentional given how much cart behavior is specific to each store's commerce backend and state model.
Should the cart drawer use Sheet or Dialog?
Sheet, in almost every case. Sheet is a side-anchored variant built on the same Radix Dialog primitive, and a slide-in panel from the edge of the screen is the pattern shoppers already expect from a cart. Reserve a full centered Dialog for something like a shipping-address confirmation modal, not the main cart surface.
How should mini-cart and cart-drawer state stay in sync?
Both should read from the same client-side store rather than maintaining separate copies of the cart. Treat that store as an optimistic mirror of the server-authoritative cart from your commerce backend, reconciling after every mutation and rolling back cleanly on failure.
Is a mini-cart necessary if I already have a cart drawer?
Not strictly, but it meaningfully improves the add-to-cart moment. Opening the full drawer for every single add-to-cart click interrupts browsing; a brief mini-cart confirms the action without pulling the shopper out of the product grid, and lets them choose to open the full drawer only when they are ready to review the cart.