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 └── SizeButtonSee Product Page Architecture for the complete Liquid section setup, including variant and option data serialization.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
options | Parent (ProductPage) | product.options_with_values |
selectedOptions | Local state | Initialized from URL or first available |
variants | Parent (ProductPage) | product.variants |
isOptionAvailable() | Derived | Checks 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:
- Update the selected option value
- Find the matching variant
- Check if that variant is available
- Show availability for other options based on current selection
Product: T-ShirtOptions: 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
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
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> );}.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
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.}.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
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> );}.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);}Dropdown Variant Selector
For many options, use a dropdown instead:
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:
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>;}.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
- Option-based selection: Select option values, find matching variant
- Availability per option: Check if each option leads to available variant
- Visual feedback: Strikethrough for sold-out options
- Color swatches: Use actual colors for color options
- Dropdown fallback: Use dropdown for options with many values
- Stock indicator: Show remaining inventory when low
- 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...