Skip to main content

Build product pages with variant selection

This guide shows you how to build a product page with Hydrogen's product form. You'll query a product with the fields that variant selection needs, keep the selected options in the URL, render option values that work without JavaScript, and add the selected variant to the cart.


The product form is a store that tracks which options a customer has selected on one product page. You create it with createProductFormStore() from @shopify/hydrogen, or, in React and Vue, with the ProductProvider that createProductComponents() returns.

The store derives the following state from the product data that you pass in:

  • options: Every product option and its values. Each value reports whether it's selected, whether its combination with the other selected options exists, whether that variant is available for sale, and the selectedOptions and handle that selecting it leads to.
  • selectedOptions: The option values the customer has selected.
  • selectedVariant: The variant that matches the selected options, or null.
  • errors: The cart's userErrors, warnings, and networkErrors, including the selected variant's line errors.

The store computes exists and available from the product's encoded variant fields, so it doesn't need every variant in your query. When a customer selects a value, the store resolves the new variant from the variants you queried. If it doesn't have that variant, then it keeps the selection and your page loads the variant from the server.

The URL is the source of truth for the selection. The server reads the selected options from the URL and queries the matching variant, so a product URL always renders the right variant, even without JavaScript. On the client, the store updates the selection immediately, and your page syncs the URL to match.


  • Create a Storefront API client for each request.
  • Wire handleShopifyRoutes into your server before your framework's router runs.
  • Provide the cart to your app with CartProvider, or create a cart store in vanilla JavaScript. Refer to Build cart UI.
  • Create a product route at /products/{handle}. If your storefront uses a different product path, then refer to Route templates.

Anchor to Step 1: Query product dataStep 1: Query product data

The product form needs specific fields on the product and on each variant. Define one variant fragment and reuse it for the selected variant, the adjacent variants, and each option value's first selectable variant:

Product query

lib/product-query.ts
import {gql, type StorefrontApi} from '@shopify/hydrogen';

const VARIANT_FIELDS_FRAGMENT = gql(`
fragment VariantFields on ProductVariant {
id
title
availableForSale
sku
selectedOptions {
name
value
}
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
image {
url
altText
width
height
}
product {
handle
title
}
}
`);

export const PRODUCT_QUERY = gql(
`
query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {
product(handle: $handle) {
id
handle
title
vendor
descriptionHtml
requiresSellingPlan
encodedVariantExistence
encodedVariantAvailability
options {
name
optionValues {
name
firstSelectableVariant {
...VariantFields
}
swatch {
color
image {
previewImage {
url
}
}
}
}
}
selectedOrFirstAvailableVariant(
selectedOptions: $selectedOptions
ignoreUnknownOptions: true
caseInsensitiveMatch: true
) {
...VariantFields
}
adjacentVariants(
selectedOptions: $selectedOptions
ignoreUnknownOptions: true
caseInsensitiveMatch: true
) {
...VariantFields
}
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
}
}
`,
[VARIANT_FIELDS_FRAGMENT],
);

export type ProductData = NonNullable<
StorefrontApi.ResultOf<typeof PRODUCT_QUERY>['product']
>;

The following fields matter most:

  • encodedVariantExistence and encodedVariantAvailability: Let the store mark every option value as existing or available without querying every variant.
  • selectedOrFirstAvailableVariant: Resolves the variant from the URL's selected options. ignoreUnknownOptions and caseInsensitiveMatch stop unrelated or differently cased parameters from breaking the match.
  • adjacentVariants: The variants one option change away from the selected one, so most selections resolve on the client.
  • requiresSellingPlan: Tells canAddToCart() to block products that can only be sold on a subscription.

To query prices and content for the customer's market, add @inContext to the query. Refer to Markets.

Read the selected options from the URL with getSelectedProductOptions() and pass them to the query. The following loader runs on the server and returns null when the product doesn't exist:

Load the product

lib/product-loader.ts
import {getSelectedProductOptions, type StorefrontClient} from '@shopify/hydrogen';

import {PRODUCT_QUERY} from './product-query';

export async function loadProduct(
storefront: StorefrontClient,
handle: string,
searchParams: URLSearchParams,
) {
const selectedOptions = getSelectedProductOptions({searchParams});
const {data, errors} = await storefront.graphql(PRODUCT_QUERY, {
variables: {handle, selectedOptions},
});
if (errors) console.error('Product query failed', errors);

return data?.product ?? null;
}

