Variant Selection: Options and Availability
Build variant pickers that handle product options, track availability, update prices, and integrate with the product form.
Product variants represent different versions of a product (sizes, colors, materials). Building intuitive variant pickers that show availability and update the page dynamically is essential for a good shopping experience.
Understanding Variants
A product can have up to 3 options and 100 variants:
{# Product options #}{{ product.options }} {# ["Size", "Color"] #}{{ product.options.size }} {# 2 #}
{# Options with values #}{%- for option in product.options_with_values -%} {{ option.name }} {# "Size" #} {{ option.position }} {# 1 #} {{ option.values }} {# ["Small", "Medium", "Large"] #} {{ option.selected_value }} {# "Medium" #}{%- endfor -%}
{# Variants #}{{ product.variants.size }} {# 9 (3 sizes x 3 colors) #}{{ product.variants.first.title }} {# "Small / Red" #}Variant Properties
Each variant has:
{%- assign variant = product.selected_or_first_available_variant -%}
{{ variant.id }} {# 12345678901234 #}{{ variant.title }} {# "Small / Blue" #}{{ variant.sku }} {# "TSH-SM-BLU" #}{{ variant.price }} {# 2999 (in cents) #}{{ variant.compare_at_price }} {# 3999 #}{{ variant.available }} {# true/false #}{{ variant.inventory_quantity }} {# 5 (if tracking enabled) #}{{ variant.option1 }} {# "Small" #}{{ variant.option2 }} {# "Blue" #}{{ variant.option3 }} {# nil #}{{ variant.featured_image }} {# Image object #}Basic Dropdown Variant Picker
The simplest variant selector:
{# snippets/product-variant-picker.liquid #}
{%- unless product.has_only_default_variant -%} <div class="variant-picker"> {%- for option in product.options_with_values -%} <div class="variant-picker__option"> <label class="variant-picker__label" for="option-{{ option.position }}"> {{ option.name }} </label>
<select id="option-{{ option.position }}" class="variant-picker__select" data-option-position="{{ option.position }}" > {%- for value in option.values -%} <option value="{{ value }}" {% if value == option.selected_value %}selected{% endif %} > {{ value }} </option> {%- endfor -%} </select> </div> {%- endfor -%} </div>{%- endunless -%}Button-Style Variant Picker
More visual and touch-friendly:
{%- unless product.has_only_default_variant -%} <variant-picker class="variant-picker" data-url="{{ product.url }}"> {%- for option in product.options_with_values -%} <fieldset class="variant-picker__option"> <legend class="variant-picker__label"> {{ option.name }}: <span class="variant-picker__selected" data-selected-option="{{ option.position }}"> {{ option.selected_value }} </span> </legend>
<div class="variant-picker__buttons"> {%- for value in option.values -%} {%- assign option_disabled = true -%} {%- for variant in product.variants -%} {%- if variant.available -%} {%- case option.position -%} {%- when 1 -%} {%- if variant.option1 == value %}{% assign option_disabled = false %}{% endif -%} {%- when 2 -%} {%- if variant.option2 == value %}{% assign option_disabled = false %}{% endif -%} {%- when 3 -%} {%- if variant.option3 == value %}{% assign option_disabled = false %}{% endif -%} {%- endcase -%} {%- endif -%} {%- endfor -%}
<label class="variant-picker__button {% if option_disabled %}variant-picker__button--disabled{% endif %}"> <input type="radio" name="option{{ option.position }}" value="{{ value }}" {% if value == option.selected_value %}checked{% endif %} {% if option_disabled %}disabled{% endif %} data-option-position="{{ option.position }}" class="visually-hidden" > <span class="variant-picker__button-label">{{ value }}</span> </label> {%- endfor -%} </div> </fieldset> {%- endfor -%}
{# Hidden select for form submission #} <select name="id" class="visually-hidden" data-variant-select> {%- for variant in product.variants -%} <option value="{{ variant.id }}" {% if variant == product.selected_or_first_available_variant %}selected{% endif %} data-available="{{ variant.available }}" > {{ variant.title }} </option> {%- endfor -%} </select> </variant-picker>{%- endunless -%}Variant Picker CSS
.variant-picker { display: flex; flex-direction: column; gap: var(--spacing-lg);}
.variant-picker__option { border: none; padding: 0; margin: 0;}
.variant-picker__label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: var(--spacing-sm);}
.variant-picker__selected { font-weight: 400;}
/* Dropdown styles */.variant-picker__select { width: 100%; padding: var(--spacing-sm) var(--spacing-md); font-size: 1rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-background);}
/* Button styles */.variant-picker__buttons { display: flex; flex-wrap: wrap; gap: var(--spacing-xs);}
.variant-picker__button { cursor: pointer;}
.variant-picker__button-label { display: inline-flex; align-items: center; justify-content: center; min-width: 48px; padding: var(--spacing-sm) var(--spacing-md); font-size: 0.875rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); transition: all 0.2s;}
.variant-picker__button:hover .variant-picker__button-label { border-color: var(--color-text);}
.variant-picker__button input:checked + .variant-picker__button-label { background: var(--color-text); color: var(--color-background); border-color: var(--color-text);}
.variant-picker__button--disabled { opacity: 0.4; cursor: not-allowed;}
.variant-picker__button--disabled .variant-picker__button-label { text-decoration: line-through;}Color Swatches
For color options, show actual colors:
{%- if option.name == 'Color' or option.name == 'Colour' -%} <div class="variant-picker__swatches"> {%- for value in option.values -%} {%- assign color_handle = value | handleize -%}
<label class="variant-picker__swatch"> <input type="radio" name="option{{ option.position }}" value="{{ value }}" {% if value == option.selected_value %}checked{% endif %} class="visually-hidden" > <span class="variant-picker__swatch-color" style="background-color: {{ value | downcase }};" title="{{ value }}" ></span> </label> {%- endfor -%} </div>{%- else -%} {# Regular buttons for other options #}{%- endif -%}.variant-picker__swatches { display: flex; flex-wrap: wrap; gap: var(--spacing-xs);}
.variant-picker__swatch { cursor: pointer;}
.variant-picker__swatch-color { display: block; width: 32px; height: 32px; border-radius: 50%; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); transition: border-color 0.2s;}
.variant-picker__swatch:hover .variant-picker__swatch-color { border-color: var(--color-border);}
.variant-picker__swatch input:checked + .variant-picker__swatch-color { border-color: var(--color-text);}Variant Picker JavaScript
Handle option changes and find matching variants:
class VariantPicker extends HTMLElement { constructor() { super(); this.variantSelect = this.querySelector('[data-variant-select]'); this.options = this.querySelectorAll('[data-option-position]'); this.productUrl = this.dataset.url; }
connectedCallback() { this.options.forEach(option => { option.addEventListener('change', this.onOptionChange.bind(this)); }); }
onOptionChange() { // Get selected options const selectedOptions = this.getSelectedOptions();
// Find matching variant const variant = this.findVariant(selectedOptions);
if (variant) { // Update hidden select this.variantSelect.value = variant.id;
// Update URL this.updateUrl(variant.id);
// Dispatch event for other components this.dispatchEvent(new CustomEvent('variant:changed', { detail: { variant }, bubbles: true })); }
// Update selected value display this.updateSelectedLabels(selectedOptions);
// Update availability states this.updateAvailability(selectedOptions); }
getSelectedOptions() { const options = [];
this.querySelectorAll('input:checked, select').forEach(input => { if (input.dataset.optionPosition) { options[input.dataset.optionPosition - 1] = input.value; } });
return options; }
findVariant(selectedOptions) { const variants = JSON.parse( document.getElementById('product-variants').textContent );
return variants.find(variant => { return selectedOptions.every((option, index) => { return variant[`option${index + 1}`] === option; }); }); }
updateUrl(variantId) { const url = new URL(this.productUrl, window.location.origin); url.searchParams.set('variant', variantId); history.replaceState({}, '', url); }
updateSelectedLabels(selectedOptions) { selectedOptions.forEach((value, index) => { const label = this.querySelector(`[data-selected-option="${index + 1}"]`); if (label) label.textContent = value; }); }
updateAvailability(selectedOptions) { // This is complex - need to check which options are still available // given the current selections }}
customElements.define('variant-picker', VariantPicker);Passing Variant Data
Include variant data for JavaScript:
<script type="application/json" id="product-variants"> {{ product.variants | json }}</script>Updating Page Elements
When variant changes, update other page elements:
document.addEventListener('variant:changed', (event) => { const variant = event.detail.variant;
// Update price const priceEl = document.querySelector('[data-product-price]'); if (priceEl) { priceEl.textContent = formatMoney(variant.price); }
// Update compare price const compareEl = document.querySelector('[data-compare-price]'); if (compareEl) { if (variant.compare_at_price > variant.price) { compareEl.textContent = formatMoney(variant.compare_at_price); compareEl.hidden = false; } else { compareEl.hidden = true; } }
// Update SKU const skuEl = document.querySelector('[data-product-sku]'); if (skuEl) { skuEl.textContent = variant.sku || ''; }
// Update add to cart button const addButton = document.querySelector('[data-add-to-cart]'); if (addButton) { if (variant.available) { addButton.disabled = false; addButton.textContent = 'Add to Cart'; } else { addButton.disabled = true; addButton.textContent = 'Sold Out'; } }
// Update gallery to show variant image if (variant.featured_media) { const gallery = document.querySelector('product-gallery'); gallery?.setActiveMedia(variant.featured_media.id); }});Showing Availability Per Option
Indicate which options are available:
updateAvailability(selectedOptions) { const variants = JSON.parse( document.getElementById('product-variants').textContent );
this.querySelectorAll('.variant-picker__option').forEach((optionEl, optionIndex) => { const buttons = optionEl.querySelectorAll('.variant-picker__button');
buttons.forEach(button => { const input = button.querySelector('input'); const value = input.value;
// Check if any variant with this option value is available const isAvailable = variants.some(variant => { // Check if this variant matches all currently selected options // except for the current option position const matches = selectedOptions.every((selected, i) => { if (i === optionIndex) { return variant[`option${i + 1}`] === value; } return variant[`option${i + 1}`] === selected; });
return matches && variant.available; });
button.classList.toggle('variant-picker__button--disabled', !isAvailable); input.disabled = !isAvailable; }); });}Complete Variant Picker
{# snippets/product-variant-picker.liquid #}
{%- unless product.has_only_default_variant -%} <variant-picker class="variant-picker" data-url="{{ product.url }}" data-section="{{ section.id }}" > {%- for option in product.options_with_values -%} <fieldset class="variant-picker__option"> <legend class="variant-picker__label"> {{ option.name }}: <span data-selected-option="{{ option.position }}">{{ option.selected_value }}</span> </legend>
{%- liquid assign is_color = false assign color_names = 'color,colour,cor,couleur,farbe' | split: ',' assign option_name_lower = option.name | downcase for color_name in color_names if option_name_lower == color_name assign is_color = true break endif endfor -%}
<div class="variant-picker__values {% if is_color %}variant-picker__values--swatches{% endif %}"> {%- for value in option.values -%} <label class="variant-picker__value"> <input type="radio" name="option{{ option.position }}" value="{{ value }}" {% if value == option.selected_value %}checked{% endif %} data-option-position="{{ option.position }}" class="visually-hidden" >
{%- if is_color -%} <span class="variant-picker__swatch" style="background-color: {{ value | downcase }};" title="{{ value }}" ></span> {%- else -%} <span class="variant-picker__button">{{ value }}</span> {%- endif -%} </label> {%- endfor -%} </div> </fieldset> {%- endfor -%}
<select name="id" class="visually-hidden" aria-label="Product variants" data-variant-select> {%- for variant in product.variants -%} <option value="{{ variant.id }}" {% if variant == product.selected_or_first_available_variant %}selected{% endif %} > {{ variant.title }} - {{ variant.price | money }} </option> {%- endfor -%} </select> </variant-picker>
<script type="application/json" id="product-variants"> {{ product.variants | json }} </script>{%- endunless -%}Practice Exercise
Build a variant picker that:
- Shows color swatches for color options
- Shows buttons for size options
- Updates the selected value label
- Disables unavailable options
- Updates the URL when variant changes
- Dispatches an event for other components
Test with products that have:
- One option
- Two options
- Three options
- Some unavailable variants
Key Takeaways
- Check
has_only_default_variantbefore showing picker - Use
options_with_valuesfor option data - Color swatches improve UX for color options
- Show availability by checking variant combinations
- Update URL with variant ID for shareable links
- Dispatch events for cross-component communication
- Hidden select maintains form functionality
- Update page elements when variant changes
What’s Next?
With variant selection working, the next lesson covers Product Form and Add-to-Cart Mechanics for submitting purchases.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...