Skip to content

ProductDetail

The product page's layout. From the lg breakpoint: gallery and content on the left, the buy box sticky on the right across both, related sections full width below. Narrower, the parts stack in document order: gallery, buy box, content. Children are the ProductDetail* parts, in that order.

import { ProductDetail } from "@evolve-storefront/feature-product/product-detail";

Renders inside ProductListingProvider.

Preview

DefaultOpen in Storybook ↗

Usage

<ProductDetail>
<ProductDetailGallery>{gallery}</ProductDetailGallery>
<ProductDetailBuyBox>
<Heading level={1}>{product.name}</Heading>
<VariantSelector dimensions={product.variantDimensions} toHref={toHref} />
<ProductDetailBuyAction><CartAddProductButton sku={variant.sku} /></ProductDetailBuyAction>
</ProductDetailBuyBox>
<ProductDetailContent>
<ProductDetailAnchorNav anchors={[{ id: "overview", hasContent: true }, { id: "reviews", hasContent: reviews.total > 0, count: reviews.total }]} />
<ProductDetailSection id="overview">
<ProductHighlights attributes={variant.highlightedSpecifications} locale={locale} />
<ProductDescription><Markdown>{variant.description}</Markdown></ProductDescription>
</ProductDetailSection>
</ProductDetailContent>
</ProductDetail>
<ProductDetailStickyBar><CartAddProductButton sku={variant.sku} /></ProductDetailStickyBar>;

Props

No props of its own.

Change the layout

Pass children to ProductDetail to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-product/product-detail.

<ProductDetail>
<ProductDetailGallery>{gallery}</ProductDetailGallery>
<ProductDetailBuyBox>
<Heading level={1}>{product.name}</Heading>
<VariantSelector dimensions={product.variantDimensions} toHref={toHref} />
<ProductDetailBuyAction><CartAddProductButton sku={variant.sku} /></ProductDetailBuyAction>
</ProductDetailBuyBox>
<ProductDetailContent>
<ProductDetailAnchorNav anchors={[{ id: "overview", hasContent: true }, { id: "reviews", hasContent: reviews.total > 0, count: reviews.total }]} />
<ProductDetailSection id="overview">
<ProductHighlights attributes={variant.highlightedSpecifications} locale={locale} />
<ProductDescription><Markdown>{variant.description}</Markdown></ProductDescription>
</ProductDetailSection>
</ProductDetailContent>
</ProductDetail>
<ProductDetailStickyBar><CartAddProductButton sku={variant.sku} /></ProductDetailStickyBar>;
ProductBackInStockForm"Mail me when it's back" for a sold-out variant, in place of the add button.
PropTypeDescription
onValidSubmit(input: { email: string; }) => void

Called with a valid address; the site sends ProductVariantBackInStockSubscribe.

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.

isPending?boolean
isSubscribed?boolean

The request went through: the form gives way to a confirmation.

error?string | null

A form-level message, such as a failed request.

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

ProductDescriptionThe product description, clamped to --product-description-lines lines (4 by default) with "Read more" when it runs longer.

No props of its own.

ProductDetailAccessoriesWhat is sold for this variant: accessories, and spare parts apart from them, each in a row.
PropTypeDescription
accessoriesreadonly ProductAccessory<ListingProduct>[]

readProductAccessories' result.

renderItemProductListingItemRenderer<ListingProduct & { unit?: string | null | undefined; }>

Renders each accessory, usually a ProductCard with the cart's add button in its actions: this package adds nothing to a cart itself.

title?ReactNode
headingLevel?HeadingLevel
id?string

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

ProductDetailAddOnsThe buy box's add-ons, such as an extra warranty: one checkbox each, with its price.
PropTypeDescription
addOnsreadonly ProductAddOn[]

What the variant offers, in display order. Renders nothing when empty.

selectedreadonly string[]

The ticked SKUs, from useAddOnSelection.

onSelectedChange(selected: string[]) => void

Hands over the new list of ticked SKUs.

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

ProductDetailAnchorNavLinks to the page's sections, listing only those with content.
PropTypeDescription
anchorsreadonly ProductDetailAnchor[]

In page order. Only those with content are listed.

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

ProductDetailBundle"Complete the look" or "Frequently bought together": the page's product with items to tick, a size to choose for an item that has sizes, and a live total with the bundle's discount on the extras.
PropTypeDescription
kind"complete-the-look" | "bought-together"

Picks the title: "Complete the look" for fashion, "Frequently bought together" otherwise.

product{ name: string; price: Money | null; image?: { url: string; alt?: string | null | undefined; } | null | undefined; }

The page's product, always part of the bundle.