Call loadProduct() from your product route's server code, and pass it the request's search parameters, so a product URL always renders the variant that it encodes. When it returns null, respond with your framework's 404 page. To find where this code goes in your framework, refer to Framework wiring.

In Next.js, await the page's searchParams. Convert it to URLSearchParams with a small helper. If you cache the call with 'use cache', then pass the search string to the cached function instead, using a module-scoped catalog client:

Next.js search params helper

lib/url-params.ts
export function toURLSearchParams(
input: Record<string, string | string[] | undefined>,
): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(input)) {
if (Array.isArray(value)) {
for (const item of value) params.append(key, item);
} else if (value != null) {
params.set(key, value);
}
}
return params;
}

getSelectedProductOptions() reads every search parameter as an option, except reserved ones like variant. To limit it to known option names, pass allowedOptionNames.


Anchor to Step 2: Build option URLsStep 2: Build option URLs

Every option value links to a URL that encodes its selected options, like /products/shirt?Color=Blue&Size=M. Build these URLs with buildProductSelectionSearchParams(), and pass the current search parameters as base, so parameters that aren't options, like UTM tags, survive a selection:

Option URL helper

lib/variant-url.ts
import {
buildProductSelectionSearchParams,
type SelectedOption,
} from '@shopify/hydrogen';

export function variantUrl(
product: {handle: string; options: Array<{name: string}>},
selectedOptions: SelectedOption[],
handle = product.handle,
base: URLSearchParams = new URLSearchParams(),
): string {
const params = buildProductSelectionSearchParams({
selectedOptions,
optionNames: product.options.map((option) => option.name),
base: new URLSearchParams(base),
});
const query = params.toString();
return `/products/${handle}${query ? `?${query}` : ''}`;
}

Links from other storefronts and apps often use Liquid-style ?variant=<id> URLs. You don't need to handle them. handleShopifyRoutes redirects them to option parameters before your route runs. To build a ?variant= link yourself, for example in a feed, pass style: 'variant' and a variant to buildProductSelectionSearchParams().


Anchor to Step 3: Provide the product formStep 3: Provide the product form

Create the product form for your product query type. In React and Vue, createProductComponents() returns a provider and hooks that share one product form store. In vanilla JavaScript, create the store yourself with createProductFormStore(), and pass it the cart store from Build cart UI:

Product bindings

lib/product.ts

import {createProductFormStore} from '@shopify/hydrogen';

import {cartStore} from './cart';
import type {ProductData} from './product-query';

export function createProductForm(product: ProductData) {
return createProductFormStore(product, cartStore);
}
import {createProductComponents} from '@shopify/hydrogen/react';

import type {ProductData} from './product-query';

export const {ProductProvider, useProduct, useProductForm} =
createProductComponents<ProductData>();
import {createProductComponents} from '@shopify/hydrogen/vue';

import type {ProductData} from './product-query';

export const {ProductProvider, useProduct, useProductForm} =
createProductComponents<ProductData>();

In React and Vue, wrap the product page in ProductProvider. The provider hydrates the store when the product ID or selected variant ID changes, and destroys the store when it unmounts. In vanilla JavaScript, call store.hydrate(product) after you load new product data, and call store.destroy() when the customer leaves the page.

After a customer selects a value, sync the URL. Replace the history entry instead of pushing a new one, and keep the scroll position. In React and Vue, sync it in the provider's onSelect callback. In vanilla JavaScript, sync it after store.selectOption() returns.

Each React router reads and replaces the URL differently, so the React examples in this guide use two small hooks that wrap your router:

Router hooks

lib/router.ts

import {useRouter, useSearchParams} from 'next/navigation';

export function useSearch(): string {
return useSearchParams().toString();
}

export function useReplaceUrl(): (url: string) => void {
const router = useRouter();
return (url) => router.replace(url, {scroll: false});
}
import {useLocation, useNavigate} from 'react-router';

export function useSearch(): string {
return useLocation().search;
}

export function useReplaceUrl(): (url: string) => void {
const navigate = useNavigate();
return (url) => {
void navigate(url, {replace: true, preventScrollReset: true});
};
}

Provide the product form, and sync the URL after each selection:

