Solving Shopify RTL Swatch Issues: A Deep Dive into Multi-Language Variant Mapping with Storefront API
Hey everyone! As a Shopify expert and someone who spends a lot of time digging through community discussions, I often see incredibly specific and complex issues that can stump even experienced developers. Today, I want to tackle a fascinating challenge brought up by a store owner, dorame, in a recent thread. It's a perfect example of how combining custom code, the Storefront API, and multi-language support can create some unique headaches.
The Head-Scratcher: RTL Swatches & Variant Mapping Gone Awry
dorame came to the community with a classic multi-language conundrum: their product variant swatches worked perfectly in English (LTR), but as soon as they switched the storefront to Arabic (RTL), everything broke. Swatches disappeared, variant mapping seemed off, and selected options weren't resolving to the correct variant. This wasn't just any store; it was a setup using the Shopify Storefront API to inject variants beyond the standard 250 variant limit, making things a bit more intricate.
Here's a quick rundown of dorame's setup and observations:
- Storefront built with Shopify Storefront API.
- Products have over 250 variants, with additional variants loaded dynamically via the API.
- Swatches function perfectly in English (LTR).
- The Problem: In Arabic (RTL), variant swatches fail, mapping is broken, and selected options don't resolve. Some swatches even vanish.
- Key Suspicions: Option names/values might be getting translated differently in Arabic, variant matching logic relies on English names/values, and injected variants from the API might not align with translated labels. The RTL direction itself could also be playing a role in rendering.
This is a common pitfall when you're dealing with custom variant pickers and internationalization. Shopify's native localization handles many things beautifully, but when you introduce custom JavaScript to manage variants, especially with API fetches, you need to be incredibly precise about how you handle localized data versus canonical identifiers.
Peeling Back the Layers: The Code in Question
dorame generously shared their custom JavaScript (variant-selects.js, a custom web component) and a Liquid snippet (product-variant-picker.liquid) that orchestrates the variant selection and swatch injection. Let's look at some key parts.
The VariantSelects Web Component (variant-selects.js)
This component is the brain behind the variant selection on the product page. It initializes options, handles changes, filters available variants, and applies fallbacks. What immediately stands out is how it tries to accommodate both English and Arabic option names:
class VariantSelects extends HTMLElement {
constructor() {
super();
this.variant = this.variant ?? JSON.parse(this.querySelector('variant-selects [data-selected-variant]')?.innerHTML);
this.sizeOpti label, fieldset[data-name="\u0645\u0642\u0627\u0633"] label')];
this.colorOpti label, fieldset[data-name="\u0644\u0648\u0646"] label')];
}
connectedCallback() {
const section = this.closest('section');
const productInfo = this.closest('product-info');
const selectBtn = productInfo?.querySelector('.btn-select-size');
const sizePopup = productInfo?.querySelector('.size-secection');
const closeBtn = productInfo?.querySelector('.close-size');
// Restore last-picked size
if (window.sizeSelected) {
console.log(window.sizeSelected, "window.sizeSelected");
const field = this.querySelector(
`fieldset[data-name="Size"] input[value="${window.sizeSelected}"],
fieldset[data-name="\u0645\u0642\u0627\u0633"] input[value="${window.sizeSelected}"]`
);
if (field) field.checked = true;
}
// Clone blocks for mobile
this.linkedTo = [...productInfo.querySelectorAll('[data-linked-to]')];
this.linkedTo.forEach(block => {
const toCl
let name = block.dataset.linkedTo;
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
if (name === '\u0644\u0648\u0646' || name === 'Color') {
name = 'color';
}
} else {
name = name.toLowerCase();
}
this[`${name}Options`] = [
...(this[`${name}Options`] || []),
...toClone.querySelectorAll('label')
];
toClone.querySelectorAll('[id]').forEach(el => {
el.id += 'cloned';
el.name += 'cloned';
});
block.innerHTML = '';
[...toClone.childNodes].reverse().forEach(n => toClone.append(n));
block.appendChild(toClone);
});
// \u2705 Assign loader early \u2014 showLoader() in the change handler needs it
this.loader = productInfo.querySelector('.loader');
const _selectedVariantData = JSON.parse(
this.querySelector('[data-selected-variant]')?.innerHTML || 'null'
);
const _pendingColor = _selectedVariantData?.options?.[1];
const runInitWithColorRestore = () => {
if (!this.dataset.combined) {
// \u2705 skipFilter=true \u2014 we handle filterOptions ourselves after re-cloning
window.injectMissingColorSwatches?.(true);
// \u2705 Re-clone mobile color fieldset AFTER injection so it includes the new swatches
this.linkedTo?.forEach(linkedBlock => {
const fieldsetName = linkedBlock.dataset.linkedTo;
if (fieldsetName !== 'Color' && fieldsetName !== '\u0644\u0648\u0646') return;
const sourceFieldset = this.querySelector(`fieldset[data-name="${fieldsetName}"]`);
if (!sourceFieldset) return;
const toCl
toClone.querySelectorAll('[id]').forEach(el => {
el.id += 'cloned';
el.name += 'cloned';
});
linkedBlock.innerHTML = '';
[...toClone.childNodes].reverse().forEach(n => toClone.append(n));
linkedBlock.appendChild(toClone);
});
// Restore checked color before filterOptions runs
if (_pendingColor) {
const colorInput = this.querySelector(
`fieldset[data-name="Color"] input[value="${_pendingColor}"],
fieldset[data-name="\u0644\u0648\u0646"] input[value="${_pendingColor}"]`
);
if (colorInput && !colorInput.checked) {
colorInput.checked = true;
}
}
this.filterOptions();
}
// \u2705 Section only becomes visible AFTER correct variant state is fully applied \u2014 no flicker
if (!section.classList.contains('show')) {
section.classList.add('show');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
this.removeLoader();
this.applyFallback();
requestAnimationFrame(() => {
const submitBtn = this.closest('product-info')?.querySelector('button.product-form__submit');
if (submitBtn) submitBtn.style.opacity = '1';
});
};
if (window.ShopifyProduct._loading) {
document.addEventListener('shopify:variants:loaded', runInitWithColorRestore, { once: true });
} else {
runInitWithColorRestore();
}
// Update size label if already selected
if (window.sizeSelected && selectBtn) {
selectBtn.textC
selectBtn.classList.add('selected');
}
// Size popup toggle
if (selectBtn && !selectBtn.classList.contains('bound')) {
selectBtn.addEventListener('click', () => {
sizePopup.style.display = 'block';
});
selectBtn.classList.add('bound');
}
if (closeBtn && !closeBtn.classList.contains('bound')) {
closeBtn.addEventListener('click', () => {
sizePopup.style.display = 'none';
});
closeBtn.classList.add('bound');
}
this.addEventListener('change', event => {
const t = this.getInputForEventTarget(event.target);
const fs = t.closest('fieldset');
const name = fs?.dataset.name;
if (name === 'Gender' || name === '\u062c\u0646\u0633') {
window.sizeSelected = '';
this.querySelectorAll('fieldset[data-name="Size"] input[type="radio"], fieldset[data-name="\u0645\u0642\u0627\u0633"] input[type="radio"]').forEach(r => r.checked = false);
if (selectBtn) {
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
selectBtn.textC;
} else {
selectBtn.textC;
}
selectBtn.classList.remove('selected');
}
sizePopup.style.display = 'none';
section.classList.remove('show');
this.showLoader();
}
if (name === 'Color' || name === '\u0644\u0648\u0646') {
window.sizeSelected = '';
section.classList.remove('show');
window.removeColorLoader?.();
}
if (name === 'Size' || name === '\u0645\u0642\u0627\u0633') {
const headerATC = document.querySelector('.product-form-btn');
const submitBton = document.querySelector('.product-form-btn button[type="submit"]');
const spinLoad = document.querySelector('.spin-load');
const spinner = document.querySelector(".product-form-btn .loading__spinner");
if (submitBton) {
submitBton.classList.add("hidden");
spinLoad.classList.remove("hidden");
spinner?.classList.remove("hidden");
headerATC.style.textAlign = 'center';
setTimeout(() => {
headerATC.style.textAlign = '';
submitBton.classList.remove("hidden");
spinLoad.classList.add("hidden");
spinner?.classList.add("hidden");
}, 1200);
}
if (!t.classList.contains("disabled")) {
window.sizeSelected = t.value;
if (selectBtn) {
selectBtn.textC
selectBtn.classList.add('selected');
}
sizePopup.style.display = 'none';
} else {
window.sizeSelected = '';
if (selectBtn) {
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
selectBtn.textC;
} else {
selectBtn.textC;
}
selectBtn.classList.remove('selected');
}
}
if (t.classList.contains("disabled")) {
document.body.classList.add("loader-active");
waitForElementAndClick(".gw-button-widget, .gw-float-widget");
}
function waitForElementAndClick(selector, maxAttempts = 10, interval = 200) {
let attempts = 0;
const checkAndClick = () => {
const element = document.querySelector(selector);
if (element && typeof element.click === 'function') {
try {
element.click();
document.body.classList.remove("loader-active");
return true;
} catch (error) {
console.warn("Click failed, retrying...", error);
}
}
if (attempts < maxAttempts) {
attempts++;
setTimeout(checkAndClick, interval);
} else {
console.error("Failed to find or click the element after maximum attempts.");
}
};
checkAndClick();
}
}
// \u2705 KEY FIX: if variants are still loading, wait then re-run filterOptions
const runFilter = () => {
if (!this.dataset.combined) this.filterOptions();
this.applyFallback();
};
if (window.ShopifyProduct._loading) {
document.addEventListener('shopify:variants:loaded', runFilter, { once: true });
} else {
runFilter();
}
this.updateSelectionMetadata(event);
publish(PUB_SUB_EVENTS.optionValueSelectionChange, {
data: {
event,
target: t,
selectedOptionValues: this.selectedOptionValues,
selectedOptions: this.selectedOptions,
}
});
});
// Observer for stock fallback
const desktopBtn = productInfo.querySelector('button.product-form__submit');
if (desktopBtn) new MutationObserver(() => {
let { Gender, Color } = this.selectedOptions;
let g = Gender || this.selectedOptions["\u062c\u0646\u0633"];
let c = Color || this.selectedOptions["\u0644\u0648\u0646"];
const inStock = (window.ShopifyProduct?.variants || []).filter(v => v.options[0] === g && v.options[1] === c && v.available);
if (inStock.length > 0 && desktopBtn.disabled) this.applyFallback();
}).observe(desktopBtn, { attributes: true, childList: true, subtree: true });
document.querySelectorAll('form.add-to-cart-form button').forEach(btn => {
new MutationObserver(() => {
let { Gender, Color } = this.selectedOptions;
let g = Gender || this.selectedOptions["\u062c\u0646\u0633"];
let c = Color || this.selectedOptions["\u0644\u0648\u0646"];
const inStock = (window.ShopifyProduct?.variants || []).filter(v => v.options[0] === g && v.options[1] === c && v.available);
if (inStock.length > 0 && btn.disabled) this.applyFallback();
}).observe(btn, { attributes: true, childList: true, subtree: true });
});
}
filterOpti = true) => {
let { Gender, Color } = this.selectedOptions;
let g = Gender || this.selectedOptions["\u062c\u0646\u0633"];
let c = Color || this.selectedOptions["\u0644\u0648\u0646"];
const byG = ShopifyProduct.variants.filter(v => v.options[0] === g);
if (hide) {
const colors = [...new Set(byG.map(v => v.options[1]))];
const productInfo = this.closest('product-info');
// \u2705 Query BOTH original fieldset labels AND cloned mobile fieldset labels
this.colorOpti
...this.querySelectorAll('fieldset[data-name="Color"] label.color-swatch, fieldset[data-name="\u0644\u0648\u0646"] label.color-swatch'),
...(productInfo?.querySelectorAll('[data-linked-to="Color"] label.color-swatch, [data-linked-to="\u0644\u0648\u0646"] label.color-swatch') || [])
];
this.colorOptions.forEach(el => el.hidden = !colors.includes(el.dataset.value));
const sizes = [...new Set(byG.filter(v => v.options[1] === c).map(v => v.options[2]))];
// \u2705 Same for sizes
this.sizeOpti
...this.querySelectorAll('fieldset[data-name="Size"] label, fieldset[data-name="\u0645\u0642\u0627\u0633"] label'),
...(productInfo?.querySelectorAll('[data-linked-to="Size"] label, [data-linked-to="\u0645\u0642\u0627\u0633"] label') || [])
];
this.sizeOptions.forEach(el => el.hidden = !sizes.includes(el.dataset.value));
}
return true;
}
applyFallback() {
const variants = window.ShopifyProduct?.variants || [];
let { Gender, Color } = this.selectedOptions;
let g = Gender || this.selectedOptions["\u062c\u0646\u0633"];
let c = Color || this.selectedOptions["\u0644\u0648\u0646"];
if (!g || !c) return;
const inStock = variants.filter(v => v.options[0] === g && v.options[1] === c && v.available);
const fallback = inStock[0] || null;
const root = this.closest('product-info');
if (root) {
const desktopInput = root.querySelector('input.product-variant-id');
const desktopBtn = root.querySelector('button.product-form__submit');
if (desktopInput && desktopBtn) {
if (fallback) {
desktopInput.value = fallback.id;
desktopInput.disabled = false;
desktopBtn.disabled = false;
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
desktopBtn.querySelector('span').textC;
} else {
desktopBtn.querySelector('span').textC;
}
} else {
desktopInput.value = '';
desktopInput.disabled = true;
desktopBtn.disabled = true;
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
desktopBtn.querySelector('span').textC;
} else {
desktopBtn.querySelector('span').textC;
}
}
}
}
document.querySelectorAll('form.add-to-cart-form').forEach(form => {
const input = form.querySelector('input.product-variant-id');
const btn = form.querySelector('button');
if (!input || !btn) return;
if (fallback) {
input.value = fallback.id;
input.disabled = false;
btn.disabled = false;
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
btn.textC;
} else {
btn.textC;
}
} else {
input.value = '';
input.disabled = true;
btn.disabled = true;
if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
btn.textC;
} else {
btn.textC;
}
}
});
}
updateSelectionMetadata({ target }) {
const { value, tagName } = target;
if (tagName === 'SELECT' && target.selectedOptions.length) {
target.querySelectorAll('option').forEach(o => o.selected = false);
target.selectedOptions[0].selected = true;
const sw = target.closest('.product-form__input').querySelector('[data-selected-value]>.swatch');
const v = target.selectedOptions[0].dataset.optionSwatchValue;
if (sw) {
sw.style.setProperty('--swatch--background', v || 'unset');
sw.classList.toggle('swatch--unavailable', !v);
}
} else if (tagName === 'INPUT' && target.type === 'radio') {
const disp = target.closest('.product-form__input')?.querySelector('[data-selected-value]');
if (disp) disp.textC
}
}
showLoader() {
this.loader?.classList.add('active');
}
removeLoader() {
this.loader?.classList.remove('active');
}
getInputForEventTarget(t) {
return t.tagName === 'SELECT' ? t.selectedOptions[0] : t;
}
get selectedOptionValues() {
return Array.from(this.querySelectorAll('select option:checked, fieldset input:checked')).map(el => el.dataset.optionValueId);
}
get selectedOptions() {
return Array.from(this.querySelectorAll('select option:checked, fieldset input:checked')).reduce((a, el) => {
a[el.closest('fieldset').dataset.name] = el.value;
return a;
}, {});
}
}
customElements.define('variant-selects', VariantSelects);
The component correctly queries for fieldsets using both English and Arabic data-name attributes, which is a good start. However, the filterOptions and applyFallback methods still rely on comparing v.options[X] (from ShopifyProduct.variants) with values derived from el.dataset.value or el.value (from the DOM). This is where the inconsistency can creep in.
The Variant Injection Logic (product-variant-picker.liquid)
This Liquid file contains the crucial JavaScript for fetching additional variants via the Storefront API and the injectMissingColorSwatches function. This function is responsible for dynamically adding swatches for variants not initially loaded with the product payload.
{% comment %}
Renders product variant-picker
Accepts:
- product: {Object} product object.
- block: {Object} passing the block information.
- product_form_id: {String} Id of the product form to which the variant picker is associated.
Usage:
{% render 'product-variant-picker', product: product, block: block, product_form_id: product_form_id %}
{% endcomment %}
{%- unless product.has_only_default_variant -%}
{% if product.tags contains 'combined_listing' %}
{% endif %}
{% assign opti %}
{%- for option in options -%}
{%- liquid
assign swatch_count = option.values | map: 'swatch' | compact | size
assign picker_type = block.settings.picker_type | default: 'button'
if swatch_count > 0 and block.settings.swatch_shape != 'none'
if block.settings.picker_type == 'dropdown'
assign picker_type = 'swatch_dropdown'
else
assign picker_type = 'swatch'
endif
endif
-%}
{%- if picker_type == 'swatch' -%}
{%- elsif picker_type == 'button' -%}
The injectMissingColorSwatches function is critical here. It collects allColors from ShopifyProduct.variants (which has been augmented by the Storefront API data), compares them to existingColors (from DOM elements), and then creates new inputs and labels for any missing ones. The key is how color is used:
newInput.value = color;newLabel.dataset.value = color;- The
variants.findcall also usesv.opti color.
This means that the color variable needs to be consistent, whether it's coming from the initial Liquid payload or the Storefront API, and whether it's being compared to existing DOM elements.
The Root Cause: Inconsistent Option Value Mapping
After reviewing dorame's code and problem description, the most likely culprit is a mismatch in how variant option values are represented. Shopify's Storefront API and the raw product.variants data typically provide option values in their canonical, untranslated form (e.g., "Red", "Small"). However, if the Liquid templates or other parts of the JavaScript are rendering label.color-swatch elements with data-value attributes that contain *translated* option values (e.g., "أحمر" for Red), then the JavaScript logic will break.
Consider this: if ShopifyProduct.variants has v.opti, but an existing swatch label has data-value="أحمر", then existingColors.add(l.dataset.value) will add "أحمر" to the set. When missingColors is calculated, "Red" will be seen as a missing color (because "Red" is not equal to "أحمر"), leading to incorrect swatch injection and filtering.
The Solution: Embrace Canonical Values for Logic, Localized Values for Display
The fix revolves around ensuring that your JavaScript logic, especially for variant matching and filtering, always uses the untranslated, canonical option values. The display of these values in the UI (the text inside the label, or the alt attribute of an image) can and should be localized. Here's how to tackle it:
Step-by-Step Instructions:
-
Standardize
data-valueAttributes in Liquid:- In your
product-variant-picker.liquid(or the partial that renders individual swatches, likelyproduct-variant-options.liquid), ensure that thedata-valueattribute on yourelements always contains the untranslated variant option value. This is typically what comes inoption.valuewhen Liquid is processing product data directly from the backend, assuming your product options aren't manually translated in the Shopify admin itself. - Example: If your option value is "Red" in English, and "أحمر" in Arabic, the
data-valueshould consistently be "Red" regardless of the active language. The visible text inside the label can be{{ option.value | t }}or similar localized text.
- In your
-
Verify Storefront API Data Consistency:
- Confirm that the
node.selectedOptions[X].valuereturned by your Storefront API query always provides the untranslated, canonical variant option values. This is generally Shopify's default behavior, but it's good to double-check. - The
mapVariantfunction correctly usesnode.selectedOptions.map(function(o) { return o.value; }). Ensure theseo.values are indeed the untranslated ones.
- Confirm that the
-
Refine
injectMissingColorSwatches:- The existing logic for building
combosByGender,allColors,existingColors, andmissingColorsis correct *if* all `data-value` attributes in the DOM and all `v.optionX` values from the `ShopifyProduct.variants` array are consistently untranslated. - If you find that
el.dataset.value*is* translated in the DOM, you'll need a way to map it back to its canonical English equivalent before adding it toexistingColors, or ensure Liquid never renders a translated value intodata-value. The latter is strongly recommended. - When creating the
newInputandnewLabel, ensurenewInput.value = color;andnewLabel.dataset.value = color;both use the untranslatedcolorvalue derived fromallColors.
- The existing logic for building
-
Review
filterOptionsandapplyFallback:- These functions rely on
this.selectedOptions(which getsel.valuefrom the checked input) andel.dataset.valuefrom labels. Ensure these values are untranslated. - The comparisons like
v.options[0] === g && v.options[1] === cwill work correctly ifgandc(derived fromel.value/el.dataset.value) are untranslated, matching thev.optionsarray.
- These functions rely on
A good debugging strategy here would be to use console.log() extensively. In both English and Arabic modes, log the values of:
el.dataset.valuefor all swatches.v.option1,v.option2,v.optionsfromwindow.ShopifyProduct.variants.- The contents of
existingColors,allColors, andmissingColorsininjectMissingColorSwatches.
This will quickly reveal if there's a discrepancy between the values JavaScript is expecting and what it's actually getting from the DOM or the variant data.
It's a subtle but critical distinction: display strings can change, but the underlying data identifiers (your variant option values) should remain consistent for your logic to function across languages. dorame's code is already quite sophisticated in handling the dual names for fieldsets, which is great! The final piece of the puzzle is to ensure that the *values* themselves are handled with the same level of consistency for comparison logic. Once that's aligned, those Arabic swatches should pop right back into place, and your variant mapping will be solid, no matter the language or the number of variants you're juggling with the Storefront API.