itemsreadonly ProductBundleItem[]

What the bundle offers with it, in display order. Renders nothing when empty.

selectionProductBundleSelection

From useBundleSelection, with the same items.

discountPercent?number

The percentage off each extra, as the merchant configured the bundle discount in the commerce engine. Only shown here; the cart applies the real discount.

vatDisplay?VatDisplay

The VAT basis of every price passed in, as readProductBundle read it. net marks the prices "excl. VAT"; it converts nothing.

action?ReactNode

Rendered under the total, such as CartAddBundleButton.

renderImage?((props: ProductBundleImageProps) => ReactNode)
id?string
headingLevel?HeadingLevel

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

ProductDetailBuyActionWraps the buy box's main action, so ProductDetailStickyBar can tell when it has scrolled out of view.

No props of its own.

ProductDetailBuyBoxTitle, price, variants and the buy action.

No props of its own.

ProductDetailContentThe decision content next to the buy box: anchor navigation, then the sections.

No props of its own.

ProductDetailDeliveryPromiseWhere and when the variant arrives: "Order before 22:00, delivered tomorrow", the carrier and the delivery cost, for the shopper's postcode, which they can change in place.
PropTypeDescription
promiseProductDeliveryPromise | null | undefined

The variant's deliveryPromise. Null renders nothing; undefined, while it loads, only the postcode.

onPostalCodeChange(postalCode: string) => void

The shopper changed the postcode; the site asks for the promise again.

timeZonestring

The store's time zone, in which "tomorrow" and the order deadline are read.

postalCode?string | null

The postcode the promise is for, if the shopper gave one.

alternativesHref?string

Where to find variants or products that can be delivered, when this one cannot.

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

ProductDetailDocumentsA variant's documents, each a download with what it is, its file kind, size and language where the catalog says.
PropTypeDescription
documentsreadonly ProductDocument[]

readProductDocuments' result.

title?ReactNode
headingLevel?HeadingLevel
id?string

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

ProductDetailGalleryThe product's images, first on a phone and top left from lg.

No props of its own.

ProductDetailPriceThe buy box's price.
PropTypeDescription
priceGrossPrice

What the variant costs now. Renders nothing when absent.

regularPrice?GrossPrice

The price without a sale; struck through, with the percentage, when higher.

lowestPrice?GrossPrice

The lowest price in the 30 days before the discount, shown under a discounted price.

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

ProductDetailPromiseOne promise under the buy action, such as free delivery, with an optional icon.
PropTypeDescription
icon?"email" | "sort" | "error" | "check" | "add" | "menu" | "search" | "time" | "filter" | "security" | "success" | "pending" | "next" | "previous" | "expand" | "co…

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

ProductDetailPromisesThe promises under the buy action, by how the variant reaches the buyer.
PropTypeDescription
kindProductKind | null | undefined

The variant's kind, which picks the list. Unknown reads as shipped.

physical?ReactNode

The site's delivery and return promises, as ProductDetailPromises.

digital?ReactNode

Replaces the default digital promises: instant download, no shipping.

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

ProductDetailQuantityThe buy box's quantity, or nothing for a digital variant.
PropTypeDescription
valuenumber
onValueChange(value: number) => void
kindProductKind | null | undefined

The variant's kind. A digital one renders nothing.

disabled?boolean

Locks the value, e.g. while the previous change saves: the buttons disable and the field turns read-only but keeps focus, so a pending save cannot blur it.

max?number
min?number
step?number

The pack size: steps go by it, and a typed value snaps to a multiple of it, with min and max rounded onto multiples too. Defaults to 1.

onRemove?(() => void)

At the minimum, the decrement becomes a remove button that calls this, such as removing a cart line. Without it the decrement is disabled there.

removeLabel?string

Accessible label for the remove button; required with onRemove.

quantityRule?ProductQuantityRule | null

The variant's quantityRule: steps go by its pack size between its minimum and maximum. Start value at its minimum. min, max and step override it.

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

ProductDetailRecentlyViewedThe products the shopper looked at before this one, newest first, from this browser's history; no request is made.
PropTypeDescription
productListingProduct

The variant on this page: recorded as viewed, and left out of the row.

renderItemProductListingItemRenderer<ListingProduct>

Renders each product, usually a ProductCard.

title?ReactNode
headingLevel?HeadingLevel
storageKey?string

The localStorage key; two sites on one domain each pass their own.

limit?number

How many products the history keeps.

id?string
show?number

How many to show; the history may keep more.

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

ProductDetailRelatedFull-width sections under both columns, such as related products.

No props of its own.