Provide the product form

lib/product-page.ts

import {createProductForm} from './product';
import type {ProductData} from './product-query';
import {variantUrl} from './variant-url';

export function startProductPage(product: ProductData) {
const store = createProductForm(product);

function selectOption(name: string, value: string) {
const result = store.selectOption(name, value);
if (result.status === 'invalid') return;

const url = variantUrl(
product,
result.selectedOptions,
result.selectedVariant?.product?.handle,
new URLSearchParams(window.location.search),
);
if (result.status === 'resolved') {
window.history.replaceState(window.history.state, '', url);
} else {
// The store doesn't have this variant, so load it from the server.
window.location.replace(url);
}
}

return {store, selectOption};
}
import {AddToCartForm} from './AddToCartForm';
import {ProductOptions} from './ProductOptions';
import {ProductProvider} from '../lib/product';
import type {ProductData} from '../lib/product-query';
import {useReplaceUrl, useSearch} from '../lib/router';
import {variantUrl} from '../lib/variant-url';

export function ProductDetails({product}: {product: ProductData}) {
const replaceUrl = useReplaceUrl();
const search = useSearch();

return (
<ProductProvider
product={product}
onSelect={(result) => {
replaceUrl(
variantUrl(
product,
result.selectedOptions,
result.selectedVariant?.product?.handle,
new URLSearchParams(search),
),
);
}}
>
<h1>{product.title}</h1>
<ProductOptions product={product} />
<AddToCartForm product={product} />
</ProductProvider>
);
}
<script setup lang="ts">
import type {ValidProductSelectionResult} from '@shopify/hydrogen/vue';
import {useRouter} from 'vue-router';

import AddToCartForm from './AddToCartForm.vue';
import ProductOptions from './ProductOptions.vue';
import {ProductProvider} from '../lib/product';
import type {ProductData} from '../lib/product-query';
import {variantUrl} from '../lib/variant-url';

const props = defineProps<{product: ProductData}>();
const router = useRouter();

function handleSelect(result: ValidProductSelectionResult<ProductData>) {
const url = variantUrl(
props.product,
result.selectedOptions,
result.selectedVariant?.product?.handle,
new URLSearchParams(window.location.search),
);
void router.replace(url);
}
</script>

<template>
<ProductProvider :product="product" :on-select="handleSelect">
<h1>{{ product.title }}</h1>
<ProductOptions :product="product" />
<AddToCartForm :product="product" />
</ProductProvider>
</template>

Render ProductDetails from your product route, and pass it the product that loadProduct() returns. In vanilla JavaScript, server-render the option links, serialize the product into the page, and pass it to startProductPage() from your client bundle. Pass store and selectOption to renderProductOptions(), and store to attachAddToCart(). In Vue, reload the product when the route query changes.

Keep URL syncing idempotent. It can run for a URL that's already current, for example when a router link navigates to the same URL on the same click.

In React Router, to skip the loader for selections that resolve on the client, use the route's shouldRevalidate export. Don't pass revalidation flags to navigate().


Anchor to Step 4: Render option valuesStep 4: Render option values

Render each option as a <fieldset> with a <legend>. How you render a value depends on its state:

  • Values on the same product that exist: A link to the value's option URL, with aria-current on the selected value. In React and Vue, spread register('optionValue', ...) on the link. In vanilla JavaScript, call selectOption() from its click handler. Either way, a hydrated click selects the value in the store without a full navigation.
  • Values that exist but are sold out: The same link. Keep sold-out values interactive, and add a visually hidden "Sold out" label and a sold-out style.
  • Values whose combination doesn't exist: A <button> with the native disabled attribute and aria-pressed. There's no URL for a combination that doesn't exist.
  • Values on a different product: A plain link to that product. For details, refer to Combined listings.

Hide the whole picker when every option has one value or fewer, like a product with only the default Title option. Don't hide values based on the current selection. Customers need to see every value to change their mind.

Render option values

lib/product-options.ts

import type {ProductFormStore} from '@shopify/hydrogen';

import type {ProductData} from './product-query';
import {variantUrl} from './variant-url';

