Skip to content

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

DefaultOpen in Storybook ↗

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

PropTypeDescription
cartCart

The resolved cart. The caller owns the query; this view only renders.

vatDisplay?VatDisplay

Net vs gross presentation. A prop, not a read of CartProvider: the provider is a client context, and reading it here would make this root a client component and drag the summary across with it. The provider still carries it for the mutation hooks, which are client anyway.

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 children is given — compose it onto CartLineItems yourself there.

renderAvailability?CartLineAvailabilityRenderer<Cart["lineItems"][number]>

Each line's availability, under its facts, in the default composition. Omit for none. Ignored when children is given.

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 useCartChanges, marked on each line in the default composition.

renderItem?CartLineItemRenderer<Cart["lineItems"][number]>

Replace the row markup entirely, in the default composition. Ignored when children is given — compose it onto CartLineItems yourself there. The second argument carries the list's own remove / setQuantity, so a bespoke row keeps the undo window rather than forking it.

checkout?ReactNode

The checkout call to action, in the default composition: the summary's, and CartSummaryBar's on a narrow cart. It renders in both, so give it no id. Ignored when children is given.

discountCode?ReactNode

The discount-code entry in the default composition's summary. Defaults to CartDiscountCodeForm behind an "Add a discount code" button; null for none. Ignored when children is given.

children?ReactNode

Compose the pieces (CartLineItems, CartSummary, CartDiscountCodeForm) for a bespoke layout, or omit for the standard two-column composition.

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.
PropTypeDescription
cartCart
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.
PropTypeDescription
cartAnalyticsSourceCart
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
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 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.

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.

route?CheckoutRoute

What the click reports as the route. Defaults to "button".

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.

PropTypeDescription
cartCart
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.

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.

PropTypeDescription
cartCart
openboolean
onOpenChange(open: boolean) => void
addressesreadonly CartDeliveryAddress[]

Saved addresses the buyer may pick from.

addressEmptyState?ReactNode

Shown by the picker when addresses is empty.

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.

PropTypeDescription
addressesreadonly 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.

More

Optional because the original consumer is inside a form and reads the selection out of FormData — but checkout picks an address and writes it immediately, and delegating the radio's change event through the fieldset meant casting event.target. Named onValueChange per evolve-component-api: it hands over the value, so it cannot be onChange.

emptyState?ReactNode

Rendered instead of the list when there are no addresses — a link to the address book, or feature-account's dialog.

More

Not 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. CheckoutAddress composes this picker with a real form for that.

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.
PropTypeDescription
cartPick<Cart, "shippingCosts" | "estimatedShippingCosts">
promiseCartDeliveryPromise | null | undefined

From useCartDeliveryPromise; no arrival line while it is null or undefined.

timeZonestring

The store's time zone, such as Europe/Amsterdam: it decides "today" and the order-before time.

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).

PropTypeDescription
cartCart
validationErrors?FormValidationErrors

Field-error map driving display. RAC fields read their own entry by name, and it shows until the map changes — the owner revalidates as the user edits.

serverErrors?FormValidationErrors

Server-provided field errors (the mapped result of a failed mutation's typed errors — see @evolve-storefront/errors). Merged over validationErrors, and the first errored field is focused, so a failed submit surfaces the same way as client validation. A field's entry clears once the user edits that field, until the next map arrives.

validationBehavior?"aria" | "native"

Default "aria": mark fields via ARIA rather than blocking native submit. The form is then noValidate, so an input's own constraint (type="email") never stops the submit with a browser tooltip that the field's error does not match.

defaultOpen?boolean

Show the field from the start. With false it waits behind an "Add a discount code" button and closes again once a code applies. Defaults to true.

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.
PropTypeDescription
rowsreadonly CartDiscountRow[]

The rows to show, usually cartDiscountRows(cart, vatDisplay).

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.
PropTypeDescription
thresholdMoney | null | undefined
remainingMoney | 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.

PropTypeDescription
lineCartLineItem
vatDisplay?VatDisplay
layout?CartLineLayout

Defaults to business inside a business unit's cart, else consumer.

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 img of the variant's primary image.

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 useCartChanges.

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 aria-busy and carries data-updating for a brand to style. It never locks; a step taken meanwhile queues behind it.

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 CartLineAddOns, listed under its facts.

bundleExtras?ReactNode

The extras bought with it as a bundle, as CartLineBundleExtras, under its add-ons.

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.

PropTypeDescription
lineCartLineItem
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.
PropTypeDescription
cartOmit<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 lineItems is still where the item type is captured, so cart={myCart} infers Line.

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 renderItem only when the whole row has to change.

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 useCartChanges; each line marks its own.

renderItem?CartLineItemRenderer<CartLineItem>

Replace the row markup entirely. Omit for the standard CartLineItem. The second argument carries the list's own writes, so a bespoke row keeps the undo window rather than forking it.

undoTimeout?number

How long a removed line keeps its "removed. Undo" row, in ms. Defaults to CartProvider's undoTimeout, no timer; 0 removes immediately.

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.
PropTypeDescription
mergeCartMerge | null | undefined

The sign-in's cartMerge; nothing renders when it merged nothing.

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.
PropTypeDescription
cartCart
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: ExpressCheckout.

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.
PropTypeDescription
cartCart
checkoutReactNode

The checkout call to action, as passed to CartSummary. Routing is the app's.

vatDisplay?VatDisplay

Net vs gross presentation, as passed to CartSummary. Defaults to gross. Net shows the total excluding VAT under that label, the summary's first total.

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.

EmptyCartEmptyThe empty cart.
LoadingCartLineItemSkeletonOne line row — mirrors CartLineItem's flex items-start gap-4 py-4.
LoadingCartLineItemsSkeletonLoading state for CartLineItems — the rows in the list's own wrapper.
LoadingCartSkeletonLoading state for the default Cart composition: the flex flex-col gap-8 section, the two-column grid and the line list, breakpoints included.
LoadingCartSummarySkeletonLoading 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>
)}
/>;
Open in Storybook ↗
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)}
/>
)}
/>;
Open in Storybook ↗
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" }}
/>;
Open in Storybook ↗
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>
);
}
Open in Storybook ↗
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>;
Open in Storybook ↗