Bundle Selection Patterns
Explore different patterns for implementing bundle component selection, each optimized for different use cases and user experiences. Choose and implement the best pattern for your storefront's needs.
Quick Start
Choose the right bundle selection pattern for your storefront:
Pattern Overview
- Checkbox Grid - Visual grid with images
- Step-by-Step Wizard - Guided configuration
- Accordion View - Expandable sections
- Visual Builder - Drag-and-drop interface
- Quick Configure Modal - For product listings
Choosing the Right Pattern
Pattern | Best For | Implementation Complexity |
---|---|---|
Checkbox Grid | Visual products, quick selection | 🟢 Simple |
Step-by-Step Wizard | Complex bundles, first-time users | 🟡 Moderate |
Accordion View | Many components, limited space | 🟢 Simple |
Visual Builder | Customizable products, engagement | 🔴 Complex |
Quick Configure Modal | Product lists, quick add | 🟢 Simple |
Pattern 1: Checkbox Grid
Overview
Display all component options in a visual grid with checkboxes. This pattern works best for bundles with visual products where shoppers benefit from seeing all options at once.
When to Use
- Products with strong visual differentiation
- Limited number of components (2-5)
- Quick decision making is important
- Mobile-first experiences
Implementation
function CheckboxGridPattern({ bundle, componentProducts, componentImages }) {
const [selectedOptions, setSelectedOptions] = useState({});
const components = bundle.attributes?.components || {};
return Object.entries(components).map(([componentKey, component]) => {
const isRequired = component.min && component.min > 0;
const selectedCount = Object.keys(selectedOptions[componentKey] || {}).length;
const reachedMax = component.max && selectedCount >= component.max;
return (
<section key={componentKey}>
{/* Component header with requirements */}
<h3>{component.name} {isRequired && '*'}</h3>
<p>{getSelectionHint(component)}</p>
{/* Option grid */}
{component.options.map(option => {
const product = componentProducts.find(p => p.id === option.id);
const isSelected = selectedOptions[componentKey]?.[option.id];
const isDisabled = !isSelected && reachedMax;
return (
<label key={option.id}>
<input
type="checkbox"
checked={isSelected}
disabled={isDisabled}
onChange={(e) => handleOptionToggle(
componentKey, option.id, e.target.checked, component
)}
/>
{/* Product image */}
{renderProductImage(product, componentImages[option.id])}
{/* Product details */}
<h4>{product?.attributes?.name}</h4>
<p>{product?.meta?.display_price?.without_tax?.formatted}</p>
</label>
);
})}
</section>
);
});
}
Smart Selection Behavior (Auto-Replacement)
When maximum selections are reached, clicking a new option automatically deselects the oldest selection:
function handleOptionToggle(componentKey, optionId, checked, component) {
const currentSelections = selectedOptions[componentKey] || {};
const selectedCount = Object.keys(currentSelections).length;
if (checked && component.max && selectedCount >= component.max) {
// Auto-replacement behavior: Remove the first (oldest) selection
const updatedSelections = { ...currentSelections };
const oldestKey = Object.keys(updatedSelections)[0];
delete updatedSelections[oldestKey];
// Add the new selection
updatedSelections[optionId] = 1;
setSelectedOptions(prev => ({
...prev,
[componentKey]: updatedSelections
}));
} else if (checked) {
// Normal selection
setSelectedOptions(prev => ({
...prev,
[componentKey]: {
...currentSelections,
[optionId]: 1
}
}));
} else {
// Deselection
const updatedSelections = { ...currentSelections };
delete updatedSelections[optionId];
setSelectedOptions(prev => ({
...prev,
[componentKey]: updatedSelections
}));
}
}
This provides an intuitive experience where users don't need to manually deselect items before selecting new ones.
Mobile Optimization
// Use responsive grid columns based on screen size
const gridClasses = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5';
// Ensure touch-friendly sizing
const optionCardClasses = 'min-h-[120px] sm:min-h-[140px] touch-manipulation';
Pattern 2: Step-by-Step Wizard
Overview
Guide users through bundle configuration one component at a time. This pattern reduces cognitive load and ensures all requirements are met.
When to Use
- Complex bundles with many components
- First-time or infrequent users
- When order of selection matters
- Need to explain component choices
Implementation
function StepByStepWizard({ bundle, componentProducts }) {
const [currentStep, setCurrentStep] = useState(0);
const [selectedOptions, setSelectedOptions] = useState({});
const [visitedSteps, setVisitedSteps] = useState(new Set([0]));
const components = Object.entries(bundle.attributes?.components || {});
const totalSteps = components.length;
const isStepValid = (stepIndex: number) => {
const [componentKey, component] = components[stepIndex];
const selectedCount = Object.keys(selectedOptions[componentKey] || {}).length;
return validateComponentSelection(component, selectedCount);
};
return (
<>
{/* Progress indicator */}
{components.map(([key, component], index) => (
<button
key={key}
onClick={() => handleStepClick(index)}
disabled={!visitedSteps.has(index) && index !== 0}
>
{isStepValid(index) ? '✓' : index + 1}
{component.name}
</button>
))}
{/* Current step content */}
<StepContent
component={components[currentStep][1]}
componentProducts={componentProducts}
selectedOptions={selectedOptions[components[currentStep][0]] || {}}
onSelectionChange={(options) => updateSelection(currentStep, options)}
/>
{/* Navigation buttons */}
<button onClick={handlePrevious} disabled={currentStep === 0}>
Previous
</button>
<span>Step {currentStep + 1} of {totalSteps}</span>
<button
onClick={currentStep < totalSteps - 1 ? handleNext : handleComplete}
disabled={!isStepValid(currentStep)}
>
{currentStep < totalSteps - 1 ? 'Next' : 'Add to Cart'}
</button>
</>
);
}```
function StepContent({ component, componentProducts, selectedOptions, onSelectionChange }) {
const availableProducts = getAvailableProducts(component.options, componentProducts);
return (
<>
<h2>{component.name}</h2>
{component.description && <p>{component.description}</p>}
{availableProducts.map(({ id, product }) => {
const isSelected = !!selectedOptions[id];
return (
<div key={id} onClick={() => toggleSelection(id, selectedOptions, onSelectionChange)}>
<input
type={component.max === 1 ? 'radio' : 'checkbox'}
checked={isSelected}
readOnly
/>
<h4>{product.attributes.name}</h4>
<p>{product.attributes.description}</p>
<p>{product.meta?.display_price?.without_tax?.formatted}</p>
</div>
);
})}
</>
);
}
Pattern 3: Accordion View
Overview
Display components in collapsible sections to save space while maintaining organization. Perfect for bundles with many components or limited screen space.
When to Use
- Many components (5+)
- Limited screen real estate
- Mobile-first design
- Progressive disclosure needed
Implementation
function AccordionPattern({ bundle, componentProducts }) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [selectedOptions, setSelectedOptions] = useState({});
const components = bundle.attributes?.components || {};
return (
<>
{Object.entries(components).map(([componentKey, component]) => {
const isExpanded = expandedSections.has(componentKey);
const selectedCount = Object.keys(selectedOptions[componentKey] || {}).length;
const isComplete = isComponentComplete(component, selectedCount);
return (
<div key={componentKey}>
{/* Accordion header */}
<button onClick={() => toggleSection(componentKey)}>
{isComplete ? '✓' : '○'}
{component.name} {component.min > 0 && '*'}
{selectedCount > 0 && `(${selectedCount} selected)`}
{/* Chevron icon for expand/collapse */}
</button>
{/* Accordion content */}
{isExpanded && (
<>
<p>{getSelectionHint(component)}</p>
{component.options.map(option => {
const product = componentProducts.find(p => p.id === option.id);
const isSelected = selectedOptions[componentKey]?.[option.id];
return (
<label key={option.id}>
<input
type="checkbox"
checked={!!isSelected}
onChange={(e) => handleOptionToggle(
componentKey, option.id, e.target.checked
)}
/>
<h4>{product?.attributes?.name}</h4>
<p>SKU: {product?.attributes?.sku}</p>
<p>{product?.meta?.display_price?.without_tax?.formatted}</p>
</label>
);
})}
</>
)}
</div>
);
})}
{/* Configuration summary */}
<ConfigurationSummary
selectedOptions={selectedOptions}
components={components}
componentProducts={componentProducts}
/>
</>
);
}
Pattern 4: Visual Builder
Overview
Interactive drag-and-drop or visual assembly interface for building custom bundles. Best for products where visual representation enhances understanding.
When to Use
- Highly customizable products
- Visual assembly makes sense (computers, furniture)
- Engagement is important
- Desktop-first experiences
Implementation
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
function VisualBuilder({ bundle, componentProducts }) {
const [configuration, setConfiguration] = useState({});
return (
<DndProvider backend={HTML5Backend}>
{/* Component palette */}
<ComponentPalette
components={bundle.attributes?.components || {}}
componentProducts={componentProducts}
/>
{/* Build area with drag-drop zone */}
<BuildArea
configuration={configuration}
onConfigurationChange={setConfiguration}
components={bundle.attributes?.components || {}}
/>
{/* Configuration summary */}
<ConfigurationSummary
configuration={configuration}
componentProducts={componentProducts}
/>
</DndProvider>
);
}
function DraggableComponent({ component, product }) {
const [{ isDragging }, drag] = useDrag({
type: 'component',
item: { componentKey: component.key, productId: product.id, product },
collect: (monitor) => ({ isDragging: monitor.isDragging() })
});
return (
<div ref={drag} style={{ opacity: isDragging ? 0.5 : 1 }}>
{/* Product image if available */}
{product.image && renderProductImage(product)}
{/* Product info */}
<h4>{product.attributes.name}</h4>
<p>{product.meta?.display_price?.without_tax?.formatted}</p>
</div>
);
}
function BuildArea({ configuration, onConfigurationChange, components }) {
const [{ isOver }, drop] = useDrop({
accept: 'component',
drop: (item: any) => {
const { componentKey, productId } = item;
// Validate drop against component rules
if (!canAddComponent(components[componentKey], configuration[componentKey])) {
showError(`Maximum ${components[componentKey].max} allowed`);
return;
}
// Add to configuration
addToConfiguration(onConfigurationChange, componentKey, productId);
},
collect: (monitor) => ({ isOver: monitor.isOver() })
});
return (
<div ref={drop} data-is-over={isOver}>
{Object.keys(configuration).length === 0 ? (
// Empty state
<p>Drag components here to build your bundle</p>
) : (
// Visual representation of configured bundle
<BundleVisualization
configuration={configuration}
components={components}
/>
)}
</div>
);
}
Pattern 5: Quick Configure Modal
Overview
Streamlined configuration modal for product listing pages, allowing quick bundle configuration without leaving the list view.
When to Use
- Product listing pages
- Quick add to cart functionality
- Simple bundles with few options
- Reducing friction in purchase flow
Implementation
function QuickConfigureModal({ bundle, isOpen, onClose, onAddToCart }) {
const [selectedOptions, setSelectedOptions] = useState({});
const [isLoading, setIsLoading] = useState(false);
const handleQuickAdd = async () => {
setIsLoading(true);
try {
const configuration = await configureBundle(bundle.id, selectedOptions);
await onAddToCart(bundle.id, configuration);
onClose();
} catch (error) {
console.error('Failed to add bundle:', error);
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<div onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
{/* Modal header */}
<h2>{bundle.attributes.name}</h2>
<button onClick={onClose}>×</button>
{/* Component selection */}
{Object.entries(bundle.attributes?.components || {}).map(
([componentKey, component]) => (
<div key={componentKey}>
<h3>{component.name} {component.min > 0 && '*'}</h3>
<select
onChange={(e) => handleComponentSelection(e.target.value, componentKey)}
required={component.min > 0}
>
<option value="">
{component.min > 0 ? 'Select an option' : 'None'}
</option>
{component.options.map(option => (
<option key={option.id} value={option.id}>
{getProductName(option.id)}
</option>
))}
</select>
</div>
)
)}
{/* Modal actions */}
<button onClick={onClose} disabled={isLoading}>Cancel</button>
<button
onClick={handleQuickAdd}
disabled={isLoading || !isConfigurationValid(selectedOptions, bundle)}
>
{isLoading ? 'Adding...' : 'Add to Cart'}
</button>
</div>
</div>
);
}
Performance Optimization
Lazy Loading Components
import dynamic from 'next/dynamic';
// Lazy load heavy patterns
const VisualBuilder = dynamic(
() => import('./patterns/VisualBuilder'),
{
loading: () => <div>Loading builder...</div>,
ssr: false // Disable SSR for drag-and-drop
}
);
// Conditional loading based on viewport
const BundlePattern = ({ pattern, ...props }) => {
switch (pattern) {
case 'visual-builder':
return <VisualBuilder {...props} />;
case 'wizard':
return <StepByStepWizard {...props} />;
default:
return <CheckboxGrid {...props} />;
}
};
Optimistic Updates
function useOptimisticConfiguration(bundleId: string) {
const [localConfig, setLocalConfig] = useState({});
const [serverConfig, setServerConfig] = useState({});
const [isSyncing, setIsSyncing] = useState(false);
const updateConfiguration = useCallback(async (newConfig) => {
// Update local state immediately
setLocalConfig(newConfig);
// Sync with server in background
setIsSyncing(true);
try {
const result = await configureByContextProduct({
path: { product_id: bundleId },
body: { data: { selected_options: newConfig } }
});
setServerConfig(result.data);
} catch (error) {
// Revert on error
setLocalConfig(serverConfig);
throw error;
} finally {
setIsSyncing(false);
}
}, [bundleId, serverConfig]);
return { configuration: localConfig, isSyncing, updateConfiguration };
}
Key Takeaways
-
Choose patterns based on:
- Product complexity
- User expertise level
- Device constraints
- Business goals
-
Optimize for mobile when using:
- Checkbox Grid
- Accordion View
- Quick Configure Modal
-
Enhance engagement with:
- Visual Builder
- Step-by-Step Wizard
-
Always consider:
- Performance optimization
- Error handling
- Loading states
Next Steps
- Bundle Pricing Strategies - Handle dynamic pricing
- Configure Dynamic Bundles - Implementation details
- Understanding Bundle Configuration - Deep dive