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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cart-drawer.jsonPrompt
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,234and 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
onRemovewhen 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-disabledwith a guard inside the handler, never nativelydisabled, so the button keeps focus and a keyboard user is never dropped ondocument.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,appliedorrejectedand 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.
Pricing Calculator
An interactive estimator whose billable axes re-derive the bill on every change — switching tier by itself when a cap is crossed, and naming the cheaper plan when another one wins.
Age Gate
A date-of-birth gate with three explicit boxes, a real calendar behind them, and a minimum-age rule whose refusal is honest and always offers a way out.