Skip to main content

Product Variant Selector

A headless, flexible product variant selector component for e-commerce storefronts. Supports multiple UI patterns (pills, dropdowns, radio buttons), option availability tracking, and variant selection.

Purpose

This component provides a headless solution for product variant selection in e-commerce applications. It handles the complex logic of variant matching, option availability calculation, and state management while allowing complete control over the UI presentation. It should be used when building product detail pages that need to handle products with multiple variants defined by option groups (e.g., Size, Color).

Features

  • Product option selection with automatic variant matching
  • Variant availability checking based on current selections
  • Automatic filtering of unavailable options
  • Option group management for hierarchical variant structures
  • Selection state persistence across option groups
  • Flexible option rendering (pills, dropdowns, radio buttons)
  • Automatic correction of invalid selections
  • Event bus integration for variant selection changes

Installation

npm install @haus-storefront-react/product-variant-selector

Note: This is not a public package. Contact the Haus Tech Team for access.

API Reference

ProductVariantSelector.Root

Context provider for product variant selection functionality. Manages product data fetching, option group processing, and variant matching logic. Returns null if the product has no option groups.

Props

PropTypeRequiredDefaultDescription
productIdstringNo-Product ID for fetching variant data
productSlugstringNo-Product slug (alternative to productId)
initialVariantIdstringNo-Initial selected variant ID
autoSelectOnInvalidOptionbooleanNotrueAuto-select first available variant when current selection becomes invalid
hideUnavailableOptionsbooleanNotrueHide options that are not available with current selection
strategiesProductVariantSelectorStrategiesNo-Optional store strategy (e.g. productVariantStoreStrategy) to persist variant selection
children(context: RootContext) => ReactNodeYes-Render prop with variant selector context

Context

interface RootContext {
optionGroups: ProductOptionGroup[]
selectedVariant: ProductVariant | undefined
}

ProductVariantSelector.OptionGroup

Wraps a group of related options (e.g., "Size", "Color"). Provides selection functionality and filtered options based on current selections.

Props

PropTypeRequiredDefaultDescription
optionGroupProductOptionGroupYes-The option group object
asChildbooleanNofalseRender as child component
children(context: OptionGroupContext) => ReactNodeYes-Render prop with option group context

Context

interface OptionGroupContext {
options: ProductOption[]
selectOption: (optionId: string) => void
selectedOptionId?: string
}

ProductVariantSelector.Option

Represents a single option within an option group. Provides availability status and selection state. Returns null if the option is not found in any option group.

Props

PropTypeRequiredDefaultDescription
optionIdstringYes-The option ID
asChildbooleanNofalseRender as child component (useful for native HTML elements like <option>)
children(context: OptionContext) => ReactNodeYes-Render prop with option context

Context

interface OptionContext {
option: ProductOption
selectOption: (optionId: string) => void
isSelected: boolean
isAvailable: boolean
}

useProductVariantSelector

Manages product variant selection logic, option availability calculation, and variant matching. Handles automatic selection of initial variants and correction of invalid selections.

Parameters

ParameterTypeRequiredDescription
optionsUseProductVariantSelectorPropsYesConfiguration object

Options Object

interface UseProductVariantSelectorProps {
productId?: string
productSlug?: string
initialVariantId?: string
autoSelectOnInvalidOption?: boolean
hideUnavailableOptions?: boolean
strategies?: ProductVariantSelectorStrategies
}

Returns

Return ValueTypeDescription
productProduct | undefinedThe loaded product data or undefined while loading
productOptionGroupsProductOptionGroup[]Processed and filtered option groups
selectedVariantOptionsVariantOptionsCurrent selected options per option group
availableOptionsPerGroupRecord<string, string[]>Available option IDs per option group code
selectedVariantProductVariant | undefinedThe currently selected variant or undefined if selection is incomplete
handleVariantOptionChange(value: string, optionGroup: ProductOptionGroup) => voidFunction to handle option selection changes
setVariant(variantId: string) => voidFunction to directly set a variant by ID

Persisting variant selection (e.g. URL sync)

To persist the selected variant (e.g. for shareable URLs), pass a ProductVariantStoreStrategy via the strategies prop (ProductVariantSelectorStrategies). The @haus-storefront-react/strategies package provides DefaultProductVariantUrlStrategy, which syncs to the URL:

import { DefaultProductVariantUrlStrategy } from '@haus-storefront-react/strategies'
import { ProductVariantSelector } from '@haus-storefront-react/product-variant-selector'

<ProductVariantSelector.Root
productSlug="my-product"
strategies={{ productVariantStoreStrategy: new DefaultProductVariantUrlStrategy() }}
>
{({ optionGroups, selectedVariant }) => (
// ... render option groups and selected variant
)}
</ProductVariantSelector.Root>
  • URL implementation: One query parameter per option group — param name is the option group code, value is the option ID (e.g. ?size=opt-1&color=opt-2).
  • When the URL is updated: Only after the user has actively changed a variant (e.g. by clicking an option). The initial or default selection on first page load is not written. Once the user has interacted, subsequent changes (including switching back to the default variant) are synced.
  • Invalid or unknown option IDs in the URL are safely ignored.

Basic Usage

Simple Component with Pills

import { ProductVariantSelector } from '@haus-storefront-react/product-variant-selector'