export function renderProductOptions(
root: HTMLElement,
product: ProductData,
store: ProductFormStore<ProductData>,
selectOption: (name: string, value: string) => void,
) {
function render() {
const {options} = store.getState();
const base = new URLSearchParams(window.location.search);
root.hidden = !options.some((option) => option.values.length > 1);

root.replaceChildren(
...options.map((option) => {
const fieldset = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = option.name;
fieldset.append(legend);

for (const value of option.values) {
const otherProduct = value.handle && value.handle !== product.handle;

if (!otherProduct && !value.exists) {
const button = document.createElement('button');
button.type = 'button';
button.disabled = true;
button.setAttribute('aria-pressed', String(value.selected));
button.textContent = value.name;
fieldset.append(button);
continue;
}

const link = document.createElement('a');
link.href = variantUrl(product, value.selectedOptions, value.handle, base);
link.textContent = value.name;
link.dataset.available = String(value.available);
if (!value.available) {
const label = document.createElement('span');
label.className = 'sr-only';
label.textContent = ' (Sold out)';
link.append(label);
}
if (!otherProduct) {
if (value.selected) link.setAttribute('aria-current', 'true');
link.addEventListener('click', (event) => {
event.preventDefault();
selectOption(option.name, value.name);
});
}
fieldset.append(link);
}
return fieldset;
}),
);
}

render();
return store.subscribe(render);
}
import {useProductForm} from '../lib/product';
import type {ProductData} from '../lib/product-query';
import {useSearch} from '../lib/router';
import {variantUrl} from '../lib/variant-url';

export function ProductOptions({product}: {product: ProductData}) {
const {options, register} = useProductForm();
const base = new URLSearchParams(useSearch());

if (!options.some((option) => option.values.length > 1)) return null;

return (
<div>
{options.map((option) => (
<fieldset key={option.name}>
<legend>{option.name}</legend>
{option.values.map((value) => {
const href = variantUrl(product, value.selectedOptions, value.handle, base);
const soldOut = value.available ? null : (
<span className="sr-only"> (Sold out)</span>
);

if (value.handle && value.handle !== product.handle) {
return (
<a
key={value.name}
href={href}
data-available={value.available ? 'true' : 'false'}
>
{value.name}
{soldOut}
</a>
);
}

if (!value.exists) {
return (
<button
key={value.name}
type="button"
disabled
aria-pressed={value.selected}
>
{value.name}
</button>
);
}

const optionProps = register('optionValue', {
optionName: option.name,
value: value.name,
});

return (
<a
key={value.name}
href={href}
aria-current={value.selected ? 'true' : undefined}
data-available={value.available ? 'true' : 'false'}
{...optionProps}
onClick={(event) => {
event.preventDefault();
optionProps.onClick();
}}
>
{value.name}
{soldOut}
</a>
);
})}
</fieldset>
))}
</div>
);
}
<script setup lang="ts">
import type {SelectedOption} from '@shopify/hydrogen';
import {computed} from 'vue';
import {RouterLink, useRoute} from 'vue-router';

import {useProductForm} from '../lib/product';
import type {ProductData} from '../lib/product-query';
import {variantUrl} from '../lib/variant-url';

const props = defineProps<{product: ProductData}>();
const form = useProductForm();
const route = useRoute();

const base = computed(() => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(route.query)) {
for (const item of [value].flat()) {
if (item != null) params.append(key, item);
}
}
return params;
});

const hasChoices = computed(() =>
form.options.some((option) => option.values.length > 1),
);

function href(value: {selectedOptions: SelectedOption[]; handle: string}) {
return variantUrl(props.product, value.selectedOptions, value.handle, base.value);
}
</script>

<template>
<div v-if="hasChoices">
<fieldset v-for="option in form.options" :key="option.name">
<legend>{{ option.name }}</legend>
<template v-for="value in option.values" :key="value.name">
<RouterLink
v-if="value.handle && value.handle !== product.handle"
:to="href(value)"
:data-available="String(value.available)"
>
{{ value.name }}
<span v-if="!value.available" class="sr-only"> (Sold out)</span>
</RouterLink>
<button
v-else-if="!value.exists"
type="button"
disabled
:aria-pressed="value.selected"
>
{{ value.name }}
</button>
<RouterLink
v-else
:to="href(value)"
replace
:aria-current="value.selected ? 'true' : undefined"
:data-available="String(value.available)"
v-bind="form.register('optionValue', {optionName: option.name, value: value.name})"
>
{{ value.name }}
<span v-if="!value.available" class="sr-only"> (Sold out)</span>
</RouterLink>
</template>
</fieldset>
</div>
</template>

