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
- Yarn
npm install @haus-storefront-react/product-variant-selector
yarn add @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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
productId | string | No | - | Product ID for fetching variant data |
productSlug | string | No | - | Product slug (alternative to productId) |
initialVariantId | string | No | - | Initial selected variant ID |
autoSelectOnInvalidOption | boolean | No | true | Auto-select first available variant when current selection becomes invalid |
hideUnavailableOptions | boolean | No | true | Hide options that are not available with current selection |
strategies | ProductVariantSelectorStrategies | No | - | Optional store strategy (e.g. productVariantStoreStrategy) to persist variant selection |
children | (context: RootContext) => ReactNode | Yes | - | 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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
optionGroup | ProductOptionGroup | Yes | - | The option group object |
asChild | boolean | No | false | Render as child component |
children | (context: OptionGroupContext) => ReactNode | Yes | - | 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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
optionId | string | Yes | - | The option ID |
asChild | boolean | No | false | Render as child component (useful for native HTML elements like <option>) |
children | (context: OptionContext) => ReactNode | Yes | - | 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
| Parameter | Type | Required | Description |
|---|---|---|---|
options | UseProductVariantSelectorProps | Yes | Configuration object |
Options Object
interface UseProductVariantSelectorProps {
productId?: string
productSlug?: string
initialVariantId?: string
autoSelectOnInvalidOption?: boolean
hideUnavailableOptions?: boolean
strategies?: ProductVariantSelectorStrategies
}
Returns
| Return Value | Type | Description |
|---|---|---|
product | Product | undefined | The loaded product data or undefined while loading |
productOptionGroups | ProductOptionGroup[] | Processed and filtered option groups |
selectedVariantOptions | VariantOptions | Current selected options per option group |
availableOptionsPerGroup | Record<string, string[]> | Available option IDs per option group code |
selectedVariant | ProductVariant | undefined | The currently selected variant or undefined if selection is incomplete |
handleVariantOptionChange | (value: string, optionGroup: ProductOptionGroup) => void | Function to handle option selection changes |
setVariant | (variantId: string) => void | Function 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
- React
- React Native
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>
)
}
import { Text, Pressable } from 'react-native'
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 }) => (
<>
{optionGroups.map((optionGroup) => (
<ProductVariantSelector.OptionGroup
key={optionGroup.code}
optionGroup={optionGroup}
>
{({ options, selectOption, selectedOptionId }) => (
<>
<Text>{optionGroup.name}</Text>
{options.map((option) => (
<ProductVariantSelector.Option
key={option.id}
optionId={option.id}
>
{({
option: optionData,
selectOption,
isSelected,
isAvailable,
}) => (
<Pressable
onPress={() => selectOption(optionData.id)}
disabled={!isAvailable}
>
<Text>{optionData.name}</Text>
</Pressable>
)}
</ProductVariantSelector.Option>
))}
</>
)}
</ProductVariantSelector.OptionGroup>
))}
{selectedVariant && <Text>Selected: {selectedVariant.name}</Text>}
</>
)}
</ProductVariantSelector.Root>
)
}
Basic Hook Usage
- React
- React Native
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>
)
}
import { Text } from 'react-native'
import { useProductVariantSelector } from '@haus-storefront-react/product-variant-selector'
function ProductVariantSelector() {
const { product, productOptionGroups, selectedVariant } =
useProductVariantSelector({
productId: '123',
})
if (!product) return <Text>Product not found</Text>
return (
<>
{productOptionGroups.map((group) => (
<Text key={group.code}>{group.name}</Text>
))}
{selectedVariant && <Text>Selected: {selectedVariant.name}</Text>}
</>
)
}
Advanced Usage
Complex Component Configuration
- React
- React Native
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>
)
}
import { View, Text, Pressable } from 'react-native'
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 (
<View>
<Text>Select Options</Text>
{optionGroups.map((optionGroup) => (
<ProductVariantSelector.OptionGroup
key={optionGroup.code}
optionGroup={optionGroup}
>
{({ options, selectOption, selectedOptionId }) => (
<View>
<Text>{optionGroup.name}</Text>
<View>
{options.map((option) => (
<ProductVariantSelector.Option
key={option.id}
optionId={option.id}
>
{({
option: optionData,
selectOption,
isSelected,
isAvailable,
}) => (
<Pressable
onPress={() => selectOption(optionData.id)}
disabled={!isAvailable}
>
<Text>{optionData.name}</Text>
</Pressable>
)}
</ProductVariantSelector.Option>
))}
</View>
</View>
)}
</ProductVariantSelector.OptionGroup>
))}
{selectedVariant && (
<View>
<Text>Price: {priceDisplay}</Text>
<Text>SKU: {selectedVariant.sku}</Text>
<Text>Stock: {selectedVariant.stockLevel}</Text>
</View>
)}
</View>
)
}}
</ProductVariantSelector.Root>
)
}
Conditional Rendering Patterns
- React
- React Native
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>
)
}
import { View, Text, Pressable } from 'react-native'
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 <Text>This product has no variants</Text>
}
if (!selectedVariant) {
return (
<View>
<Text>Please select all options</Text>
{/* Render option groups */}
</View>
)
}
return (
<View>
{optionGroups.map((optionGroup) => (
<ProductVariantSelector.OptionGroup
key={optionGroup.code}
optionGroup={optionGroup}
>
{({ options, selectOption }) => (
<View>
<Text>{optionGroup.name}</Text>
{options.map((option) => (
<ProductVariantSelector.Option
key={option.id}
optionId={option.id}
>
{({
option: optionData,
selectOption,
isSelected,
isAvailable,
}) => (
<Pressable
onPress={() => selectOption(optionData.id)}
disabled={!isAvailable}
>
<Text>{optionData.name}</Text>
</Pressable>
)}
</ProductVariantSelector.Option>
))}
</View>
)}
</ProductVariantSelector.OptionGroup>
))}
<View>
<Text>Selected Variant</Text>
<Text>Name: {selectedVariant.name}</Text>
<Text>Price: {selectedVariant.priceWithTax}</Text>
</View>
</View>
)
}}
</ProductVariantSelector.Root>
)
}
Made with ❤️ by Haus Tech Team