ProductDetailRelatedProductsThe products related to this one, in a row under the page's columns.
PropTypeDescription
productsreadonly ListingProduct[]

readRelatedProducts' result, or a site's own recommendations.

renderItemProductListingItemRenderer<ListingProduct>

Renders each product, usually a ProductCard.

title?ReactNode
headingLevel?HeadingLevel
id?string

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

ProductDetailSectionA content section with a heading, the target of an anchor in ProductDetailAnchorNav.
PropTypeDescription
id(string & {}) | "overview" | "specifications" | "reviews" | "questions" | "documents" | "accessories" | "comparison"

The anchor target. A known id gets its default title.

title?ReactNode
headingLevel?HeadingLevel

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

ProductDetailStickyBarA bar stuck to the bottom of the viewport below lg, shown once the buy box's main action has scrolled up out of view.
PropTypeDescription
watch?string

The id of the element whose scrolling out of view shows the bar.

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

ProductDetailStockThe selected variant's stock as a status tag: in stock, "Only 2 left", a pre-order with its expected date and when it is charged, or sold out with a link to the nearest variant in stock.
PropTypeDescription
stockProductStock | null | undefined

The variant's stock. Renders nothing while it is null.

alternative?ProductStockAlternative | null

The variant's inStockAlternative, offered when it is sold out.

toHref?((target: { slug: string; sku: string; }) => string)

The alternative's address: the app's route.

renderLink?((props: StockAlternativeLinkProps) => ReactNode)

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

ProductDetailStockActionThe buy box's action by stock: the button, or the back-in-stock form in its place when sold out.
PropTypeDescription
stockProductStock | null | undefined

The variant's stock.

children(props: { action: "add" | "pre-order"; label: string | undefined; }) => ReactNode

The add button, given the action and its label: "Pre-order" for a pre-order.

notifyReactNode

Shown instead of the button when sold out, such as ProductBackInStockForm.

ProductHighlightsThe first few highlighted specifications, each as its label and value.
PropTypeDescription
attributesreadonly SpecificationAttribute[]

The variant's highlightedSpecifications.

localestring
limit?number

How many to show; the page leads with three.

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

Examples

Digital ProductThe buy box follows the variant's kind.
<ProductDetailBuyBox>
<VariantSelector dimensions={product.variantDimensions} toHref={toHref} />
<ProductDetailQuantity kind={variant.kind} value={quantity} onValueChange={setQuantity} />
<ProductDetailBuyAction>
<CartAddProductButton sku={variant.sku} quantity={orderQuantity(variant.kind, quantity)} />
</ProductDetailBuyAction>
<ProductDetailPromises
kind={variant.kind}
physical={<ProductDetailPromise icon="shipping">{t("delivery")}</ProductDetailPromise>}
/>
</ProductDetailBuyBox>
Open in Storybook ↗
BusinessA business page lists its sections in its own order, specifications first, and a section the framework has no title for passes its own.
<ProductDetailAnchorNav
anchors={[
{ id: "specifications", hasContent: true },
{ id: "documents", hasContent: documents.length > 0 },
{ id: "accessories", hasContent: accessories.length > 0 },
{ id: "reviews", hasContent: reviews.total > 0, count: reviews.total },
]}
/>;
Open in Storybook ↗
RelatedUnder both columns: the products this one names as related, then the ones the shopper viewed before it, from this browser.
const related = await readRelatedProducts(gqlClient, { slug });
<ProductDetailRelated>
<ProductDetailRelatedProducts
products={related}
renderItem={(product) => <ProductCard product={product} href={productHref(product)} />}
/>
<ProductDetailRecentlyViewed
product={variant}
renderItem={(product) => <ProductCard product={product} href={productHref(product)} />}
/>
</ProductDetailRelated>;
Open in Storybook ↗
Sticky BarThe bar appears once the buy box's action has scrolled up out of view, and goes again when it scrolls back.
// After ProductDetail, in the same scrolling parent.
<ProductDetailStickyBar>
<span>{variantLabel} · <Price money={variant.price} /></span>
<CartAddProductButton sku={variant.sku} />
</ProductDetailStickyBar>;
Open in Storybook ↗
Sticky Buy Box Ends At RelatedFrom lg the buy box sticks while the content beside it scrolls, and lets go where that row ends, so it never covers a full-width related section.
<ProductDetail>
<ProductDetailGallery>…</ProductDetailGallery>
<ProductDetailBuyBox>…</ProductDetailBuyBox>
<ProductDetailContent>…</ProductDetailContent>
<ProductDetailRelated>
<ProductDetailSection id="specifications">…</ProductDetailSection>
</ProductDetailRelated>
</ProductDetail>;
Open in Storybook ↗