Tiga bab terakhir soal nemuin produk. Sekarang kita masuk ke halaman produknya sendiri, dan hal pertama yang bikin pusing di sana: varian.
Produk bisa dipecah jadi maksimal tiga opsi (Size, Color, Material), dan satu kombinasi nilai opsi itu satu varian. Kaos ukuran S/M/L dan warna Black/White/Red punya 9 varian, S/Black salah satunya. Yang dibeli pembeli itu varian, bukan produk, jadi form add to cart harus ngirim ID varian.
Object yang dipakai udah dibahas propertinya di 14 Objects Produk dan Koleksi > 5. Varian, 14 Objects Produk dan Koleksi > 6. Opsi, dan 14 Objects Produk dan Koleksi > Object Opsi. Bab ini soal cara ngerakitnya, dan soal perubahan besar: produk sekarang bisa punya ribuan varian, dan pola lama gak jalan lagi.
Dua Komponen yang Wajib
- Penanganan deep link varian: produk bisa dilink langsung ke varian tertentu, dan halaman harus nampilin info varian itu.
- Pemilih varian: pembeli harus bisa jelajah varian dengan mudah, dan memilih varian harus ngupdate halaman dengan info varian itu.
Dua-duanya ditaruh di section yang dipakai template produk (JSON atau Liquid).
Deep Link Varian
1. Format ?variant=
Link produk dengan parameter ?variant=[variant-id], misalnya /products/kaos?variant=39072856. Ini yang dipakai email marketing, iklan, dan kartu produk di koleksi yang udah difilter varian (37 Filtering > 2. Filter Level Varian).
Varian yang dilink diakses lewat product.selected_variant. Tapi link produk gak selalu bawa varian, jadi defaultnya pakai product.selected_or_first_available_variant: varian yang dipilih kalau ada, kalau gak, varian pertama yang tersedia, kalau gak ada yang tersedia, varian pertama.
2. Format ?option_values=
Format baru: ?option_values=[id-1],...,[id-N] dengan ID nilai opsi (product_option_value.id), satu per opsi, dan urutannya harus sama dengan urutan opsi produk:
GET /products/pants-green?option_values=<option_value_id_1>,...,<option_value_id_N>Bedanya sama ?variant=: kamu milih nilai opsi, bukan varian. Kalau kombinasi nilai opsi itu gak punya varian (misalnya Red/XL gak pernah dibikin), nilai opsinya tetap terpilih di UI, tapi selected_variant dan selected_or_first_available_variant dua-duanya null. Ini penting: kode kamu harus siap nerima nil dari selected_or_first_available_variant, yang di era ?variant= gak pernah terjadi.
3. Yang Harus Ngikutin Varian
Setelah tau varian mana yang ditampilin, tiga elemen harus mencerminkannya:
- Media produk (gambar/video varian).
- Harga.
- Pemilih varian (state terpilih).
Contoh dasar yang ngerjain ketiganya:
{% assign current_variant = product.selected_or_first_available_variant %}
<!-- Product media -->
{% assign featured_media = current_variant.featured_media %}
{% case featured_media.media_type %}
{% when 'image' %}
<div class="product-single__media"
style="padding-top: {{ 1 | divided_by: featured_media.aspect_ratio | times: 100 }}%;"
data-media-id="{{ featured_media.id }}"
>
{{ featured_media | image_url: width: 2048 | image_tag }}
</div>
{% when 'external_video' %}
<div class="product-single__media"
style="padding-top: {{ 1 | divided_by: featured_media.aspect_ratio | times: 100 }}%;"
data-media-id="{{ featured_media.id }}"
>
{{ featured_media | external_video_tag }}
</div>
{% when 'video' %}
<div class="product-single__media" data-media-id="{{ featured_media.id }}">
{{ featured_media | video_tag: controls: true }}
</div>
{% when 'model' %}
<div class="product-single__media"
style="padding-top: 100%;"
data-media-id="{{ featured_media.id }}"
>
{{ featured_media | model_viewer_tag }}
</div>
{% else %}
<div class="product-single__media"
style="padding-top: 100%;"
data-media-id="{{ featured_media.id }}"
>
{{ featured_media | media_tag }}
</div>
{% endcase %}
<!-- Product price -->
<div class="price">
<span class="price-reg">{{ current_variant.price | money }}</span>
{% if current_variant.compare_at_price > current_variant.price %}
<span class="price-sale"><s>{{ current_variant.compare_at_price | money }}</s></span>
{% endif %}
</div>
<!-- Variant selector -->
<select name="id">
{% for variant in product.variants %}
<option value="{{ variant.id }}"
{% if variant == current_variant %}selected="selected"{% endif %}
>
{{ variant.title }} - {{ variant.price | money }}
</option>
{% endfor %}
</select>Tiga bagiannya:
- Media:
current_variant.featured_mediadi-casepermedia_type, dengan filter media dari 19 Filters HTML, URL, Media, Color, dan Font > Filter Media.padding-topdariaspect_ratiobuat nyediain ruang sebelum gambar dimuat (nyegah layout shift, dibahas di 62 Performance Mobile dan Layout). Detail media di 40 Produk Media. - Harga:
pricedancompare_at_pricevarian (bukan produk), dengan coret kalau lagi diskon. Detail harga di 42 Harga Diskon, Currency, Unit Pricing, Installments, B2B. - Selector:
<select name="id">, satu<option>per varian,selectedbuat yang aktif. Namaiditu yang dibaca form produk sebagai varian yang ditambahin ke cart.
Pemilih Varian
Satu <select> dengan semua varian (kaya contoh di atas) itu jalan, tapi produk biasanya punya lebih dari satu opsi. Buat pengalaman pembeli yang lebih baik dan buat menghindari masalah kompatibilitas ke depan (ini kata Shopify sendiri, dan alasannya di bagian high-variant di bawah), tampilin tiap opsi secara terpisah: satu selector buat Size, satu buat Color. Caranya lewat product.options_with_values, terus JavaScript ngupdate state waktu nilai opsi dipilih.
Apapun pendekatannya, waktu varian baru dipilih, media dan harga harus diupdate. Pemilih varian juga bisa dipakai di kartu produk grid atau quick view di halaman koleksi. Contoh Dawn: snippets/product-variant-picker.liquid dan assets/global.js.
❌ satu dropdown gabungan, susah dipakai kalau 3 opsi × banyak nilai
<select name="id">
{% for variant in product.variants %}
<option value="{{ variant.id }}">{{ variant.title }}</option>
{% endfor %}
</select>✅ satu kontrol per opsi
{% for option in product.options_with_values %}
<fieldset>
<legend>{{ option.name }}</legend>
{% for value in option.values %}
<input type="radio" name="{{ option.name }}" value="{{ value | escape }}" {% if value.selected %}checked{% endif %}>
<label>{{ value }}</label>
{% endfor %}
</fieldset>
{% endfor %}High-variant Products
Ini bagian yang ngubah cara berpikir. Dulu theme ngeload semua varian produk waktu render (loop product.variants, atau product | json ke JavaScript, terus JavaScript nyari varian yang cocok sama kombinasi opsi). Makin banyak varian, makin lambat render-nya, dan itu kritis buat produk dengan lebih dari 250 varian dan buat combined listings.
Jadi Shopify ngebatasin product.variants jadi maksimal 250, dan nambahin API baru buat kasus yang dulu butuh semua varian. Kalau theme kamu punya kode yang nganggap semua varian ada, audit sekarang.
1. Object product_option_value
Dari 14 Objects Produk dan Koleksi > 2. product_option_value, ini field yang relevan dan kegunaannya:
available: apakah nilai opsi ini punya varian yang tersedia, dalam konteks nilai yang udah dipilih di opsi sebelumnya. Buat UX ketersediaan “top-down”.id: dipakai bareng?option_values=waktu ngerender ulang section pemilih.variant: varian yang cocok dengan nilai ini digabung nilai opsi lain yang lagi dipilih (kalau ada). Akses langsung ke varian tanpa loop, danvariant.available-nya bisa dipakai buat ketersediaan.selected: lagi dipilih atau gak, penggantiproduct_option.selected_value == option_value.product_url: URL produk lain yang terkait nilai ini, buat combined listings.
2. option_values di URL Produk
Kunci buat ndukung lebih banyak varian: tunda load info varian sampai dibutuhin. Parameter option_values di URL produk, dikombinasiin sama product_option_value, bikin klien cuma ngeload varian yang relevan sama state pilihan sekarang. Ingat peringatannya: kombinasi tanpa varian → selected_variant dan selected_or_first_available_variant null.
3. Pola Kode yang Bermasalah
Kode Liquid apapun yang butuh set lengkap varian gak bakal jalan sesuai harapan di atas 250 varian. Contoh klasik: hidden select di form produk yang niatnya ngeloop semua varian, kenyataannya cuma 250 pertama:
{% form 'product', product, id: product_form_id %}
<select name="id" style="display: none;">
{%- for variant in product.variants -%}
<option
value="{{ variant.id }}"
{% if product.selected_or_first_available_variant.id == variant.id %}
selected="selected"
{% endif %}
>
{{ variant.title }}
</option>
{% endfor %}
</select>
{% endform %}Varian ke-251 dan seterusnya gak bisa dibeli lewat form ini. Tapi gak semua kode varian harus diubah: product.variants.first atau product.variants.size > 1 tetap jalan karena gak bergantung set lengkap.
Yang perlu diaudit: pemakaian product.variants dan product | json. Cari kesempatan buat ngilangin overfetching atau nunda load info varian pakai API baru, Storefront API, atau Ajax API /products/handle.js dari 51 Ajax API dan Section Rendering API.
Dan satu kabar yang ngelegain: theme gak lagi wajib ndukung pengguna yang matiin JavaScript. Kode fallback <noscript> buat pemilih varian bisa dihapus.
4. Ngerender Nilai Opsi
Pakai product.options_with_values buat opsi, dan product_option_value (dari option.values) buat nilainya:
{%- for option in product.options_with_values -%}
{%- for option_value in option.values -%}
<input
id="{{ section.id }}-{{ option.position }}-{{ forloop.index0 -}}"
type="radio"
name="{{ option.name }}-{{ option.position }}"
value="{{ option_value | escape }}"
{% if option_value.selected %}
checked
{% endif %}
{% unless option_value.available %}
class="disabled"
{% endunless %}
/>
<label for="{{ section.id }}-{{ option.position }}-{{ forloop.index0 -}}">
{{ option_value -}}
</label>
{%- endfor -%}
{%- endfor -%}{{ option_value }} langsung ngeluarin nama nilainya. id input dirakit dari section.id, posisi opsi, dan indeks biar unik walaupun section-nya dipakai dua kali di halaman. Buat swatch warna/gambar, pakai option_value.swatch (object swatch dari 14 Objects Produk dan Koleksi > 4. swatch), panduannya di Getting started with swatches.
5. Ketersediaan: Top-down vs Adjacent
Daripada loop semua varian buat tau nilai mana yang bisa dibeli, pakai salah satu:
product_option_value.available→ UX top-down: nilai opsi dianggap pohon. Nilai tersedia kalau ada bagian dari subtree-nya yang bisa dibeli. Pilih Red → Size cuma nyala buat ukuran yang ada varian Red-nya → Material cuma nyala buat yang ada Red+Size itu. Urutan opsi jadi penting.variant.available(lewatproduct_option_value.variant) → UX adjacent: nilai opsi dianggap node graf. Nilai tersedia kalau node itu sendiri (kombinasi nilai ini + pilihan sekarang di opsi lain) bisa dibeli. Gak peduli urutan opsi.
Dua-duanya sah, tergantung UX yang kamu mau. Yang penting: gak ada loop 250 varian.
Waktu pembeli milih nilai opsi, pakai product_option_value.id buat request ke Section Rendering API dan ngeload state ketersediaan berikutnya:
<input
...
{% unless option_value.available %}
class="disabled"
{% endunless %}
data-option-value-id="{{ option_value.id }}"
/>
...
<script>
function onVariantChange(event) {
// Assumes the option value picker is wrapped in a section with a class optionValueSelectors with a section-id data attribute
const optionValueSelectors = event.target.closest('.optionValueSelectors');
const sectionId = optionValueSelectors.dataset.sectionId;
// Get the selected option value IDs and format as query param
const selectedOptionValues = Array.from(
optionValueSelectors.querySelectorAll('input[type="radio"]:checked')
).map(({dataset}) => dataset.optionValueId);
const params = selectedOptionValues.length > 0
? `&option_values=${selectedOptionValues.join(',')}`
: '';
// Fetch the product section with the new option value selection, and replace the option value picker with the new availability state
fetch(`${window.location.href}?section_id=${sectionId}${params}`)
.then((response) => response.text())
.then((responseText) => {
const html = new DOMParser().parseFromString(responseText, 'text/html');
optionValueSelectors.innerHTML = html.querySelector('.optionValueSelectors').innerHTML;
document.querySelector(`#${event.target.id}`).focus();
});
}
</script>Alurnya: kumpulin ID nilai opsi yang tercentang → request halaman produk dengan section_id dan option_values → server ngerender ulang section dengan selected dan available yang baru → ganti isi pemilih → balikin fokus ke input yang diklik (karena DOM-nya diganti, fokus hilang). Ini pola yang sama dengan predictive search di 38 Search dan Predictive Search > 5. Fungsi JavaScript, dan versi deklaratifnya bakal jadi {% partial %} di 35 Developer Preview tag {% block %} dan {% partial %}.
Kenapa server yang ngitung? Karena cuma server yang tau semua varian, dan dia ngitungnya efisien. Theme cuma nampilin hasilnya.
6. Combined Listings (Opsional)
Combined listing itu satu produk induk (“Pants”) yang nampilin beberapa produk anak (“Pants - red”, “Pants - green”) seolah-olah satu produk dengan opsi warna. Waktu pembeli ganti warna, UI harus nampilin info produk anak yang sesuai: harga, gambar, deskripsi. Caranya lewat product_option_value.product_url:
<!-- Add product url to option value input -->
<input
type="radio"
id="option_value_1"
name="{{ option.name }}"
value="{{ option_value | escape }}"
data-product-url="{{ option_value.product_url }}"
...
/>
...
<script>
...
function onVariantChange(event) {
const {productUrl} = event.target.dataset;
// Fetch the product for the selected option value
fetch(`${productUrl}?section_id=${sectionId}${params}`)
.then((response) => response.text())
.then((responseText) => {
// Update the product section and any related sections
});
}
</script>Bedanya sama sebelumnya: request-nya ke URL produk anak, bukan window.location.href, dan yang diganti seluruh section produk, bukan cuma pemilihnya.
7. Contoh Utuh
Ngerangkai semuanya: load set varian minimal lewat product_option_value.variant, Section Rendering API buat state berikutnya, dan ganti seluruh produk kalau pindah ke saudara di combined listing:
<section id="product" data-product-url="{{ product.url }}" data-section-id="{{ section.id }}">
<h2>{{ product.title }}</h2>
<div class="price">
{{ product.price | money }}
</div>
<!-- ...other product info... -->
<div class="optionValueSelectors">
<!-- Use options_with_values to render options -->
{%- for option in product.options_with_values -%}
<fieldset class="option">
<legend>{{ option.name }}</legend>
{%- for option_value in option.values -%}
{%- capture input_id -%}
{{ section.id }}-{{ option.position }}-{{ forloop.index0 -}}
{%- endcapture -%}
<!-- Use product_option_value` fields to simplify option value input rendering and support combined listings -->
<input
type="radio"
id="{{ input_id }}"
name="{{ option.name }}"
value="{{ option_value | escape }}"
{% if option_value.selected %}
checked
{% endif %}
{% unless option_value.available %}
class="disabled"
{% endunless %}
data-product-url="{{ option_value.product_url }}"
data-option-value-id="{{ option_value.id }}"
/>
<label for="{{ input_id }}">
{{ option_value -}}
</label>
{%- endfor -%}
</fieldset>
{%- endfor -%}
</div>
</section>
{% javascript %}
...
function onVariantChange(event) {
const productElement = event.target.closest('#product');
const sectionId = productElement.dataset.sectionId;
// Combined listings: We use old and new product urls to determine if the product should change
const oldProductUrl = productElement.dataset.productUrl;
const newProductUrl = optionValueElement.dataset.productUrl;
// Get the selected option value IDs and format as query param
const selectedOptionValues = Array.from(
optionValueSelectors.querySelectorAll('fieldset input:checked')
).map(({dataset}) => dataset.optionValueId);
const params = selectedOptionValues.length > 0
? `&option_values=${selectedOptionValues.join(',')}`
: '';
// Deferred variants: Fetch the option value picker with the new availability state when remaining on the same product
// Combined listings: Fetch the product associated with the selected option value and replace the entire product if switching to a sibling product
fetch(`${newProductUrl}?section_id=${sectionId}${params}`)
.then((response) => response.text())
.then((responseText) => {
const html = new DOMParser().parseFromString(responseText, 'text/html');
// Combined listings: If the product changed, replace the old product section with the new product section
if (newProductUrl && oldProductUrl !== newProductUrl) {
productElement.parentNode.insertBefore(html.querySelector('#product'), productElement);
productElement.remove();
// Focus the input for the last clicked option value
document.querySelector(`#${event.target.id}`).focus();
return;
}
const optionValueSelectors = event.target.closest('.optionValueSelectors');
optionValueSelectors.innerHTML = html.querySelector('.optionValueSelectors').innerHTML;
document.querySelector(`#${event.target.id}`).focus();
// Update any other sections that depend on the option value picker
});
}
{% endjavascript %}Perhatiin {% javascript %} dari 12 Tags Khusus Theme: script-nya dibundel Shopify ke satu file dan cuma dimuat sekali walaupun section-nya dipakai berkali-kali.
Dan di form add to cart, varian yang dikirim sekarang cukup:
{% assign current_variant = product.selected_or_first_available_variant %}
{% form 'product', product %}
<input type="hidden" name="id" value="{{ current_variant.id }}">
<button {% unless current_variant.available %}disabled{% endunless %}>
{% if current_variant == nil %}Unavailable{% elsif current_variant.available %}Add to cart{% else %}Sold out{% endif %}
</button>
{% endform %}Satu hidden input, diganti isinya waktu section dirender ulang. Gak ada 250 <option>.
Ringkasan
- Varian = kombinasi maksimal tiga opsi, yang dibeli pembeli adalah ID varian lewat
name="id". - Deep link
?variant=ID→product.selected_variant, default keselected_or_first_available_variant. Format baru?option_values=id1,id2,id3(satu per opsi, urut) bisa ngasilinnullbuat dua-duanya kalau kombinasinya gak punya varian. - Waktu varian berubah, tiga hal harus ikut: media (
featured_mediadi-caseper tipe), harga (pricedancompare_at_pricevarian), dan state pemilih. - Tampilin tiap opsi terpisah pakai
options_with_values, bukan satu dropdown semua varian. product.variantsdibatasi 250. Auditproduct.variantsdanproduct | json, tunda load varian sampai dibutuhin.variants.firstdanvariants.sizetetap aman. Fallback no-JavaScript boleh dihapus.product_option_value:available,id,variant,selected,product_url,swatch.- Ketersediaan tanpa loop:
option_value.available(top-down, pohon, urutan opsi penting) atauoption_value.variant.available(adjacent, graf). - Section Rendering API buat state berikutnya: request
?section_id=X&option_values=..., ganti isi pemilih, balikin fokus. - Combined listings:
product_urlbeda → request ke produk anak dan ganti seluruh section produk.
Bab selanjutnya kita bahas media produk: gambar, video Shopify, video eksternal (YouTube/Vimeo), dan model 3D, cara ngerendernya, galeri yang ngikutin varian, dan pedoman UX-nya.