Skip to content

CartAddProductButton

Add a product to the cart, including the "no cart yet" path — useAddToCart names the current cart on the wire and lets the server create one only when there is none, so a first add is a single round trip and a second one lands in the cart the first made.

import { CartAddProductButton } from "@evolve-storefront/feature-cart/cart-add-product-button";

Renders inside CartProvider.

Design notes

It always targets the active cart, and takes no cart argument. Adding to a specific cart would put a target prop on this button, and every PDP and PLP in the storefront would inherit it — in single-cart mode too, where it means nothing. In multi-cart the flow is switch first, then add.

There is no CartAddProductForm. The only field cart owns is quantity, and ui/quantity-stepper already clamps it, so a PDP composes the stepper next to this button. Anything richer is the client's fields with the client's schema: they own the form and call useAddToCart, which fires the same confirmation seam.

It renders exactly one element and no confirmation of its own — a partial OUT_OF_STOCK add arrives as warnings on the seam payload, so the app decides whether that is a toast, a line in a mini-cart, or nothing.

Preview

DefaultOpen in Storybook ↗

Usage

The button on its own. Watch the badge: no confirmation UI is wired here.

// It always targets the active cart, and creates one only if there is none.
<CartAddProductButton sku={variant.sku} />;

Props

PropTypeDescription
skustring
isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

type?"reset" | "button" | "submit"
variant?ButtonVariant
tone?ButtonTone
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

asChild?boolean
data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

quantity?number
addOns?readonly string[]

SKUs of add-ons to add with the product, such as a warranty: one of each per unit, each its own line linked to the product's.

availableStock?number | null

Stock for this variant, when the surface knows it. 0 disables the button and says why — surfaced here rather than left to every caller to derive from disabled, which throws the reason away.

availability?CartVariantAvailability | null

The variant's availability. NOT_FOR_SALE, a variant without a price, disables it.

onAdded?((result: LineItemsAddedResult) => void)

Confirmation for this call site, overriding CartProvider's onLineItemsAdded. An override, not an addition: a surface that opens a cross-sell dialog does not also want the app-wide mini-cart.

children?ReactNode

Any other props go to the root element, like on a plain HTML element.

Examples

RefusedA refused add is said beside the button, in the cart's own words.
// The alert renders as a sibling of the button; onAdded never runs.
<CartAddProductButton sku={variant.sku} onAdded={confirm} />;
Open in Storybook ↗
Product Detail PageA PDP: ui's stepper beside the button.
const [quantity, setQuantity] = useState(1);
// No form, no schema, no second code path — the stepper clamps against `max`.
<QuantityStepper
value={quantity}
onValueChange={setQuantity}
min={1}
max={variant.availableStock}
label={t("quantity-label", { product: variant.name })}
decrementLabel={t("quantity-decrease", { product: variant.name })}
incrementLabel={t("quantity-increase", { product: variant.name })}
/>
<CartAddProductButton
sku={variant.sku}
quantity={quantity}
availableStock={variant.availableStock}
>
{t("add-to-cart")}
</CartAddProductButton>;
Open in Storybook ↗
Out Of StockavailableStock={0} disables the button and says why, rather than leaving every caller to derive it from disabled and lose the reason.
// `availableStock={0}` disables it AND labels it. Passing `disabled` yourself
// works too, but the shopper is then not told why.
<CartAddProductButton sku={variant.sku} availableStock={0} />;
Open in Storybook ↗
Not For SaleA variant without a price for this shopper cannot be added, and the button says so.
<CartAddProductButton sku={variant.sku} availability={variant.availability} />;
Open in Storybook ↗
Confirm With ToastRecipe 1 — a toast, via ui/toaster.
// Set once, on the provider: every add anywhere confirms the same way,
// including a reorder flow or your own form calling `useAddToCart`.
<CartProvider
onLineItemsAdded={({ addedItems, requested, warnings }) => {
const name = addedItems[0]?.productName ?? "";
if (warnings.length > 0) {
toast.warning(t("added-partial", { product: name }));
return;
}
toast.success(t("added", { product: name, count: requested[0].quantity }));
}}
>
{children}
</CartProvider>;
Open in Storybook ↗
Confirm With DialogRecipe 2 — a dialog, via ui/dialog: the natural home for cross-sell, because it has room for a product carousel.
// `onAdded` overrides the provider default for this call site — an override,
// not an addition, so this surface does not also open the mini-cart.
const [added, setAdded] = useState<LineItemsAddedResult | null>(null);
<CartAddProductButton sku={variant.sku} onAdded={setAdded} />
<Dialog open={added !== null} onOpenChange={() => setAdded(null)}>
<DialogContent closeLabel={t("close")}>
<DialogHeader>
<DialogTitle>{t("added-title")}</DialogTitle>
<DialogDescription>
{added?.addedItems.map((item) => item.productName).join(", ")}
</DialogDescription>
</DialogHeader>
{/* Your recommendations query — this package never fetches products. */}
<CrossSellCarousel skus={added?.requested.map((item) => item.sku)} />
<DialogFooter>
<Button variant="secondary" onClick={() => setAdded(null)}>{t("keep-shopping")}</Button>
<Button>{t("go-to-cart")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
Open in Storybook ↗
Confirm With Mini CartRecipe 3 — a mini-cart, via ui/sheet.
// The seam hands over the authoritative `cart`, so the panel needs no query.
const [added, setAdded] = useState<LineItemsAddedResult | null>(null);
const cart = added?.cart;
<CartAddProductButton sku={variant.sku} onAdded={setAdded} />
<Sheet open={added !== null} onOpenChange={(open) => !open && setAdded(null)}>
<SheetContent closeLabel={t("close")}>
<SheetHeader>
<SheetTitle>{t("added-title")}</SheetTitle>
</SheetHeader>
<SheetBody>
{/* Or compose `Cart` + `CartLineItems` here for the full rendering. */}
<ul className="flex flex-col gap-3">
{cart?.lineItems.map((line) => (
<li key={line.id}>{line.quantity} × {line.productName}</li>
))}
</ul>
</SheetBody>
<SheetFooter>
<CheckoutButton total={cart?.total.gross} />
</SheetFooter>
</SheetContent>
</Sheet>;
Open in Storybook ↗
Guest SessionA guest, who has no session until something asks for one — and add to cart is what asks.
// The app's own hook: create a guest session if there is none, then
// revalidate auth state so `hasSession` flips and the cart query runs.
const ensureSession = useEnsureSession();
const { hasToken, validateLocalToken } = useAuth();
<CartProvider
hasSession={hasToken}
ensureSession={ensureSession}
onTokenChange={validateLocalToken}
>
{children}
</CartProvider>;
Open in Storybook ↗