Product Page Components Advanced 15 min read

Variant Selector: Options and Availability States

Build a variant selector that handles multiple options, shows availability states, and updates prices dynamically. Handle sold-out combinations gracefully.

The variant selector is one of the most complex UI components in e-commerce. It must handle multiple options, show availability for each combination, and provide clear feedback to users.

Theme Integration

This component is part of the product page component hierarchy:

sections/product-main.liquid
└── <div id="product-page-root">
└── ProductPage (React)
└── VariantSelector ← You are here
├── OptionGroup
├── ColorSwatch
└── SizeButton

See Product Page Architecture for the complete Liquid section setup, including variant and option data serialization.

Data Source

Prop/StateSourceLiquid Field
optionsParent (ProductPage)product.options_with_values
selectedOptionsLocal stateInitialized from URL or first available
variantsParent (ProductPage)product.variants
isOptionAvailable()DerivedChecks variant.available for matching variants

Data Flow: Variant Selection

1. INITIAL DATA (from Liquid)
┌─────────────────────────────────────────────────────────┐
│ sections/product-main.liquid │
│ └── product.options_with_values → options[] │
│ └── product.variants → variants[] with availability │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ProductPage reads JSON, passes to VariantSelector │
│ └── selectedOptions initialized (URL param or default) │
└─────────────────────────────────────────────────────────┘
2. USER SELECTS OPTION
┌─────────────────────────────────────────────────────────┐
│ OptionGroup → onSelectOption(optionName, value) │
│ └── Updates selectedOptions state │
│ └── Finds matching variant from variants[] │
└─────────────────────────────────────────────────────────┘
3. AVAILABILITY CHECK
┌─────────────────────────────────────────────────────────┐
│ isOptionAvailable(optionName, value) │
│ └── For each option value, check if ANY variant with │
│ current selections + this value is available │
│ └── Returns false if all matching variants sold out │
└─────────────────────────────────────────────────────────┘
4. UI UPDATES
┌─────────────────────────────────────────────────────────┐
│ VariantSelector re-renders │
│ └── Unavailable options show strikethrough │
│ └── Price updates to selected variant price │
│ └── URL updates with ?variant={id} │
└─────────────────────────────────────────────────────────┘

Variant Selection Logic

When a user selects an option, we need to:

  1. Update the selected option value
  2. Find the matching variant
  3. Check if that variant is available
  4. Show availability for other options based on current selection
Product: T-Shirt
Options: Size (S, M, L, XL), Color (Black, White, Red)
Selected: Size = M, Color = ?
Available variants:
- M/Black ✓
- M/White ✗ (sold out)
- M/Red ✓
Show: Black ✓, White ✗ (strikethrough), Red ✓

Variant Selector Component

src/components/product/VariantSelector/VariantSelector.tsx
import type { ProductOption } from '@/types/product';
import { OptionGroup } from './OptionGroup';
import styles from './VariantSelector.module.css';
interface VariantSelectorProps {
options: ProductOption[]; // All product options (Size, Color, etc.).
selectedOptions: Record<string, string>; // Current selections by option name.
onSelectOption: (optionName: string, value: string) => void; // Called when user selects an option.
isOptionAvailable: (optionName: string, value: string) => boolean; // Checks if option leads to available variant.
}
/**
* VariantSelector renders all product options as selectable groups.
* Delegates to OptionGroup for the actual UI (buttons, swatches, dropdowns).
*/
export function VariantSelector({
options,
selectedOptions,
onSelectOption,
isOptionAvailable,
}: VariantSelectorProps) {
return (
<div className={styles.selector}>
{/* Render an OptionGroup for each product option */}
{options.map((option) => (
<OptionGroup
key={option.name}
option={option}
selectedValue={selectedOptions[option.name]}
onSelect={(value) => onSelectOption(option.name, value)}
// Pass availability checker scoped to this option.
isValueAvailable={(value) => isOptionAvailable(option.name, value)}
/>
))}
</div>
);
}

Option Group Component

