Skip to main content

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

  1. Checkbox Grid - Visual grid with images
  2. Step-by-Step Wizard - Guided configuration
  3. Accordion View - Expandable sections
  4. Visual Builder - Drag-and-drop interface
  5. Quick Configure Modal - For product listings

Choosing the Right Pattern

PatternBest ForImplementation Complexity
Checkbox GridVisual products, quick selection🟢 Simple
Step-by-Step WizardComplex bundles, first-time users🟡 Moderate
Accordion ViewMany components, limited space🟢 Simple
Visual BuilderCustomizable products, engagement🔴 Complex
Quick Configure ModalProduct 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

  1. Choose patterns based on:

    • Product complexity
    • User expertise level
    • Device constraints
    • Business goals
  2. Optimize for mobile when using:

    • Checkbox Grid
    • Accordion View
    • Quick Configure Modal
  3. Enhance engagement with:

    • Visual Builder
    • Step-by-Step Wizard
  4. Always consider:

    • Performance optimization
    • Error handling
    • Loading states

Next Steps

References