Product Page Implementation Intermediate 15 min read

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:

  1. Shows color swatches for color options
  2. Shows buttons for size options
  3. Updates the selected value label
  4. Disables unavailable options
  5. Updates the URL when variant changes
  6. Dispatches an event for other components

Test with products that have:

  • One option
  • Two options
  • Three options
  • Some unavailable variants

Key Takeaways

  1. Check has_only_default_variant before showing picker
  2. Use options_with_values for option data
  3. Color swatches improve UX for color options
  4. Show availability by checking variant combinations
  5. Update URL with variant ID for shareable links
  6. Dispatch events for cross-component communication
  7. Hidden select maintains form functionality
  8. 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...