function ProductDetail() {
const productId = '123'
const initialVariantId = 'variant-456'

return (
<ProductVariantSelector.Root
productId={productId}
initialVariantId={initialVariantId}
>
{({ optionGroups, selectedVariant }) => (
<div>
{optionGroups.map((optionGroup) => (
<ProductVariantSelector.OptionGroup
key={optionGroup.code}
optionGroup={optionGroup}
>
{({ options, selectOption, selectedOptionId }) => (
<div>
<label>{optionGroup.name}</label>
<div style={{ display: 'flex', gap: '8px' }}>
{options.map((option) => (
<ProductVariantSelector.Option
key={option.id}
optionId={option.id}
>
{({
option: optionData,
selectOption,
isSelected,
isAvailable,
}) => (
<button
onClick={() => selectOption(optionData.id)}
disabled={!isAvailable}
style={{
padding: '8px 16px',
borderRadius: '21px',
border: isSelected
? '2px solid #2c5282'
: '1px solid #ccc',
backgroundColor: isSelected ? '#2c5282' : 'white',
color: isSelected ? 'white' : '#333',
opacity: isAvailable ? 1 : 0.5,
cursor: isAvailable ? 'pointer' : 'not-allowed',
}}
>
{optionData.name}
</button>
)}
</ProductVariantSelector.Option>
))}
</div>
</div>
)}
</ProductVariantSelector.OptionGroup>
))}
{selectedVariant && <div>Selected: {selectedVariant.name}</div>}
</div>
)}
</ProductVariantSelector.Root>
)
}

Basic Hook Usage

import { useProductVariantSelector } from '@haus-storefront-react/product-variant-selector'

function ProductVariantSelector() {
const { product, productOptionGroups, selectedVariant } =
useProductVariantSelector({
productId: '123',
})

if (!product) return <div>Product not found</div>

return (
<div>
{productOptionGroups.map((group) => (
<div key={group.code}>{group.name}</div>
))}
{selectedVariant && <div>Selected: {selectedVariant.name}</div>}
</div>
)
}

Advanced Usage

Complex Component Configuration

import { ProductVariantSelector } from '@haus-storefront-react/product-variant-selector'
import type {
ProductOptionGroup,
ProductVariant,
} from '@haus-storefront-react/shared-types'

function AdvancedProductDetail() {
const productId = '123'
const [priceDisplay, setPriceDisplay] = React.useState<string>('')

const handleVariantChange = (variant: ProductVariant | undefined) => {
if (variant) {
setPriceDisplay(`${variant.currencyCode} ${variant.priceWithTax}`)
}
}

return (
<ProductVariantSelector.Root
productId={productId}
autoSelectOnInvalidOption={true}
hideUnavailableOptions={true}
>
{({ optionGroups, selectedVariant }) => {
React.useEffect(() => {
handleVariantChange(selectedVariant)
}, [selectedVariant])

return (
<div>
<h2>Select Options</h2>
{optionGroups.map((optionGroup) => (
<ProductVariantSelector.OptionGroup
key={optionGroup.code}
optionGroup={optionGroup}
>
{({ options, selectOption, selectedOptionId }) => (
<div>
<label>{optionGroup.name}</label>
<div className='option-grid'>
{options.map((option) => (
<ProductVariantSelector.Option
key={option.id}
optionId={option.id}
>
{({
option: optionData,
selectOption,
isSelected,
isAvailable,
}) => (
<button
onClick={() => selectOption(optionData.id)}
disabled={!isAvailable}
className={`option-pill ${
isSelected ? 'selected' : ''
} ${isAvailable ? '' : 'unavailable'}`}
>
{optionData.name}
</button>
)}
</ProductVariantSelector.Option>
))}
</div>
</div>
)}
</ProductVariantSelector.OptionGroup>
))}
{selectedVariant && (
<div className='variant-info'>
<div>Price: {priceDisplay}</div>
<div>SKU: {selectedVariant.sku}</div>
<div>Stock: {selectedVariant.stockLevel}</div>
</div>
)}
</div>
)
}}
</ProductVariantSelector.Root>
)
}

Conditional Rendering Patterns

import { ProductVariantSelector } from '@haus-storefront-react/product-variant-selector'

function ConditionalProductDetail() {
const productId = '123'

return (
<ProductVariantSelector.Root productId={productId}>
{({ optionGroups, selectedVariant }) => {
if (optionGroups.length === 0) {
return <div>This product has no variants</div>
}

if (!selectedVariant) {
return (
<div>
<p>Please select all options</p>
{/* Render option groups */}
</div>
)
}

return (
<div>
{optionGroups.map((optionGroup) => (
<ProductVariantSelector.OptionGroup
key={optionGroup.code}
optionGroup={optionGroup}
>
{({ options, selectOption }) => (
<div>
<label>{optionGroup.name}</label>
{options.map((option) => (
<ProductVariantSelector.Option
key={option.id}
optionId={option.id}
>
{({
option: optionData,
selectOption,
isSelected,
isAvailable,
}) => (
<button
onClick={() => selectOption(optionData.id)}
disabled={!isAvailable}
>
{optionData.name}
</button>
)}
</ProductVariantSelector.Option>
))}
</div>
)}
</ProductVariantSelector.OptionGroup>
))}
<div>
<h3>Selected Variant</h3>
<div>Name: {selectedVariant.name}</div>
<div>Price: {selectedVariant.priceWithTax}</div>
</div>
</div>
)
}}
</ProductVariantSelector.Root>
)
}

Made with ❤️ by Haus Tech Team