Shopify variant selector
12/08/2026 in #shopify #variant-selector #progressive-enhancementThe series
Part 4 of 7 of the series Building a Shopify storefront with @studiometa/ui.
A variant selector has one job: when the customer picks an option, the price, the availability and the buy button reflect the chosen variant. That is state management, but small and local, and @studiometa/ui has a set of components for exactly that. Built on @studiometa/js-toolkit, they keep your server-rendered Liquid and add reactivity in place.
Pick a size. The price, the label and the buy button react instantly:
Everything reactive here is declared in markup with the Data family of components. The size L is marked sold out, so selecting it disables the button. You can open the demo in the playground to edit it.
Table of content
The Data family
@studiometa/ui ships a small set of components that bind form state to the DOM reactively, grouped by a shared key. The documentation calls them the Data family. Five of them cover a variant selector:
DataScopedefines a boundary and a shared group, so the components inside talk to each other without wiring.DataModelis the source. Put it on the option inputs; the selected value is published to the group under the input'sname. Radios sharing anamebehave as one value.DataComputedderives a value from the group with a JavaScript expression, here the price.DataBindreflects a group value into an element, here the selected-size label.DataEffectruns a side effect when a value changes, here toggling the buy button.
The markup reads top to bottom as the data flow:
<div data-component="DataScope" data-option-group="variant">
<!-- source: the checked radio publishes its value under the key "size" -->
<input type="radio" name="size" value="S" data-component="DataModel" data-option-immediate checked> S
<input type="radio" name="size" value="M" data-component="DataModel" data-option-immediate> M
<!-- derived: compute a price from the selected size -->
<span data-component="DataComputed" data-option-key="size"
data-option-compute="({ S: '19 €', M: '19 €', L: '22 €' }[value]) || '19 €'">19 €</span>
<!-- reflected: show the raw selected value -->
<span data-component="DataBind" data-option-key="size">S</span>
<!-- effect: disable the button for a sold-out size -->
<button type="button" data-component="DataEffect" data-option-key="size"
data-option-effect="target.disabled = value === 'L'; target.textContent = value === 'L' ? 'Sold out' : 'Add to cart'">
Add to cart
</button>
</div>
Register the components once and they wire themselves up from the attributes:
import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { class DataBind<T extends BaseProps = BaseProps>DataBind class.
DataBind, class DataComputed<T extends BaseProps = BaseProps>DataComputed, class DataEffect<T extends BaseProps = BaseProps>DataEffect, class DataModel<T extends BaseProps = BaseProps>DataModel, import DataScopeDataScope } from '@studiometa/ui';
import registerComponentsregisterComponents(import DataScopeDataScope, class DataModel<T extends BaseProps = BaseProps>DataModel, class DataComputed<T extends BaseProps = BaseProps>DataComputed, class DataBind<T extends BaseProps = BaseProps>DataBind class.
DataBind, class DataEffect<T extends BaseProps = BaseProps>DataEffect);
data-option-immediate makes the checked radio publish its value on mount, so the derived UI is correct before the first interaction. The compute and effect expressions receive the current value for their key (plus the element as target and the whole group as $data), so they stay small and declarative.
Demo data vs. store data
The price map in the demo ({ S: '19 €', … }) is a stand-in for your product's variant data. Client-side, hardcoding it keeps the example self-contained, but on a real store the price and availability are authoritative: they depend on inventory and pricing rules the browser should not guess. So the reactive layer above is the right tool for instant UI (labels, composing a variant id from several options, optimistic enable/disable), while the server computes price and availability. Here is how to wire that.
The real wiring: options and the Section Rendering API
Shopify recommends rendering one selector per option from product.options_with_values, and using the Section Rendering API to load the price and availability for the newly selected variant. Each option value already tells you whether it is selected and available, so the initial state is server-rendered and correct.
{% assign current_variant = product.selected_or_first_available_variant %}
<form data-component="DataScope" data-option-group="variant">
{% for option in product.options_with_values %}
<fieldset>
<legend>{{ option.name }}</legend>
{% for value in option.values %}
<label>
<input type="radio"
name="{{ option.name }}"
value="{{ value | escape }}"
data-component="DataModel"
data-option-immediate
{% if value.selected %}checked{% endif %}
{% unless value.available %}disabled{% endunless %}>
{{ value }}
</label>
{% endfor %}
</fieldset>
{% endfor %}
{% comment %} price must be a real section (sections/price.liquid), not a
snippet: only sections can be re-rendered with ?sections=price. The
{% section %} tag wraps it in <div id="shopify-section-price"> for you,
and the section itself reads current_variant's price and availability. {% endcomment %}
{% section 'price' %}
</form>
When a variant is deep-linked with the ?variant={id} URL parameter, product.selected_variant reflects it, and product.selected_or_first_available_variant gives you a sensible default otherwise. price here is a genuine theme section (sections/price.liquid), because the Section Rendering API only re-renders real section files, not a snippet you wrap in a hand-written <div id="shopify-section-price">. To refresh that authoritative section on selection, fetch the product URL for the chosen variant and swap the section by id, the exact FetchShopifySection recipe from the Section Rendering article:
<a
href="{{ product.url }}?variant={{ current_variant.id }}"
data-component="FetchShopifySection"
data-option-sections="price">
…
</a>
In practice you derive the selected variant id from the option inputs with a DataComputed, and trigger the fetch on change, as with the sort control in collection filtering. The Data components handle the instant feedback; the Section Rendering call handles the price and the availability. Note that product.variants is capped at 250, so for high-variant products lean on options_with_values and Section Rendering rather than dumping the full variant list as JSON.
Where this goes next
The selector above derives price, label and button state from the option inputs, declared entirely in markup, with Shopify rendering the authoritative price. For the section-swapping mechanics, see the Section Rendering API example.
Next article
The AJAX cart drawer adds the selected variant to the cart and opens a drawer without a reload.