Skip to main content

ProductSlider

A horizontally scrollable product carousel with built-in data fetching, navigation arrows, and responsive breakpoints. Renders ProductCard and ClusterCard components with full add-to-cart, stock, and favorites support.

Usage

CMS-driven slider -- fetch products by ID

Content editors define which products appear. The component fetches them internally using ProductService.getProducts().

<ProductSlider
graphqlClient={graphqlClient}
productIds={[123, 456, 789]}
clusterIds={[101, 202]}
language="NL"
taxZone="NL"
title="Featured Products"
user={authState.user}
cartId={cart?.cartId}
createCart={true}
onCartCreated={(newCart) => saveCart(newCart)}
afterAddToCart={(updatedCart) => saveCart(updatedCart)}
configuration={config}
onProductClick={(product) => router.push(config.urls.getProductUrl(product))}
onClusterClick={(cluster) => router.push(config.urls.getClusterUrl(cluster))}
/>

Cross-upsell slider on a product detail page

Fetches accessories and related products for a specific product via CrossupsellService.getCrossupsells(). The title auto-generates from the cross-upsell types (e.g., "Accessories & Related products") unless overridden.

<ProductSlider
graphqlClient={graphqlClient}
crossUpsellTypes={['ACCESSORIES', 'RELATED']}
productId={product.productId}
language="NL"
taxZone="NL"
user={authState.user}
cartId={cart?.cartId}
createCart={true}
onCartCreated={(newCart) => saveCart(newCart)}
afterAddToCart={(updatedCart) => saveCart(updatedCart)}
configuration={config}
onProductClick={(product) => router.push(config.urls.getProductUrl(product))}
onClusterClick={(cluster) => router.push(config.urls.getClusterUrl(cluster))}
/>

Cross-upsell slider for a cluster

<ProductSlider
graphqlClient={graphqlClient}
crossUpsellTypes={['ALTERNATIVES']}
clusterId={cluster.clusterId}
language="NL"
taxZone="NL"
configuration={config}
onProductClick={(product) => router.push(config.urls.getProductUrl(product))}
/>

Pre-loaded items (skip fetching)

Pass an array of already-fetched Product or Cluster objects. No API call is made.

<ProductSlider
graphqlClient={graphqlClient}
products={preLoadedProducts}
language="NL"
taxZone="NL"
title="Hand-picked Products"
configuration={config}
onProductClick={(product) => router.push(config.urls.getProductUrl(product))}
/>

Catalog-only mode (no add-to-cart)

Set portalMode="semi-closed" to hide the add-to-cart button on each card.

<ProductSlider
graphqlClient={graphqlClient}
productIds={[10, 20, 30]}
language="NL"
taxZone="NL"
portalMode="semi-closed"
configuration={config}
onProductClick={(product) => router.push(config.urls.getProductUrl(product))}
/>

Custom responsive layout

<ProductSlider
graphqlClient={graphqlClient}
productIds={[10, 20, 30, 40, 50, 60]}
language="NL"
taxZone="NL"
itemsPerView={{ mobile: 2, tablet: 3, desktop: 5 }}
configuration={config}
/>

Configuration

Data Source

PropTypeRequiredDefaultDescription
graphqlClientGraphQLClientYes--Propeller SDK client for API calls.
products(Product | Cluster)[]No[]Pre-loaded items. Skips all internal fetching when provided.
productIdsnumber[]No--Product IDs to fetch (CMS mode).
clusterIdsnumber[]No--Cluster IDs to fetch (CMS mode).
crossUpsellTypesstring[]No--Enables cross-upsell mode. Values: 'ACCESSORIES', 'ALTERNATIVES', 'RELATED', 'OPTIONS', 'PARTS'.
productIdnumberNo--Source product for cross-upsell lookup. Required when crossUpsellTypes is set.
clusterIdnumberNo--Source cluster for cross-upsell lookup. Required when crossUpsellTypes is set.

Locale and Pricing

PropTypeRequiredDefaultDescription
languagestringYes--Language code for API requests and localized content.
taxZonestringYes--Tax zone for price calculations.
includeTaxbooleanNo--Override the VAT toggle. When omitted, follows the price_include_tax localStorage value and priceToggleChanged event.

Layout

PropTypeRequiredDefaultDescription
titlestringNo--Heading displayed above the slider. In cross-upsell mode, auto-generates from the type names if omitted.
itemsPerView{ mobile?: number; tablet?: number; desktop?: number }No{ mobile: 1, tablet: 2, desktop: 4 }Number of visible cards at each breakpoint.
containerClassNamestringNo'mb-12'CSS class for the outermost wrapper.

