E-commerce sites usually have a form on the product detail page where options are submitted to the server when the user adds to the cart.
The most basic product form provides the ability to select from available variants. For example, variants might include product size or color:
This basic use-case comes with a few challenges:
- Variant Availability - Sometimes each variant option is only selectable if the product is available.
- Default Selection - When the page is first loaded, some merchants want the first available variant to be selected by default. Other merchants force the user to make a variant selection before the add to cart button is enabled.
While it may be tempting to build this variant selection with radio buttons, we feel it is a better user experience to use links. Selecting an option with the underlying HTML element as an <a href="">
has the following benefits:
- The page is immediately interactive when visible. On a slow connection, you don't need to wait for the page to finish loading to select a variant.
- The URL is automatically updated and unique per variation. Search engines can index each variation separately.
- The user can share and bookmark the exact selected variation with a friend via the URL.
- The variation can be prefetched on hover, increasing the percieved load time.
- The user can right click and open each variation in a new window or tab.
We make it easier to render product variations with the <VariationSelector />
component:
import {VariantSelector} from '@shopify/hydrogen';
const ProductForm = ({product}) => {
return (
<VariantSelector options={product.options} variants={product.variants}>
{({option}) => (
<>
<div>{option.name}</div>
<div>
{option.values.map(({value, isAvailable, path, isActive}) => (
<Link
to={path}
prefetch="intent"
className={
isActive ? 'active' : isAvailable ? '' : 'opacity-80'
}
>
{value}
</Link>
))}
</div>
</>
)}
</VariantSelector>
);
};
The VariantSelector
component calculates availability by joining the options
and variants
props. But if your product has many variants, it may not be feasible to query all variants. If an associated variant
is not found for a one of the options, it is assumed to be available.
Sometimes the product form should have a default variant selected, so that the user can immediately add to cart without selecting a variant. You can do this by passing defaultVariant
to <VariantSelector>
. We also provide a helper getFirstAvailableVariant
to make it easier:
import {VariantSelector, getFirstAvailableVariant} from '@shopify/hydrogen';
const ProductForm = ({product}) => {
const defaultVariant = getFirstAvailableVariant(product);
return (
<VariantSelector
options={product.options}
variants={product.variants}
defaultVariant={defaultVariant}
>
{({option}) => (
<>
<div>{option.name}</div>
<div>
{option.values.map(({value, isAvailable, path, isActive}) => (
<Link
to={path}
prefetch="intent"
className={
isActive ? 'active' : isAvailable ? '' : 'opacity-80'
}
>
{value}
</Link>
))}
</div>
</>
)}
</VariantSelector>
);
};
Within your loaders, we provide a utility to parse selected options from the request URL search params:
import {getSelectedProductOptions} from '@shopify/hydrogen';
export async function loader({request, params}) {
const selectedOptions = getSelectedProductOptions(request);
const {product} = await context.storefront.query(PRODUCT_QUERY, {
variables: {
handle: params.productHandle,
selectedOptions,
},
});
return json({product});
}
Now that we pass the selectedOptions
into the PRODUCT_QUERY
, we can update that query to use variantBySelectedOptions
to query the selected variant:
const PRODUCT_QUERY = `#graphql
query Product(
$handle: String!
$selectedOptions: [SelectedOptionInput!]!
) {
product(handle: $handle) {
id
title
vendor
handle
descriptionHtml
description
options {
name
values
}
selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {
...ProductVariantFragment
}
media(first: 7) {
nodes {
...Media
}
}
variants(first: 10) {
nodes {
...ProductVariantFragment
}
}
seo {
description
title
}
}
}
Use the CartForm
component with the product.selectedVariant
to create an Add to Cart button. Usually the button should also be disabled if the selectedVariant is invalid or unavailable.
import {
VariantSelector,
getFirstAvailableVariant,
CartForm,
} from '@shopify/hydrogen';
const ProductForm = ({product}) => {
const defaultVariant = getFirstAvailableVariant(product);
return (
<>
<VariantSelector
options={product.options}
variants={product.variants}
defaultVariant={defaultVariant}
>
{({option}) => (
<>
<div>{option.name}</div>
<div>
{option.values.map(({value, isAvailable, path, isActive}) => (
<Link
to={path}
prefetch="intent"
className={
isActive ? 'active' : isAvailable ? '' : 'opacity-80'
}
>
{value}
</Link>
))}
</div>
</>
)}
</VariantSelector>
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesAdd}
lines={[
{
merchandiseId: product.selectedVariant?.id,
},
]}
>
<button
disabled={
!product.selectedVariant?.id ||
!product.selectedVariant?.availableForSale
}
>
Add to Cart
</button>
</CartForm>
</>
);
};
Some product properties should not be apart of the URL. These should be added to your CartForm
as normal form inputs:
import {VariantSelector, CartForm} from '@shopify/hydrogen';
const ProductForm = ({product}) => {
const [quantity, setQuantity] = useState(1);
return (
<>
<VariantSelector options={product.options} variants={product.variants}>
{({option}) => (
<>
<div>{option.name}</div>
<div>
{option.variants.map(({value, isAvailable, path}) => (
<Link
to={path}
prefetch="intent"
className={isAvailable ? '' : 'opacity-80'}
>
{value}
</Link>
))}
</div>
</>
)}
</VariantSelector>
<label>
Quantity:
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</label>
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesAdd}
lines={[
{
merchandiseId: product.selectedVariant.id,
quantity,
},
]}
>
<button>Add to Cart</button>
</CartForm>
</>
);
};
per variant …… *without using JavaScript
selected variant
decreasing, perceived
each variant