src/components/product/VariantSelector/OptionGroup.tsx
import type { ProductOption } from '@/types/product';
import { ColorSwatch } from './ColorSwatch';
import { SizeButton } from './SizeButton';
import styles from './OptionGroup.module.css';
interface OptionGroupProps {
option: ProductOption; // The option data (name, values).
selectedValue: string; // Currently selected value.
onSelect: (value: string) => void; // Called when user selects a value.
isValueAvailable: (value: string) => boolean; // Checks availability for each value.
}
/**
* OptionGroup renders a single option (like Size or Color) with all its values.
* Uses ColorSwatch for color options, SizeButton for others.
*/
export function OptionGroup({
option,
selectedValue,
onSelect,
isValueAvailable,
}: OptionGroupProps) {
// Determine if this is a color option (uses swatches instead of buttons).
const isColorOption = option.name.toLowerCase() === 'color';
return (
<fieldset className={styles.group}>
{/* Legend shows option name and current selection */}
<legend className={styles.legend}>
{option.name}: <span className={styles.selected}>{selectedValue}</span>
</legend>
<div className={`${styles.options} ${isColorOption ? styles.colors : ''}`}>
{option.values.map((value) => {
const isSelected = value === selectedValue;
const isAvailable = isValueAvailable(value); // Check if this leads to available variant.
// Render color swatches for color options.
if (isColorOption) {
return (
<ColorSwatch
key={value}
color={value}
isSelected={isSelected}
isAvailable={isAvailable}
onSelect={() => onSelect(value)}
/>
);
}
// Render standard buttons for other options (Size, Material, etc.).
return (
<SizeButton
key={value}
value={value}
isSelected={isSelected}
isAvailable={isAvailable}
onSelect={() => onSelect(value)}
/>
);
})}
</div>
</fieldset>
);
}
src/components/product/VariantSelector/OptionGroup.module.css
.group {
border: none;
padding: 0;
margin: 0 0 1.5rem;
}
.legend {
display: block;
margin-bottom: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
}
.selected {
font-weight: 400;
color: var(--color-text-muted);
}
.options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.colors {
gap: 0.75rem;
}

Color Swatch Component