Portal and Visibility

PropTypeRequiredDefaultDescription
portalMode'open' | 'semi-closed'No'open''open' shows add-to-cart on product cards. 'semi-closed' hides it for a catalog-only view.
userContact | Customer | nullNonullAuthenticated user, forwarded to cards for cart and pricing operations.

Cart Integration

PropTypeRequiredDefaultDescription
cartIdstringNo--Existing cart ID to add items to.
createCartbooleanNofalseAuto-create a cart when none exists. Pair with onCartCreated.
onCartCreated(cart: Cart) => voidNo--Called after a new cart is created internally.
afterAddToCart(cart: Cart, item?: CartMainItem) => voidNo--Called after every successful add-to-cart with the updated cart.
stockValidationbooleanNofalseValidate stock before adding to cart.
showIncrDecrbooleanNotrueShow +/- stepper buttons on add-to-cart.
showModalbooleanNofalseShow a success modal instead of a toast after adding to cart.
onProceedToCheckout() => voidNo--Called when "Proceed to checkout" is clicked in the add-to-cart modal.

Stock Display

PropTypeRequiredDefaultDescription
showStockbooleanNofalseShow the stock/availability widget on each card.
showAvailabilitybooleanNotrueShow only the availability indicator (Available / Not available).
stockLabelsRecord<string, string>No--Label overrides for ItemStock. Keys: inStock, outOfStock, lowStock, available, notAvailable, pieces.

Favorites

PropTypeRequiredDefaultDescription
enableAddFavoritebooleanNofalseShow a heart-icon toggle on each card.
onToggleFavorite(item: Product | Cluster, isFavorite: boolean) => voidNo--Called when a favorite is toggled.
PropTypeRequiredDefaultDescription
onProductClick(product: Product) => voidNo--Called when a product card is clicked. Use for SPA-style routing.
onClusterClick(cluster: Cluster) => voidNo--Called when a cluster card is clicked.

Labels and Configuration

PropTypeRequiredDefaultDescription
configurationanyNo--App config object providing imageSearchFiltersGrid, imageVariantFiltersMedium, and urls for URL generation.
labelsRecord<string, string>No--UI string overrides (see Labels section).
addToCartLabelsRecord<string, string>No--Label overrides forwarded to the AddToCart component inside each card. Keys: add, adding, addedToCart, outOfStock, noCartId, errorAdding, modalTitle, quantity, continueShopping, proceedToCheckout.

Labels

KeyDefaultDescription
scrollLeft'Scroll left'Left arrow aria-label.
scrollRight'Scroll right'Right arrow aria-label.
noProducts'No products found'Empty state message (CMS mode only).
ACCESSORIES'Accessories'Cross-upsell type display name.
ALTERNATIVES'Alternatives'Cross-upsell type display name.
RELATED'Related products'Cross-upsell type display name.
OPTIONS'Options'Cross-upsell type display name.
PARTS'Parts'Cross-upsell type display name.

Behavior

Responsive Card Widths

Card widths are calculated with CSS calc() to fill the container minus gaps:

  • Mobile (default): calc((100% - 1.5rem) / 1.5) -- shows ~1.5 cards, hinting that more content is scrollable.
  • Tablet (md): calc((100% - 3rem) / 2.5) -- shows ~2.5 cards.
  • Desktop (lg): calc((100% - 4.5rem) / 4) -- shows 4 full cards.

The partial-card pattern is intentional: it signals to the user that the row is scrollable.

Scroll Navigation

  • Left/right arrow buttons appear when the number of items exceeds the desktop count.
  • Each click scrolls the track by 80% of its visible width using scrollBy({ behavior: 'smooth' }).
  • Arrows disable automatically at scroll boundaries (left arrow at the start, right arrow at the end).
  • Scroll position is tracked via the native onScroll event.

Loading and Empty States

  • Loading: Displays four animated skeleton cards (gray pulsing rectangles) while fetching.
  • Empty (CMS mode): Shows the noProducts label ("No products found" by default).
  • Empty (cross-upsell mode): The entire component hides itself. This is intentional -- a product may have no cross-upsells, and showing an empty section would be confusing.

Re-fetching

The component re-fetches automatically when any of these props change:

  • productIds or clusterIds (compared by value via JSON.stringify, not by reference — prevents stale-reference refetches when arrays are re-created with the same contents)
  • crossUpsellTypes (compared by value via JSON.stringify)
  • productId or clusterId (cross-upsell source)
  • language

Scroll initialization

The slider generates a unique sliderId on mount (via Math.random()) and uses a data-slider-id DOM attribute to query scroll dimensions. Scroll dimensions are initialized with a 50ms setTimeout after the slider ID is set, to ensure the DOM has fully rendered.

