Cart
The cart page. Pure view — it never fetches; the caller passes
the cart it owns via useCart().
import { Cart } from "@evolve-storefront/feature-cart/cart";Renders inside CartProvider.
Design notes
The root and the summary render on the server; CartLineItems stays a
client leaf because it owns the quantity and remove mutations plus the undo
timer. That split is the point — the summary is the largest static block on the
page and no longer ships as client code.
The cross-sell carousel the design shows below the line items is not here: that is product recommendations rendered by the app, and the cart page composes it alongside these parts.
Preview
Usage
The default composition: lines beside the summary on a wide cart, where the summary
sticks so checkout never scrolls away (--cart-summary-top offsets it for a fixed
header). On a narrow cart it sits below the lines and CartSummaryBar holds the
bottom of the screen. The second line is over its stock, called out on the line.
Remove a line and it leaves a "removed. Cancel" row in its slot for three seconds.
const { data: cart, isPending } = useCart();
// Gate on isPending, never isLoading: the cart query is auth-gated, and// `isLoading` is `isPending && isFetching`, so it reports false while gated.if (isPending) return <CartSkeleton />;// `isSuccess && !data` is an empty cart, not a 404.if (!cart) return <CartEmpty><Button>Start shopping</Button></CartEmpty>;
// No children = the standard two-column composition, with a sticky summary.return <Cart cart={cart} checkout={<CheckoutButton />} />;Props
| Prop | Type | Description |
|---|---|---|
cart | Cart | The resolved cart. The caller owns the query; this view only renders. |
vatDisplay? | VatDisplay | Net vs gross presentation. A prop, not a read of |
otherDiscount? | CartDiscountExplanation | Names the summary's discount that is not a code, such as a contract price. |
freeDelivery? | CartFreeDeliveryProgress | How far the cart is from free delivery, in the summary. Omit for none. |
deliveryEstimate? | CartDeliveryEstimate | null | The summary's shipping row with its arrival and carrier. Omit for today's row. |
renderLink? | CartLineLinkRenderer<Cart["lineItems"][number]> | Link each line's image and title to the product, in the default
composition. Ignored when |
renderAvailability? | CartLineAvailabilityRenderer<Cart["lineItems"][number]> | Each line's availability, under its facts, in the default composition.
Omit for none. Ignored when |
renderImage? | CartLineImageRenderer<Cart["lineItems"][number]> | Each line's image, such as the app's image component, in the default composition. |
renderActions? | CartLineActionsRenderer<Cart["lineItems"][number]> | More actions on each line, beside remove, with the list's own writes, in the default composition: a "move to wishlist" keeps the one undo window. |
lineHeadingLevel? | HeadingLevel | Render each product name as a heading of this level, in the default composition. |
changes? | CartLineChange[] | What changed since the last visit, from |
renderItem? | CartLineItemRenderer<Cart["lineItems"][number]> | Replace the row markup entirely, in the default composition. Ignored when
|
checkout? | ReactNode | The checkout call to action, in the default composition: the summary's, and
|
discountCode? | ReactNode | The discount-code entry in the default composition's summary. Defaults to
|
children? | ReactNode | Compose the pieces ( |
Any other props go to the root element, like on a plain HTML element.
Change the layout
Pass children to Cart to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-cart/cart.
// Pass children to arrange the parts yourself; each takes the cart as a prop.<Cart cart={cart}> <CartGrid> <div className="flex flex-col gap-4"> <CartLineItems cart={cart} /> <CartClearButton cart={cart} /> </div> <CartSummary cart={cart} checkout={<CheckoutButton />} paymentMethods={<PaymentIcons />} usps={<UspList />} > <CartDiscountCodeForm cart={cart} /> </CartSummary> </CartGrid></Cart>;CartLayoutThe standard two-column composition — what Cart renders when given no children.
| Prop | Type | Description |
|---|---|---|
cart | Cart | |
vatDisplay? | VatDisplay | |
otherDiscount? | CartDiscountExplanation | |
freeDelivery? | CartFreeDeliveryProgress | |
deliveryEstimate? | CartDeliveryEstimate | null | |
renderLink? | CartLineLinkRenderer<Cart["lineItems"][number]> | |
renderAvailability? | CartLineAvailabilityRenderer<Cart["lineItems"][number]> | |
renderImage? | CartLineImageRenderer<Cart["lineItems"][number]> | |
renderActions? | CartLineActionsRenderer<Cart["lineItems"][number]> | |
lineHeadingLevel? | HeadingLevel | |
changes? | CartLineChange[] | |
renderItem? | CartLineItemRenderer<Cart["lineItems"][number]> | |
checkout? | ReactNode | |
discountCode? | ReactNode |
CartCheckoutButtonThe cart's checkout call to action: a Button that reports checkoutStarted with its route on click.
| Prop | Type | Description |
|---|---|---|
cart | AnalyticsSourceCart | |
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. MoreNot inferred from the children. A kebab menu is as often a glyph or an SVG
as an |
asChild? | boolean | |
type? | "reset" | "button" | "submit" | |
isPending? | boolean | The action is running. The button keeps its label, so its width and
accessible name hold, shows a spinner and is |
data-component? | undefined | A button's identity is not a caller's to rename — see |
route? | CheckoutRoute | What the click reports as the route. Defaults to |
Any other props go to the root element, like on a plain HTML element.
CartClearButtonEmpty the cart, behind a confirmation because it is destructive and not undoable — ui/alert-dialog, per the overlay rules.
See useClearCart: cartClear is not in the schema yet,
so this removes the lines the client can see.
| Prop | Type | Description |
|---|---|---|
cart | Cart | |
isPending? | boolean | The action is running. The button keeps its label, so its width and
accessible name hold, shows a spinner and is |
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. MoreNot inferred from the children. A kebab menu is as often a glyph or an SVG
as an |
asChild? | boolean | |
data-component? | undefined | A button's identity is not a caller's to rename — see |
children? | ReactNode |
Any other props go to the root element, like on a plain HTML element.
CartDeliveryAddressDialogSet the cart's delivery destination.
It lives on the cart, not inside a flow that happens to need an address. Setting it here makes the shipping-costs line appear in the cart summary, so the buyer sees what their choice did — and it leaves the cart checkout-ready, since checkout wants the same address. Capturing it inside the quote dialog instead changed the price of a cart the buyer was not looking at.
| Prop | Type | Description |
|---|---|---|
cart | Cart | |
open | boolean | |
onOpenChange | (open: boolean) => void | |
addresses | readonly CartDeliveryAddress[] | Saved addresses the buyer may pick from. |
addressEmptyState? | ReactNode | Shown by the picker when |
CartDeliveryAddressPickerPick the cart's delivery destination.
A picker, not a form — it lists what the buyer already has. In B2B that is usually the whole question: the address is a business-unit address that gets chosen.
It used to say a form here would duplicate the validation feature-account owns.
That has stopped being true — the form and its per-country rules are
@evolve-storefront/commerce-ui/address-form, which this package already depends
on. CheckoutAddress composes the two, so entering a fresh address is a form, not
a fork.
Uncontrolled: the radio's name puts the selected id in the enclosing form's
FormData. Outside a form, take onValueChange instead.
Radios rather than a Select — each option is two or three lines, which a select
collapses, and a handful of addresses reads better expanded. Two columns from sm
up: an address card is narrow and a stacked list wasted the width on a desktop
checkout.
| Prop | Type | Description |
|---|---|---|
addresses | readonly CartDeliveryAddress[] | |
defaultValue? | string | The address the cart holds, checked. Never a default: nothing preselects on that. |
onValueChange? | ((addressId: string) => void) | Hands over the picked address id, for a caller that is not inside a form. MoreOptional because the original consumer is inside a form and reads the
selection out of |
emptyState? | ReactNode | Rendered instead of the list when there are no addresses — a link to the
address book, or MoreNot the way to offer a new address in checkout. An empty state only appears
when there is nothing saved, so a buyer with two saved addresses could never
enter a third. |
Any other props go to the root element, like on a plain HTML element.
CartDeliveryUspsThe cart's delivery promises beside its summary: when it arrives, and by when to order for that, and what delivery costs, the amount the summary shows.
| Prop | Type | Description |
|---|---|---|
cart | Pick<Cart, "shippingCosts" | "estimatedShippingCosts"> | |
promise | CartDeliveryPromise | null | undefined | From |
timeZone | string | The store's time zone, such as |
vatDisplay? | VatDisplay | Which shipping amount to show, as the summary shows it. |
Any other props go to the root element, like on a plain HTML element.
CartDiscountCodeFormApply and remove discount codes.
Validation is the server's — a code is valid or not, and no client rule can
decide that — so this form only guards the empty submit and surfaces the
server's error. That is the one case where evolve-feature-form's Zod schema
would be ceremony around a single .min(1).
| Prop | Type | Description |
|---|---|---|
cart | Cart | |
validationErrors? | FormValidationErrors | Field-error map driving display. RAC fields read their own entry by |
serverErrors? | FormValidationErrors | Server-provided field errors (the mapped result of a failed mutation's
typed errors — see |
validationBehavior? | "aria" | "native" | Default |
defaultOpen? | boolean | Show the field from the start. With |
Any other props go to the root element, like on a plain HTML element.
CartDiscountRowsThe discount rows of a cart or checkout summary, inside its PriceSummaryList.
| Prop | Type | Description |
|---|---|---|
rows | readonly CartDiscountRow[] | The rows to show, usually |
otherDiscount? | CartDiscountExplanation | Names the row for the discount that is not a code, otherwise "Discount". |
CartFreeDeliveryHow much more earns free delivery, with a meter filled toward threshold, or "Free delivery" once remaining is zero.
| Prop | Type | Description |
|---|---|---|
threshold | Money | null | undefined | |
remaining | Money | null | undefined |
Any other props go to the root element, like on a plain HTML element.
CartGridThe cart page's two columns: the lines, and the summary beside them once the grid is @4xl wide.
Shared by the layout and its skeleton so their columns cannot drift, and exported
so a composed page builds its placeholder from the same grid. cart-layout names
the grid on both, so a brand re-pointing the columns re-points both at once.
No props of its own.
CartLineItemOne cart line: image, name, attributes, quantity, line total and the unit price under it, with stock messaging inline on the line where the problem is.
The line total is line.price, which already carries quantity; "each" is divided
out of it and rounded. renderLink wraps the image and the title. Generic over
the line shape, so an extended query's fields reach it typed.
| Prop | Type | Description |
|---|---|---|
line | CartLineItem | |
vatDisplay? | VatDisplay | |
layout? | CartLineLayout | Defaults to |
renderLink? | CartLineLinkRenderer<CartLineItem> | Link the image and the title to the product. Omit for no link at all. |
renderAvailability? | CartLineAvailabilityRenderer<CartLineItem> | The variant's availability, under its facts. Omit for none. |
renderImage? | CartLineImageRenderer<CartLineItem> | The image. Omit for a plain |
headingLevel? | HeadingLevel | Render the product name as a heading of this level. Omit for plain text. |
change? | CartLineChange | How this line changed since the buyer last looked, marked on it; from |
onQuantityChange? | ((quantity: number) => void) | Omit to render the quantity read-only (e.g. an order-confirmation reprise). |
onRemove? | (() => void) | Omit to hide the remove action. |
isPending? | boolean | A removal of this line is in flight: its stepper and remove lock. |
isUpdating? | boolean | A quantity write for this line is held or in flight: the row is |
min? | number | The least the stepper allows. Defaults to 1. |
max? | number | The most the stepper allows. Omit for no maximum. |
step? | number | The stepper's step, such as a pack size. Defaults to 1. |
quantityError? | ReactNode | Why the last quantity change did not go through, shown under the stepper. |
actions? | ReactNode | Per-line action slot, e.g. a "move to shopping list" button. |
addOns? | ReactNode | The line's add-ons, as |
bundleExtras? | ReactNode | The extras bought with it as a bundle, as |
Any other props go to the root element, like on a plain HTML element.
CartLineItemRemovedStands in for a line the shopper just removed: " removed.
It occupies the row rather than raising a toast, because the undo belongs where the loss happened — a toast in the corner of a long cart makes the shopper hunt for which line it refers to.
min-h-24 is the real row's height (a 64px thumbnail inside py-4), so the
lines below it do not jump up and then back down.
| Prop | Type | Description |
|---|---|---|
line | CartLineItem | |
onUndo? | (() => void) | Omit to render the notice without an undo affordance. |
isPending? | boolean | The re-add is in flight. |
hasBundleExtras? | boolean | Bundle extras bought with the line stay in the cart, without the bundle's discount. |
reason? | string | Said instead of "removed", such as "moved to your wishlist". The site's words. |
Any other props go to the root element, like on a plain HTML element.
CartLineItemsThe cart's lines, wired to their own mutations: a stepper that changes nothing is no useful default.
| Prop | Type | Description |
|---|---|---|
cart | Omit<Cart, "lineItems"> & { lineItems: CartLineItem[]; } | The cart these lines belong to. The whole cart rather than just the lines,
because the mutations need its id and version — and |
vatDisplay? | VatDisplay | Net vs gross presentation. Defaults to gross. |
renderLink? | CartLineLinkRenderer<CartLineItem> | Link the shipped row's image and title to the product. The cheap seam — reach
for |
renderAvailability? | CartLineAvailabilityRenderer<CartLineItem> | The variant's availability on the shipped row, under its facts. Omit for none. |
renderImage? | CartLineImageRenderer<CartLineItem> | The shipped row's image, such as the app's own image component. |
renderActions? | CartLineActionsRenderer<CartLineItem> | More actions on the shipped row, beside remove. |
lineHeadingLevel? | HeadingLevel | Render each product name as a heading of this level. Omit for plain text. |
changes? | CartLineChange[] | What changed since the last visit, from |
renderItem? | CartLineItemRenderer<CartLineItem> | Replace the row markup entirely. Omit for the standard |
undoTimeout? | number | How long a removed line keeps its "removed. Undo" row, in ms. Defaults to
|
Any other props go to the root element, like on a plain HTML element.
CartMergeNoticeSays what a sign-in added to the cart from the shopper's saved cart, with an Undo where the backend can undo it.
| Prop | Type | Description |
|---|---|---|
merge | CartMerge | null | undefined | The sign-in's |
Any other props go to the root element, like on a plain HTML element.
CartSummaryTotals and the checkout call to action, on commerce-ui's PriceSummary.
| Prop | Type | Description |
|---|---|---|
cart | Cart | |
vatDisplay? | VatDisplay | Net vs gross presentation. Defaults to gross. |
otherDiscount? | CartDiscountExplanation | Names the discount that is not a code, which is otherwise labeled "Discount". |
checkout? | ReactNode | The checkout call to action — routing is the app's. |
paymentMethods? | ReactNode | Payment icons: project content, not cart data. |
usps? | ReactNode | USP list: project content, not cart data. |
children? | ReactNode | Discount-code entry, if the surface offers it. |
headingLevel? | HeadingLevel | The title's level. Defaults to 2. |
editCart? | ReactNode | Beside the title: the site's link back to the cart, such as "Edit cart". |
expressCheckout? | ReactNode | Wallet buttons above the checkout call to action: |
freeDelivery? | CartFreeDeliveryProgress | How far the cart is from free delivery, shown under the totals. Omit for none. |
deliveryEstimate? | CartDeliveryEstimate | null | The shipping row's arrival and carrier, with its price. Omit for today's row. |
Any other props go to the root element, like on a plain HTML element.
CartSummaryBarThe total and the checkout call to action, fixed to the bottom of the viewport on a narrow CartGrid; from @4xl the summary sticks beside the lines instead.
| Prop | Type | Description |
|---|---|---|
cart | Cart | |
checkout | ReactNode | The checkout call to action, as passed to |
vatDisplay? | VatDisplay | Net vs gross presentation, as passed to |
Any other props go to the root element, like on a plain HTML element.
CartViewedEventuseCartViewed as an element, for a cart page that is a Server Component and so cannot call the hook.
No props of its own.
Loading and empty states
Render these while the data loads or when there is nothing to show, so the page does not jump when it arrives.
| Empty | CartEmpty | The empty cart. |
| Loading | CartLineItemSkeleton | One line row — mirrors CartLineItem's flex items-start gap-4 py-4. |
| Loading | CartLineItemsSkeleton | Loading state for CartLineItems — the rows in the list's own wrapper. |
| Loading | CartSkeleton | Loading state for the default Cart composition: the flex flex-col gap-8 section, the two-column grid and the line list, breakpoints included. |
| Loading | CartSummarySkeleton | Loading state for CartSummary. |
Examples
Site SeamsThe shipped row with a site's parts, from the default composition: its links to the product, its image component, the names as headings, an availability status, and a "Move to wishlist" action.
<Cart cart={cart} lineHeadingLevel={3} renderLink={(line, children) => <Link href={`/products/${line.productSlug}`}>{children}</Link>} renderImage={(line) => <Image src={line.variant.primaryImage?.url} width={128} height={128} alt="" />} renderAvailability={(line) => <ProductAvailabilityStatus availability={line.variant.availability} />} renderActions={(line, api) => ( <button onClick={() => { addToWishlist(line); api.removeWith({ reason: t("moved-to-wishlist", { product: line.productName }), onUndo: () => removeFromWishlist(line), }); }} > Move to wishlist </button> )}/>;Lines With ExtrasLines with what belongs to them listed underneath: an add-on, such as a warranty, with its own price and remove action, and a bundle's extras, bought with the product at the bundle's discount, with the regular price struck through.
// Nothing to wire: the list nests add-ons and bundle extras under their line.<Cart cart={cart} />;
// A bespoke row gets both on the api, apart from each other.<CartLineItems cart={cart} renderItem={(line, { addOns, bundleExtras }) => ( <MyRow line={line} addOns={addOns.map((addOn) => addOn.line)} extras={bundleExtras.map((extra) => extra.line)} /> )}/>;Net For BusinessA business buyer reads prices excluding VAT: the build-up, the total excluding VAT, the VAT, and then the total including it, which is what is paid.
<Cart cart={cart} vatDisplay="net" otherDiscount={{ label: "Contract price −10%", note: "agreed until Dec 31, 2026" }}/>;EmptyQuery.cart returning null is this state, not an error.
const { data: cart, isPending } = useCart();
if (isPending) return <CartSkeleton />;// `cart` is null when there is no cart yet — a state to render, not a 404.if (!cart) { return ( <CartEmpty> <Button>Start shopping</Button> </CartEmpty> );}LoadingSkeleton and real view adjacent: the placeholder mirrors the DOM it replaces, including the grid's @4xl split, and shares its CartGrid, so the columns cannot drift.
// The skeleton mirrors the default composition including the breakpoint, so// the summary does not jump from below the lines to beside them on load.if (isPending) return <CartSkeleton lines={3} />;
// A mini-cart preview with no checkout CTA must say so, or the placeholder// is 52px taller than what replaces it:<CartSummarySkeleton checkout={false} />;
// A composed page: one tree, two states, so the placeholder keeps the page's chrome.<Cart cart={cart ?? EMPTY}> <MyToolbar /> <CartGrid> {cart ? <CartLineItems cart={cart} /> : <CartLineItemsSkeleton lines={2} />} {cart ? <CartSummary cart={cart} /> : <CartSummarySkeleton />} </CartGrid></Cart>;
