Blocks

Cart Drawer

A slide-out cart panel whose money re-derives on every change — quantity steppers that refuse with a reason, an undoable remove, a promo field driven by the host's verdict, and free-shipping progress.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  CircleAlert,
  ImageOff,
  LoaderCircle,
  Minus,
  Plus,
  RotateCw,
  ShoppingBag,
  Tag,
  Trash2,
  Truck,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/cart-drawer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CartDrawer" block with zod and lucide-react,
plus shadcn's Button and Input; cn() (clsx + tailwind-merge) for classes, no other
dependency. This is the panel that slides out when someone opens their basket: the
lines, the money, and the two decisions taken in it — change my mind about a
quantity, and go and pay. Its only job is to never let the number on the button
disagree with the number in the list.

Contract
- A zod schema (`cartDrawerSchema` in a sibling contract file) is the single source
  of truth and props are z.infer of it — never a parallel interface:
  { status; currency; lines[]; promo?; threshold?; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" — the panel's own render state,
  unrelated to whether the basket has anything in it. A cart that failed to load is
  error, not empty.
- currency is ISO 4217 and decides the decimals. EVERY amount in the contract is an
  INTEGER IN THE MINOR UNIT (cents for USD, yen for JPY, fils for KWD), for two
  reasons: `subtotal - discount === total` has to hold exactly, and 19.99 * 3 is
  59.96999999999999 in floats; and the decimal count belongs to the currency, not to
  the layout. Ask Intl for the divisor (`resolvedOptions().maximumFractionDigits`,
  10 ** that) — a hard-coded 100 is the classic JPY bug that invents "¥1,234.00".
- CartLine = { id (unique; it keys the stepper, the undo timer and the focus map);
  name; variant?; imageUrl?; unitAmount (minor units; NEGATIVE IS ALLOWED — a deposit
  refund is a real line); quantity; minQuantity? (default 1); maxQuantity? (number |
  null = uncapped); note?; unavailable? }.
- `unavailable` means it sold out while it sat in the basket. The line STAYS on
  screen — silently deleting something the shopper chose is worse than explaining it
  — and drops out of the unit count, the subtotal and the perk progress.
- CartPromo = { code; state: "checking" | "applied" | "rejected"; label?; amountOff?
  (positive minor units); reason? }. This is the HOST's verdict. The panel never
  decides whether a code is valid, because the discount arithmetic belongs to the
  server that will honour it.
- CartThreshold = { amount (minor units; <= 0 means already unlocked); label?
  (lowercase noun phrase, default "free shipping") }.
- Component props = the schema type plus: locale? ("en-US"), heading? ("Your cart"),
  skeletonLines? (3, clamped 1-8), undoRemoveMs? (6000), closeFocusRef?, onClose?,
  onQuantityChange?, onRemove?, onApplyPromo?, onRemovePromo?, onCheckout?, onRetry?,
  onBrowse?, browseLabel?, checkoutLabel? ("Checkout"), emptyMessage?, footnote?.
  forwardRef<HTMLElement>, extends Omit<React.HTMLAttributes<HTMLElement>,"onClose">,
  rest spread on the root <aside>.
- Every callback is optional AND load-bearing: omit onQuantityChange and quantities
  render as static "Qty 2" instead of a stepper nobody wired; omit onRemove and there
  is no Remove; omit onApplyPromo and the promo field is not painted; omit onClose,
  onRetry or onBrowse and those buttons do not exist. A dead control is worse than a
  missing one.
- Export the arithmetic — summarizeCart(lines, promo) -> { units; payableLines;
  unavailableLines; subtotal; discount; total }, quantityBounds(line),
  clampQuantity(line, value), shippingProgress(spent, target) — so a header badge, a
  mini-cart or an analytics event reuses it instead of growing a second opinion about
  what the basket is worth.

Behavior — the maths, re-derived every render
- quantityBounds: min = max(1, trunc(minQuantity ?? 1)); max = null when uncapped,
  otherwise max(min, trunc(maxQuantity)). A feed reporting a cap BELOW its own floor
  is broken, and the floor wins — a stepper with no legal value at all is worse than
  a wrong cap.
- clampQuantity is ONE function read by the stepper, the line total and the subtotal.
  That is what guarantees the number on the stepper is the number being charged for.
  quantity 0 renders and charges at the floor; a zero-quantity line is not a cart line.
- Non-finite money settles at 0 (never "$NaN"); non-finite counts settle at 0 before
  clamping. Normalise -0 so a zero line prints "$0.00", never "-$0.00".
- subtotal = sum over PAYABLE lines of unitAmount * clampQuantity. discount =
  min(max(0, amountOff), max(0, subtotal)) and only while state === "applied", so a
  promo worth more than the basket cannot make the printed discount differ from the
  discount taken, and cannot drive the total below zero. total = subtotal - discount.
- shippingProgress(total, threshold.amount): target <= 0 is already met and never
  divides; remaining = max(0, target - max(0, total)); ratio = min(1, spent / target).
  It measures the total AFTER the discount — the perk should track what is actually
  being paid — and that is a one-line change if your merchant disagrees.
- Nothing is memoised. The list is filtered and the totals recomputed in the same
  render that changed a quantity, which is the whole reason the button cannot quote
  a stale figure.
- A malformed currency code or locale makes Intl.NumberFormat throw a RangeError at
  construction; catch it and fall back to a plain number formatter that prints the
  code beside the amount, so a typo in a feed cannot blank the drawer and an amount is
  never shown without saying what it is.

Behavior — four branches, not one plus three afterthoughts
- loading: the heading survives (it is known before the feed answers); below it
  skeletonLines rows and a skeleton footer, aria-hidden, aria-busy on the root.
- empty: it SELLS rather than scolds — say what happens to anything they add, and
  what the perk is worth if a threshold is known. The CTA appears only with onBrowse.
- error: never claim the basket is empty. Say the items are safe and that this panel
  cannot read them right now; print errorMessage when given. Retry only with onRetry.
- ready: list, then a sticky footer with the perk meter, the promo field, the summary
  and the CTA.
- status "ready" with zero visible lines renders the EMPTY branch: an empty list under
  a checkout button quoting zero is worse than saying nothing.

Behavior — the stepper
- Minus is blocked at min, plus at max, and both while the line is unavailable. Use
  aria-disabled plus a guard inside the handler, NEVER the native disabled attribute:
  the browser blurs a focused disabled control to the document body and the keyboard
  user loses their place.
- The refusal always carries the reason, in three places: the button's aria-label
  ("Decrease quantity of Aurora desk lamp. This is the last one — use Remove to take
  it out."), a line of muted text under the controls, and the live region when the
  blocked button is actually pressed. A control that refuses silently is the same bug
  whether or not a screen reader is involved.
- min > 1 says "5 is the smallest order for this one"; at the cap it says "3 is the
  most you can take of this one"; unavailable says the quantity is fixed and the line
  is not included in the total.

Behavior — remove, undo, and where focus goes
- Remove never calls onRemove straight away. It PARKS the line: the row becomes an
  undo row ("Removed <name>" + an Undo button), the line leaves the totals
  immediately (that is what optimistic means here) and stays in the list (that is
  what keeps the panel out of the empty branch), and a timer commits it after
  undoRemoveMs.
- Focus claims are a ref written in the handler and spent by an effect that runs
  after the commit: Remove hands focus to that row's Undo; Undo hands it back to that
  row's Remove; when the timer fires while the shopper is standing on Undo, focus
  goes to the line that took its place (the next one, or the previous one if it was
  last, or the close button when nothing is left). The fallback chain ends on the
  panel itself (tabIndex={-1}) so focus is never dropped on the document body.
- On commit, hide the line OBJECT the host is holding right now (look it up from a
  ref holding the latest lines), not the object captured at click time — a quantity
  edit elsewhere may have rebuilt the array. The moment the host sends fresh objects
  the host's data wins, because the server knows more about the cart than the panel.
- On unmount, FLUSH every parked removal (clear the timer, then call onRemove). The
  shopper asked for it; from their side the undo window closed when the drawer left
  the screen. Dropping it silently would resurrect an item they deleted.

Behavior — promo, checkout, keyboard
- The promo field is a controlled draft. Enter applies and preventDefault()s so a
  code never submits a surrounding form; Escape with a draft clears it and stops
  propagation (innermost layer first) so the overlay keeps its own Escape for the
  second press. Empty draft: aria-disabled Apply, and pressing it says "Enter a promo
  code first" and returns focus to the field.
- Apply is one-shot: a ref read AND written synchronously in the handler, re-armed in
  an effect that runs after the commit — a state flag is only visible after a
  re-render and the second click of a double-click lands before that. While the host
  answers "checking", Apply spins and further presses are refused.
- state "applied" replaces the field with a removable chip; removing it claims focus
  for the field that takes its place. state "rejected" keeps the typed code, sets
  aria-invalid, and points aria-describedby at the reason so a typo can be fixed
  rather than retyped.
- Checkout and Retry are one-shot with a 1200ms burst lock (ref guard + a `pending`
  flag that only paints it, timer cleared on unmount). With nothing payable the CTA
  is aria-disabled and explains itself rather than going dead.
- Escape on the panel closes when onClose is passed, WITHOUT stopPropagation: the
  layer that opened this panel owns the trap and should run its own teardown too.
  Closing twice is idempotent.
- Closing focuses closeFocusRef BEFORE calling onClose, because a control that
  unmounts while focused drops the caret on the document body.
- Two polite live regions, both sr-only and aria-atomic. The first carries imperative
  feedback (quantity changed, removed with the undo window, put back, refusals) and
  is cleared on a 4s timer so the NEXT IDENTICAL sentence can be announced again. The
  second is DERIVED from the cart — the promo verdict and the moment the perk unlocks
  — so those announce themselves from the render that produced them, with no effect
  calling setState and no cascading render.

Behavior — degenerate data (branches, not crashes)
- A 67-character unbroken product name wraps (wrap-anywhere) instead of overflowing.
- A thumbnail that fails renders a placeholder tile. Probe for it in the ref callback
  (node.complete && node.naturalWidth === 0) as well as onError: on a pre-rendered
  page a cached image can fail BEFORE hydration attaches the handler, and then the
  event never arrives. Hold the URL that failed, not a boolean, so swapping the
  picture gets a fresh attempt.
- quantity 0, quantity 999 against a cap of 3, a cap below its own floor, a NaN
  price, a negative credit line, a promo worth more than the basket, a threshold of
  zero: all of them are clamped or guarded above, and every one of them still renders.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border + rounded-xl
  panel, divide-y rows, bg-muted thumbnails and skeletons, bg-muted/40 for a parked
  row, text-muted-foreground for secondary text, bg-primary for the perk meter,
  border-primary/30 + bg-primary/5 for the applied-promo chip, text-destructive for
  the error branch and the rejected reason, focus-visible rings from the Button
  primitive. Swap the meter fill for var(--chart-1) if the drawer should read as data.
- The panel is `flex max-h-full flex-col`: header and footer shrink-0, the list is
  `min-h-0 flex-1 overflow-y-auto overscroll-contain` so it scrolls while the money
  stays put. Give it a height and it fills a drawer; give it none and it sizes to
  its content.
- Merge the consumer className with cn() on the root and spread the rest of the
  native props there. Money is tabular-nums everywhere so columns line up.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and both spinners,
  motion-reduce:transition-none on the meter's width transition. The meter's WIDTH is
  the value and the transition only smooths it, so turning motion off loses the slide
  and keeps the reading.
- Cleanup: clear the announcement timer, every parked-removal timer (flushing them
  first) and both burst locks on unmount.

Customization levers
- Composition: this is the CONTENTS, not the overlay. Drop it into a Drawer, Dialog
  or Sheet and let that layer own the focus trap, the scroll lock and the slide;
  `closeFocusRef` is this panel's half of the contract. Rendering it inline as a
  sidebar cart is the same component with no wrapper.
- Sub-blocks: omit `threshold` and the meter disappears; omit `onApplyPromo` and the
  promo field does; omit `onCheckout` and the footer is a summary. Each is absent
  rather than an empty shell.
- Timing: undoRemoveMs (6000) is the whole undo affordance — raise it for a
  destructive catalogue, drop it to 0-ish for an instant commit. The 1200ms burst
  lock and the 4s announcement clear are the other two constants.
- Density: the 56px thumbnail, the px-4/py-3 row and the icon-xs stepper are the
  three numbers that make it compact or roomy; skeletonLines should match how many
  rows you expect.
- Money policy: switch the perk to measure the subtotal instead of the total (one
  argument), or make the discount a percentage the panel derives rather than an
  amount the host sends (change one line in summarizeCart).
- Copy: heading, checkoutLabel, browseLabel, emptyMessage and footnote are the whole
  voice of the panel; threshold.label lets the perk be a gift, a sample or a tier
  rather than shipping.
- Actions: onCheckout gets the computed totals, so you can send them straight to a
  payment intent. Swapping the CTA for a link is a Button asChild around an <a>.

Concepts

  • Derived money — nothing is cached. One clamp feeds the stepper, the line total and the subtotal, and the totals are recomputed in the same render that changed a quantity, so the checkout button can never quote a figure the list has already moved past. Amounts are integers in the currency's minor unit and the decimal divisor is asked of Intl, which is why a JPY basket prints ¥1,234 and not ¥12.34.
  • Parked removal — Remove is a promise, not an execution: the line leaves the totals at once, stays on screen as an undo row, and only becomes onRemove when the window closes. Unmounting inside that window flushes the pending removals instead of dropping them, because from the shopper's side the undo window closed when the drawer did.
  • Refusal with a reason — a stepper at its floor or its stock cap is aria-disabled with a guard inside the handler, never natively disabled, so the button keeps focus and a keyboard user is never dropped on document.body. The reason is on the label, under the control and in the live region — three places, one sentence.
  • Focus successor chain — anything that unmounts under the user's hands names its heir: Remove hands focus to Undo, Undo hands it back to Remove, a committed removal hands it to the line that took its place, and closing hands it to the trigger that opened the drawer. The chain ends on the panel itself, so there is no path that ends at the document body.
  • Host-owned verdict — the panel never decides whether a promo code is valid; it renders checking, applied or rejected and clamps the discount to the subtotal. The arithmetic belongs to the server that will honour it, and the clamp is what stops a generous code from printing a discount the total never took.
  • The panel is the contents — the drag physics, the focus trap and the scroll lock belong to whatever layer slides it in. This block brings the cart, the money and closeFocusRef; a Drawer, Dialog or Sheet brings the rest, and neither half has to guess what the other does.

On This Page