CMS-Driven Content (Strapi)

Add a shared.product-slider component in Strapi with fields for title, productIds (comma-separated text), and clusterIds (comma-separated text). A CMS block wrapper (ProductSliderBlock) parses these fields into number arrays and handles auth, cart, and configuration wiring automatically, so editors only need to enter IDs.

Mixed Content

A single slider can contain both Product and Cluster items. The component detects the item type by checking for clusterId vs productId and renders the appropriate card (ClusterCard or ProductCard).

VAT Toggle

Each card respects the global VAT toggle. The includeTax prop overrides the toggle when set explicitly. When omitted, cards read from localStorage (price_include_tax) and listen for the priceToggleChanged custom event.

SDK Services

ProductService -- CMS mode

When productIds or clusterIds are provided (and products is not), the component calls ProductService.getProducts() internally:

const productService = new ProductService(graphqlClient);
const response = await productService.getProducts({
input: {
productIds: [123, 456],
clusterIds: [101],
language: 'NL',
page: 1,
offset: 50,
statuses: [
Enums.ProductStatus.A, // Active
Enums.ProductStatus.P, // Published
Enums.ProductStatus.T, // Temporary
Enums.ProductStatus.S, // Stock
],
},
imageSearchFilters: configuration?.imageSearchFiltersGrid || { page: 1, offset: 1 },
imageVariantFilters: configuration?.imageVariantFiltersMedium || {
transformations: [{
name: 'grid',
transformation: {
format: Enums.Format.WEBP,
height: 300,
width: 300,
fit: Enums.Fit.BOUNDS,
},
}],
},
filterAvailableAttributeInput: { isSearchable: true },
});

The response shape is { items: (Product | Cluster)[] }. Both products and clusters can appear in a single response.

CrossupsellService -- cross-upsell mode

When crossUpsellTypes is set along with productId or clusterId, the component calls CrossupsellService.getCrossupsells():

const crossupsellService = new CrossupsellService(graphqlClient);
const result = await crossupsellService.getCrossupsells({
input: {
types: ['ACCESSORIES', 'RELATED'],
page: 1,
offset: 50,
productIdsFrom: [product.productId],
// or: clusterIdsFrom: [cluster.clusterId],
},
language: 'NL',
imageSearchFilters: configuration?.imageSearchFiltersGrid,
imageVariantFilters: configuration?.imageVariantFiltersMedium,
priceCalculateProductInput: {
taxZone: 'NL',
companyId: user?.company?.companyId, // B2B Contact
contactId: user?.contactId, // B2B Contact
customerId: user?.customerId, // B2C Customer
},
});

The response contains { items: Crossupsell[] }. Each Crossupsell has either a productTo or clusterTo field referencing the related item.

Known limitation: CrossupsellService.getCrossupsells() has a known SDK bug where undeclared fragment variables cause an HTTP 400. Cross-upsell results may not display until the SDK is fixed. The error is caught silently.

GraphQL Queries and Mutations

Fetch products by ID (what ProductService.getProducts sends)

query products(
$input: ProductSearchInput!
$imageSearchFilters: ProductImageSearchInput
$imageVariantFilters: ProductImageVariantSearchInput
) {
products(input: $input) {
items {
productId
sku
name { value language }
slug { value language }
price { net gross }
media(input: $imageSearchFilters) {
images {
url
variants(input: $imageVariantFilters) { url }
}
}
}
itemsFound
}
}

Variables:

{
"input": {
"productIds": [123, 456],
"language": "NL",
"page": 1,
"offset": 50,
"statuses": ["A", "P", "T", "S"]
},
"imageSearchFilters": { "page": 1, "offset": 1 },
"imageVariantFilters": {
"transformations": [{
"name": "grid",
"transformation": { "format": "WEBP", "height": 300, "width": 300, "fit": "BOUNDS" }
}]
}
}

Fetch cross-upsells for a product

query crossupsells(
$input: CrossupsellSearchInput!
$language: String
$imageSearchFilters: ProductImageSearchInput
$imageVariantFilters: ProductImageVariantSearchInput
) {
crossupsells(input: $input) {
items {
type
productTo {
productId
name(language: $language) { value }
slug(language: $language) { value }
price { net gross }
}
clusterTo {
clusterId
name(language: $language) { value }
slug(language: $language) { value }
}
}
}
}

Variables:

{
"input": {
"types": ["ACCESSORIES", "RELATED"],
"productIdsFrom": [123],
"page": 1,
"offset": 50
},
"language": "NL"
}