In React and vanilla JavaScript, the option links are plain <a> elements. A hydrated click on a value of the current product calls preventDefault() and selects the value in the store, and the URL sync replaces the URL. Without JavaScript, the same link loads the selected variant from the server. In Vue, RouterLink and the registered click handler both run, so the router navigates to the same URL that onSelect replaces. To navigate to another product without a full page load, render your router's link component for that value instead of <a>.

Style sold-out values from the data-available attribute, for example with a strikethrough. To render color or image swatches, read value.swatch.color and value.swatch.image from the option value.

With combined listings, each value of an option, like each color, can be a separate product. The store sets value.handle to the handle of the product that the value links to.

When value.handle differs from the current product's handle, render the value as a plain link to that product, as the preceding example does. Don't spread register('optionValue', ...) on it, and don't call selectOption() for it. The other product has its own page, data, and product form.


Keep the add-to-cart form separate from the option picker. The form submits the selected variant, and the option links change the selection.

In React and Vue, formProps() returns a form that posts to the cart endpoint, so it works without JavaScript. After hydration, the product form store submits it through the cart store with an optimistic update. In vanilla JavaScript, call store.handleFormSubmit() from the form's submit handler:

Add to cart form

lib/add-to-cart.ts

import {canAddToCart, formatMoney, type ProductFormStore} from '@shopify/hydrogen';

import {openCartDrawer} from './cart-drawer';
import type {ProductData} from './product-query';

export function attachAddToCart(
form: HTMLFormElement,
product: ProductData,
store: ProductFormStore<ProductData>,
) {
const merchandiseId = form.elements.namedItem('merchandiseId') as HTMLInputElement;
const button = form.querySelector<HTMLButtonElement>('button[name="add-to-cart"]')!;
const price = form.querySelector<HTMLElement>('[data-price]')!;
const errors = form.querySelector<HTMLElement>('[role="alert"]')!;

form.addEventListener('submit', (event) => {
event.preventDefault();
if (!canAddToCart(product, store.getState().options)) return;
openCartDrawer();
void store.handleFormSubmit(event);
});

function render() {
const {options, selectedVariant, errors: formErrors} = store.getState();
const addable = canAddToCart(product, options);
const amount = selectedVariant?.price ?? product.priceRange.minVariantPrice;

merchandiseId.value = selectedVariant?.id ?? '';
price.textContent = formatMoney(amount, {locale: 'en-US'}).toString();
button.setAttribute('aria-disabled', String(!addable));
button.textContent = addable
? 'Add to cart'
: product.requiresSellingPlan
? 'Choose a subscription'
: selectedVariant
? 'Sold out'
: 'Select options';
errors.replaceChildren(
...formErrors.userErrors.map((error) => {
const item = document.createElement('li');
item.textContent = error.message;
return item;
}),
);
}

render();
return store.subscribe(render);
}
import {canAddToCart, formatMoney} from '@shopify/hydrogen';

import {openCartDrawer} from '../lib/cart-drawer';
import {useProductForm} from '../lib/product';
import type {ProductData} from '../lib/product-query';

export function AddToCartForm({product}: {product: ProductData}) {
const {options, selectedVariant, register, formProps, errors, pending} =
useProductForm();
const addable = canAddToCart(product, options);
const price = selectedVariant?.price ?? product.priceRange.minVariantPrice;

return (
<form {...formProps({beforeSubmit: openCartDrawer})}>
<p>{formatMoney(price, {locale: 'en-US'}).toString()}</p>
<input type="hidden" {...register('merchandiseId', {})} />
<label>
Quantity
<input type="number" min={1} {...register('quantity', {defaultValue: 1})} />
</label>
<button
{...register('addToCart', {})}
aria-disabled={!addable}
aria-busy={pending}
onClick={(event) => {
if (!addable) event.preventDefault();
}}
>
{addable
? 'Add to cart'
: product.requiresSellingPlan
? 'Choose a subscription'
: selectedVariant
? 'Sold out'
: 'Select options'}
</button>
{errors.userErrors.length > 0 ? (
<ul role="alert">
{errors.userErrors.map((error, index) => (
<li key={index}>{error.message}</li>
))}
</ul>
) : null}
</form>
);
}
<script setup lang="ts">
import {canAddToCart, formatMoney} from '@shopify/hydrogen';
import {computed, ref} from 'vue';