src/components/product/VariantSelector/ColorSwatch.tsx
import styles from './ColorSwatch.module.css';
interface ColorSwatchProps {
color: string; // Color name (e.g., "Blue").
isSelected: boolean; // Whether this color is currently selected.
isAvailable: boolean; // Whether this color leads to an available variant.
onSelect: () => void; // Called when user clicks the swatch.
}
/**
* ColorSwatch renders a circular color button for variant selection.
* Shows actual color value with visual indicators for selection and availability.
*/
export function ColorSwatch({ color, isSelected, isAvailable, onSelect }: ColorSwatchProps) {
// Convert color name to CSS color value.
const colorValue = getColorValue(color);
return (
<button
type="button"
className={`
${styles.swatch}
${isSelected ? styles.selected : ''}
${!isAvailable ? styles.unavailable : ''}
`}
onClick={onSelect}
aria-label={`${color}${!isAvailable ? ' (Sold out)' : ''}`} // Accessibility: announce availability.
aria-pressed={isSelected} // Accessibility: communicate selection state.
title={color} // Tooltip shows color name.
>
{/* Inner circle with actual color */}
<span className={styles.color} style={{ backgroundColor: colorValue }} />
{/* Diagonal strikethrough overlay for sold-out colors */}
{!isAvailable && <span className={styles.strikethrough} />}
</button>
);
}
/**
* Maps color names to CSS color values.
* Extend this map with your store's specific color naming conventions.
*/
function getColorValue(colorName: string): string {
const colorMap: Record<string, string> = {
black: '#000000',
white: '#ffffff',
red: '#dc2626',
blue: '#2563eb',
green: '#16a34a',
yellow: '#eab308',
orange: '#ea580c',
purple: '#9333ea',
pink: '#ec4899',
gray: '#6b7280',
grey: '#6b7280',
navy: '#1e3a5f',
beige: '#d4c4a8',
brown: '#78350f',
cream: '#fffdd0',
tan: '#d2b48c',
coral: '#ff7f50',
teal: '#008080',
mint: '#98fb98',
lavender: '#e6e6fa',
};
// Normalize: lowercase and remove spaces.
const normalized = colorName.toLowerCase().replace(/\s+/g, '');
return colorMap[normalized] || colorName; // Fallback to original if not in map.
}
src/components/product/VariantSelector/ColorSwatch.module.css
.swatch {
position: relative;
width: 32px;
height: 32px;
padding: 3px;
border: 2px solid transparent;
border-radius: 50%;
background: transparent;
cursor: pointer;
transition: border-color 0.15s ease;
}
.swatch:hover:not(.unavailable) {
border-color: var(--color-border);
}
.swatch.selected {
border-color: var(--color-text);
}
.swatch.unavailable {
opacity: 0.5;
cursor: not-allowed;
}
.color {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.strikethrough {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.strikethrough::after {
content: '';
width: 100%;
height: 2px;
background: var(--color-text);
transform: rotate(-45deg);
}

Size Button Component

src/components/product/VariantSelector/SizeButton.tsx
import styles from './SizeButton.module.css';
interface SizeButtonProps {
value: string; // Option value to display (e.g., "M", "Large").
isSelected: boolean; // Whether this value is currently selected.
isAvailable: boolean; // Whether this value leads to an available variant.
onSelect: () => void; // Called when user clicks the button.
}
/**
* SizeButton renders a rectangular button for option selection.
* Used for Size, Material, and other non-color options.
*/
export function SizeButton({ value, isSelected, isAvailable, onSelect }: SizeButtonProps) {
return (
<button
type="button"
className={`
${styles.button}
${isSelected ? styles.selected : ''}
${!isAvailable ? styles.unavailable : ''}
`}
onClick={onSelect}
aria-label={`${value}${!isAvailable ? ' (Sold out)' : ''}`} // Accessibility: announce availability.
aria-pressed={isSelected} // Accessibility: communicate selection state.
>
{value}
{/* Strikethrough line for unavailable options */}
{!isAvailable && <span className={styles.strikethrough} />}
</button>
);
}
src/components/product/VariantSelector/SizeButton.module.css
.button {
position: relative;
min-width: 48px;
padding: 0.625rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.button:hover:not(.unavailable):not(.selected) {
border-color: var(--color-text);
}
.button.selected {
border-color: var(--color-text);
background: var(--color-text);
color: var(--color-background);
}
.button.unavailable {
color: var(--color-text-muted);
cursor: not-allowed;
}
.button.unavailable:not(.selected) {
border-style: dashed;
}
.strikethrough {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.strikethrough::after {
content: '';
width: 100%;
height: 1px;
background: currentColor;
transform: rotate(-10deg);
}

For many options, use a dropdown instead:

src/components/product/VariantSelector/OptionDropdown.tsx
import { useState, useRef, useEffect } from 'react';
import type { ProductOption } from '@/types/product';
import styles from './OptionDropdown.module.css';
interface OptionDropdownProps {
option: ProductOption;
selectedValue: string;
onSelect: (value: string) => void;
isValueAvailable: (value: string) => boolean;
}
export function OptionDropdown({
option,
selectedValue,
onSelect,
isValueAvailable,
}: OptionDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div ref={dropdownRef} className={styles.dropdown}>
<label className={styles.label}>{option.name}</label>
<button
type="button"
className={styles.trigger}
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span>{selectedValue}</span>
<ChevronIcon />
</button>
{isOpen && (
<ul className={styles.menu} role="listbox">
{option.values.map((value) => {
const isAvailable = isValueAvailable(value);
return (
<li key={value} role="option" aria-selected={value === selectedValue}>
<button
type="button"
className={`
${styles.option}
${value === selectedValue ? styles.selected : ''}
${!isAvailable ? styles.unavailable : ''}
`}
onClick={() => {
onSelect(value);
setIsOpen(false);
}}
disabled={!isAvailable}
>
{value}
{!isAvailable && <span className={styles.soldOut}>(Sold out)</span>}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function ChevronIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 6l4 4 4-4" />
</svg>
);
}

Stock Indicator

Show remaining stock for the selected variant:

src/components/product/VariantSelector/StockIndicator.tsx
import type { ProductVariant } from '@/types/product';
import styles from './StockIndicator.module.css';
interface StockIndicatorProps {
variant: ProductVariant | null; // Currently selected variant.
lowStockThreshold?: number; // Quantity at which to show "low stock" warning.
}
/**
* StockIndicator displays inventory status for the selected variant.
* Shows "In Stock", "Only X left", or "Out of Stock" based on availability.
*/
export function StockIndicator({ variant, lowStockThreshold = 5 }: StockIndicatorProps) {
// Don't render if no variant is selected.
if (!variant) return null;
// Out of stock state.
if (!variant.available) {
return <div className={`${styles.indicator} ${styles.out}`}>Out of Stock</div>;
}
const quantity = variant.inventoryQuantity;
// Low stock warning when below threshold.
if (quantity !== null && quantity <= lowStockThreshold) {
return (
<div className={`${styles.indicator} ${styles.low}`}>
Only {quantity} left in stock
</div>
);
}
// Default: in stock.
return <div className={`${styles.indicator} ${styles.in}`}>In Stock</div>;
}
src/components/product/VariantSelector/StockIndicator.module.css
.indicator {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 4px;
}
.in {
color: var(--color-success);
background: rgba(var(--color-success-rgb), 0.1);
}
.low {
color: var(--color-warning);
background: rgba(var(--color-warning-rgb), 0.1);
}
.out {
color: var(--color-error);
background: rgba(var(--color-error-rgb), 0.1);
}

Key Takeaways

  1. Option-based selection: Select option values, find matching variant
  2. Availability per option: Check if each option leads to available variant
  3. Visual feedback: Strikethrough for sold-out options
  4. Color swatches: Use actual colors for color options
  5. Dropdown fallback: Use dropdown for options with many values
  6. Stock indicator: Show remaining inventory when low
  7. Accessible: Proper ARIA labels and keyboard support

In the next lesson, we’ll build the Add to Cart form.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...