import {openCartDrawer} from '../lib/cart-drawer';
import {useProductForm} from '../lib/product';
import type {ProductData} from '../lib/product-query';

const props = defineProps<{product: ProductData}>();
const form = useProductForm();
const quantity = ref(1);

const addable = computed(() => canAddToCart(props.product, form.options));
const price = computed(
() => form.selectedVariant?.price ?? props.product.priceRange.minVariantPrice,
);

function updateQuantity(event: Event) {
const next = Number((event.target as HTMLInputElement).value);
quantity.value = Number.isInteger(next) && next > 0 ? next : 1;
}
</script>

<template>
<form v-bind="form.formProps({beforeSubmit: openCartDrawer})">
<p>{{ formatMoney(price, {locale: 'en-US'}).toString() }}</p>
<input type="hidden" v-bind="form.register('merchandiseId', {})" />
<label>
Quantity
<input
type="number"
min="1"
v-bind="form.register('quantity', {value: quantity})"
@input="updateQuantity"
/>
</label>
<button
v-bind="form.register('addToCart', {})"
:aria-disabled="!addable"
:aria-busy="form.pending.value"
@click="!addable && $event.preventDefault()"
>
{{
addable
? 'Add to cart'
: product.requiresSellingPlan
? 'Choose a subscription'
: form.selectedVariant
? 'Sold out'
: 'Select options'
}}
</button>
<ul v-if="form.errors.userErrors.length > 0" role="alert">
<li v-for="(error, index) in form.errors.userErrors" :key="index">
{{ error.message }}
</li>
</ul>
</form>
</template>

In vanilla JavaScript, render the form on the server with method="post", the cart endpoint /api/cart as its action, a hidden merchandiseId input with the selected variant's ID, a submit button named add-to-cart, an element with a data-price attribute, and an empty <ul role="alert">. The form works before your script loads. After hydration, attachAddToCart() keeps the variant ID, price, and button text in sync with the store, and submits through the store. In Vue, pending is a ref, so read form.pending.value.

The form uses the following pieces:

  • register('merchandiseId', {}): A hidden input with the selected variant's ID.
  • register('quantity', {defaultValue: 1}): The quantity input. Pass value instead of defaultValue to control the input yourself.
  • register('addToCart', {}): The submit button's name and type.
  • canAddToCart(): Returns true only when a variant is selected, it's available for sale, and the product doesn't require a selling plan. Use it to choose the button text: "Add to cart", "Choose a subscription", "Sold out", or "Select options". The button uses aria-disabled instead of disabled, so it stays focusable and screen readers still announce its text.
  • errors: The cart's errors, including the selected variant's line errors. Render them next to the form.

Render prices from the Storefront API fields, not from values that you compute. Show selectedVariant.price, and fall back to product.priceRange.minVariantPrice when no variant is selected. Format every price with Hydrogen's formatMoney(), and pass the locale of the customer's market instead of en-US. Don't build price strings yourself, like '$' + amount or Number(amount).toFixed(2).

To add line item attributes, like a gift message, spread register('attributeValue', {key, value}) on an input. Keys can't be empty, and Shopify reserves keys that start with _ for internal use.

openCartDrawer opens the cart drawer as soon as the customer submits, so they see the optimistic cart. To build the drawer, refer to Build a cart drawer. To add accelerated checkout next to the button, refer to Shop Pay.


Anchor to Step 6: Test the product pageStep 6: Test the product page

Check the following behavior on a product with several options:

  • Opening /products/{handle}?Color=Blue&Size=M renders the blue, medium variant, with the correct price.
  • Resolved selections update the price and the URL without a full page load or a scroll jump.
  • The browser's back button leaves the product page, instead of stepping through each selection.
  • Sold-out values stay selectable and show as sold out. Combinations that don't exist are disabled.
  • With JavaScript disabled, option links load the selected variant, and the add-to-cart form adds it to the cart.
  • A product with a single variant shows no option picker.
  • Search parameters that aren't options, like utm_source, stay in the URL after a selection.


Was this page helpful?