I’m facing an issue with RTL/Arabic localization on Shopify storefront variant swatches.
Setup
-
Storefront uses Shopify Storefront API
-
Product has more than 250 variants
-
We inject/load the remaining variants separately through the Storefront API because of the variant limit
-
Swatches work correctly in English (LTR)
Problem
As soon as I switch the storefront language from English to Arabic (RTL):
-
Variant swatches stop showing correctly
-
Variant mapping seems broken
-
Selected options are not resolving to the correct variant
-
Some swatches disappear entirely
This only happens in Arabic/RTL. English works fine.
What I noticed
-
Option names/values may be getting translated differently in Arabic
-
Variant matching logic currently depends on option names/values from English
-
Injected variants from the Storefront API may not align with translated option labels
-
Direction change (LTR → RTL) might also affect swatch rendering/order
Current approach
-
Initial variants come from product payload
-
Remaining variants are fetched and merged through Storefront API
-
Swatches are generated dynamically from variant option.
Heres my code :
Product-variant-picker.liquid :
{% 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 -%}
<style>
.color-swatches-skeleton {
display: flex;
flex-wrap: wrap;
gap: 26px;
padding: 8px 0;
}
.color-swatches-skeleton .skeleton-swatch {
width: 45px;
height: 45px;
border-radius: 0%;
background: linear-gradient(90deg, #e8e8e8 25%, #f5f5f5 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: swatch-shimmer 1.4s infinite ease-in-out;
}
@keyframes swatch-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Hide real swatches while loading */
.color--option.swatches-loading .color-option {
display: none !important;
}
/* Also hide direct labels in case there's no .color-option wrapper */
.color--option.swatches-loading label.color-swatch {
display: none !important;
}
/* Hide skeleton once loaded */
.color--option:not(.swatches-loading) .color-swatches-skeleton {
display: none !important;
}
</style>
<script type="text/javascript" data-all-variants>
window.ShopifyProduct = {
variants: {{ product.variants | json }}
};
(function () {
var DECLARED_COUNT = {{ product.variants_count }};
var STOREFRONT_TOKEN = '768cddda9b2775eab70e7f5752590b60';
var PRODUCT_GID = 'gid://shopify/Product/{{ product.id }}';
var SHOP_DOMAIN = '{{ shop.permanent_domain }}';
if (DECLARED_COUNT <= 250) return;
var PAGE_SIZE = 250;
var allVariants = window.ShopifyProduct.variants.slice();
var existingIds = new Set(allVariants.map(function(v) { return v.id; }));
function buildQuery(cursor) {
var afterClause = cursor ? ', after: "' + cursor + '"' : '';
return JSON.stringify({
query: 'query { product(id: "' + PRODUCT_GID + '") { variants(first: ' + PAGE_SIZE + afterClause + ') { pageInfo { hasNextPage endCursor } edges { node { id title sku availableForSale quantityAvailable selectedOptions { name value } price { amount } image { url transformedSrc(maxWidth: 400, maxHeight: 400, crop: CENTER) } } } } } }'
});
}
function mapVariant(node) {
var opts = node.selectedOptions.map(function(o) { return o.value; });
return {
id: parseInt(node.id.replace('gid://shopify/ProductVariant/', '')),
title: node.title,
sku: node.sku,
available: node.availableForSale,
options: opts,
option1: opts[0] || null,
option2: opts[1] || null,
option3: opts[2] || null,
price: Math.round(parseFloat(node.price.amount) * 100),
inventory_quantity: node.quantityAvailable,
featured_image: node.image ? { src: node.image.transformedSrc || node.image.url } : null
};
}
function mergeBatch(edges) {
edges.forEach(function(e) {
var v = mapVariant(e.node);
if (!existingIds.has(v.id)) {
allVariants.push(v);
existingIds.add(v.id);
}
});
window.ShopifyProduct.variants = allVariants;
}
window.ShopifyProduct._loading = true;
function fetchChain(cursor) {
fetch('https://' + SHOP_DOMAIN + '/api/2024-01/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN
},
body: buildQuery(cursor)
})
.then(function(r) { return r.json(); })
.then(function(data) {
var conn = data.data.product.variants;
mergeBatch(conn.edges);
// ✅ Fire partial event after every page so variant-selects can
// progressively inject swatches without waiting for all pages
document.dispatchEvent(new CustomEvent('shopify:variants:partial', {
detail: { loaded: allVariants.length }
}));
console.log('[Variants] Progressive load: ' + allVariants.length);
if (conn.pageInfo.hasNextPage) {
// ✅ Immediately chain next page — zero idle time between pages
fetchChain(conn.pageInfo.endCursor);
} else {
finalize();
}
})
.catch(function(e) {
console.warn('[Variants] Fetch failed, using what we have.', e);
finalize();
});
}
function finalize() {
window.ShopifyProduct.variants = allVariants;
window.ShopifyProduct._loading = false;
injectMissingColorSwatches();
document.dispatchEvent(new CustomEvent('shopify:variants:loaded', {
detail: { total: allVariants.length }
}));
console.log('[Variants] Total loaded: ' + allVariants.length);
}
// Start the chain from the first page
fetchChain(null);
function injectMissingColorSwatches(skipFilter) {
var variants = window.ShopifyProduct.variants;
var productOptions = {{ product.options | json }};
function getOptionIndex(optionNames, possibleNames) {
for (var i = 0; i < optionNames.length; i++) {
var normalized = optionNames[i].toLowerCase();
for (var j = 0; j < possibleNames.length; j++) {
if (normalized === possibleNames[j].toLowerCase()) {
return i;
}
}
}
return -1;
}
console.log('[Inject] Total variants:', variants.length);
var combosByGender = {};
variants.forEach(function(v) {
var g = v.option1;
var c = v.option2;
if (!g || !c) return;
if (!combosByGender[g]) combosByGender[g] = new Set();
combosByGender[g].add(c);
});
var allColors = new Set();
Object.values(combosByGender).forEach(function(colorSet) {
colorSet.forEach(function(c) { allColors.add(c); });
});
var variantSelects = document.querySelector('variant-selects');
var colorFieldset = (variantSelects || document).querySelector('fieldset[data-name="Color"], fieldset[data-name="لون"]');
if (!colorFieldset) {
console.warn('[Inject] No color fieldset found');
return;
}
var existingLabels = colorFieldset.querySelectorAll('label.color-swatch[data-value]');
var existingColors = new Set();
existingLabels.forEach(function(l) { existingColors.add(l.dataset.value); });
var missingColors = [...allColors].filter(function(c) { return !existingColors.has(c); });
console.log('[Inject] Missing colors to inject:', missingColors);
if (missingColors.length === 0) return;
var refLabel = colorFieldset.querySelector('label.color-swatch[data-value]');
var refInput = refLabel ? colorFieldset.querySelector('input[id="' + refLabel.getAttribute('for') + '"]') : null;
if (!refLabel || !refInput) return;
var inputName = refInput.name;
missingColors.forEach(function(color) {
var matchVariant = variants.find(function(v) {
return v.option2 === color && v.featured_image;
}) || variants.find(function(v) {
return v.option2 === color;
});
var imgSrc = matchVariant && matchVariant.featured_image ? matchVariant.featured_image.src : null;
var newInput = document.createElement('input');
newInput.type = 'radio';
var safeId = 'injected-color-' + color.replace(/[\s|\/\\'"]/g, '-');
newInput.id = safeId;
newInput.name = inputName;
newInput.value = color;
newInput.dataset.originalValue = color;
newInput.dataset.productUrl = refInput.dataset.productUrl || '';
newInput.dataset.optionValueId = refInput.dataset.optionValueId || '';
newInput.setAttribute('form', refInput.getAttribute('form') || '');
newInput.hidden = true;
var newLabel = document.createElement('label');
newLabel.className = 'color-swatch';
newLabel.setAttribute('for', safeId);
newLabel.dataset.value = color;
newLabel.hidden = true;
if (imgSrc) {
var img = document.createElement('img');
img.src = imgSrc;
img.alt = color;
img.loading = 'lazy';
img.style.display = 'block';
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
img.style.margin = 'auto';
newLabel.appendChild(img);
}
var hiddenSpan = document.createElement('span');
hiddenSpan.className = 'visually-hidden label-unavailable';
newLabel.appendChild(hiddenSpan);
var colorDiv = colorFieldset.querySelector('.color-option');
if (colorDiv) {
colorDiv.appendChild(newInput);
colorDiv.appendChild(newLabel);
} else {
colorFieldset.appendChild(newInput);
colorFieldset.appendChild(newLabel);
}
console.log('[Inject] Injected:', color, '| img:', imgSrc || 'none');
});
document.querySelectorAll('variant-selects').forEach(function(vs) {
vs.colorOptions = [...vs.querySelectorAll('fieldset[data-name="Color"] label.color-swatch, fieldset[data-name="لون"] label.color-swatch')];
console.log('[Inject] Refreshed colorOptions count:', vs.colorOptions.length);
if (!skipFilter && !vs.dataset.combined) vs.filterOptions();
});
}
window.injectMissingColorSwatches = injectMissingColorSwatches;
})();
</script>
{% if product.tags contains 'combined_listing' %}
<script type="text/javascript" data-all-variants>
window.ShopifyProduct = {
variants: []
}
{% assign handles = '' %}
{% assign colors = '' %}
{% paginate collections['child-products'].products by 5000 %}
{% for collection_product in collections['child-products'].products %}
{% if collection_product.metafields.custom.parent_product.value.id == product.id %}
window.ShopifyProduct.variants = [... window.ShopifyProduct.variants, ...{{ collection_product.variants | json }}]
{% if collection_product.options_by_name["Gender"].selected_value == product.options_by_name.Gender.selected_value %}
{% assign handles = handles | append: collection_product.handle | append: ',' %}
{% assign colors = colors | append: collection_product.options_by_name["Color"].selected_value | append: ',' %}
{% endif %}
{% endif %}
{% endfor %}
{% endpaginate -%}
{% assign handles = handles | split:"," %}
{% assign colors = colors | split:"," %}
</script>
{% endif %}
<variant-selects
id="variant-selects-{{ section.id }}-{{ block.id }}"
data-section="{{ section.id }}"
{{ block.shopify_attributes }}
{% if product.tags contains 'combined_listing' %}
data-combined="true"
{% endif %}
>
{% assign options = child_product.options_with_values %}
{%- 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' -%}
<fieldset class="js product-form__input product-form__input--swatch">
{% case option.name %}
{% when 'Color' or 'لون' %}
<legend class="form__label">
{{- option.selected_value -}}
</legend>
{% when 'Size' or 'مقاس' %}
{% capture gender %}{{ product.options_by_name['Gender'].selected_value}}'s{% endcapture %}
<legend class="form__label desktop-standard-01 standard-02 regular">
{{ 'sections.main-product.size.eu' | t: gender: gender }}
</legend>
{% endcase %}
{% render 'product-variant-options',
product: product,
option: option,
block: block,
picker_type: picker_type,
linked_to: linked_to
%}
</fieldset>
{%- elsif picker_type == 'button' -%}
<fieldset
{% if option.name == 'Gender' or option.name == 'جنس' %}
{% if option.values.size < 2 %}
hidden
{% endif %}
{% endif %}
data-name="{{ option.name }}"
class="js product-form__input product-form__input--pill medium desktop-standard-02 standard-03{% if option.name == 'Gender' or option.name == 'جنس' %} gender-option toggle{% endif %}{% if option.name == 'Size' or option.name == 'مقاس' %} size--option{% endif %}{% if option.name == 'Color' or option.name == 'لون' %} color--option swatches-loading{% endif %}"
>
{% case option.name %}
{% when 'Color' or 'لون' %}
<legend class="form__label">
{{- option.selected_value -}}
</legend>
<div class="color-swatches-skeleton" aria-hidden="true">
{% for i in (1..12) %}
<span class="skeleton-swatch"></span>
{% endfor %}
</div>
{% when 'Size' or 'مقاس' %}
{% capture gender %}{{ product.options_by_name['Gender'].selected_value}}'s{% endcapture %}
{% comment %} <legend class="form__label desktop-standard-01 standard-02 regular">
{{ 'sections.main-product.size.eu' | t: gender: gender }}
</legend> {% endcomment %}
{% comment %} <div class="btn-select-size"> Select a size</div> {% endcomment %}
<div class="btn-select-size">
{% if request.locale.iso_code == 'ar' %}
اختر الحجم
{% else %}
Select a size
{% endif %}
</div>
<div class="size-secection" style="display:none;">
<div class="inner_size-secection">
{% assign raw_gender = product.options_by_name['Gender'].selected_value %}
{% if request.locale.iso_code contains 'ar' %}
{% assign gender = raw_gender %}
{% else %}
{% assign gender = raw_gender | append: "'s" %}
{% endif %}
<legend class="form__label desktop-standard-01 standard-02 regular">
{{ 'sections.main-product.size.eu' | t: gender: gender }}
</legend>
<div class="close-size">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" class="_icon_1codi_279" aria-hidden="true"><path d="m5.64 5.64 12.72 12.72M5.64 18.36 18.36 5.64"></path></svg>
</div>
</div>
{% comment %} </div> {% endcomment %}
{% endcase %}
{% if option.name != 'Gender' and option.name != 'جنس' %}
<div
class="medium desktop-semi-mono-02-regular desktop-semi-mono-02 semi-mono-03 semi-mono-03-regular {{ option.name | downcase }}-option
{% if option.name == 'Size' or option.name == 'مقاس' %} size-selection-item {% endif %} "
>
{% endif %}
{% render 'product-variant-options',
product: product,
option: option,
block: block,
picker_type: picker_type,
linked_to: linked_to,
child_product: child_product,
handles: handles,
colors: colors
%}
{% if option.name != 'Gender' and option.name != 'جنس' %}
</div>
{% endif %}
{% comment %} Avatar code start {% endcomment %}
{% if option.name == 'Color' %}
{% if avatraCheckbox == true %}
{% assign avatar_label = product.selected_or_first_available_variant.metafields.custom.avatar_text %}
{% assign avatar_image = product.selected_or_first_available_variant.metafields.custom.avatar_image %}
<div class="avatar-desktop {% if avatar_label != blank %}avatar-model{% endif %}">
<div class="avatar-container {% if avatar_label == blank or avatar_image == blank %}hide-avatar{% endif %}">
<img src="{{avatar_image | img_url:'master'}}" alt="avatar">
<span>{{ avatar_label }}</span>
</div>
</div>
{% endif %}
{%endif %}
{% comment %} Avatar code end {% endcomment %}
{% case option.name %}
{% when 'Size' or 'مقاس' %}
<div class="size-info">
<button class="open-size-chart regular standard-02 desktop-standard-01">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="_sizeChartLinkRuler_10h4p_370">
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.5 7.5h-21v9h21v-9Zm-20 8v-7H5v4h1v-4h2.25v3h1v-3h2.25v4h1v-4h2.25v3h1v-3H18v4h1v-4h2.5v7h-19Z" fill="currentColor"></path>
</svg>
{% if localization.language.iso_code == 'ar' %}
{% assign size_chart_text = "جدول المقاسات" %}
{% else %}
{% assign size_chart_text = "Size Chart" %}
{% endif %}
<span>{{ size_chart_text }}</span>
</button>
<div class="regular standard-02 desktop-standard-01">
{{ product.metafields.custom.sizing_recommendation.value.text.value | default: '' }}
</div>
</div>
</div>
{% endcase %}
</fieldset>
{% assign option_name = option.name | downcase %}
{% if request.locale.iso_code == 'ar' %}
<div class="error-message">الرجاء اختيار المقاس</div>
{% else %}
<div class="error-message">{{ 'products.product.option_error' | t: option_name: option_name }}</div>
{% endif %}
{%- else -%}
<div class="product-form__input product-form__input--dropdown">
<label class="form__label" for="Option-{{ section.id }}-{{ forloop.index0 }}">
{{ option.name }}
</label>
<div class="select">
{%- if picker_type == 'swatch_dropdown' -%}
<span
data-selected-value
class="dropdown-swatch"
>
{% render 'swatch', swatch: option.selected_value.swatch, shape: block.settings.swatch_shape %}
</span>
{%- endif -%}
<select
id="Option-{{ section.id }}-{{ forloop.index0 }}"
class="select__select"
name="options[{{ option.name | escape }}]"
form="{{ product_form_id }}"
>
{% render 'product-variant-options',
product: product,
option: option,
block: block,
picker_type: picker_type,
linked_to: linked_to,
child_product: child_product,
handles: handles,
colors: colors
%}
</select>
{% render 'icon-caret' %}
</div>
</div>
{%- endif -%}
{%- endfor -%}
<script type="application/json" data-selected-variant>
{{ product.selected_or_first_available_variant | json }}
</script>
<script>
(function () {
window.removeColorLoader = function removeColorLoader() {
document.querySelectorAll('.color--option').forEach(function (el) {
var realCount = el.querySelectorAll('label.color-swatch').length;
var skeletonContainer = el.querySelector('.color-swatches-skeleton');
if (skeletonContainer) {
skeletonContainer.innerHTML = '';
for (var i = 0; i < realCount; i++) {
var span = document.createElement('span');
span.className = 'skeleton-swatch';
skeletonContainer.appendChild(span);
}
}
el.classList.remove('swatches-loading');
});
}
// Large product (>250 variants): _loading is true, wait for fetch to finish
if (window.ShopifyProduct && window.ShopifyProduct._loading) {
document.addEventListener('shopify:variants:loaded', removeColorLoader, { once: true });
} else {
// Small product (≤250 variants): no async fetch, remove loader after
// a short delay so the DOM has time to render the swatches first
setTimeout(removeColorLoader, 800);
}
})();
</script>
</variant-selects>
{%- endunless -%}
<script>
console.log('=== PRODUCT OPTIONS DEBUG ===');
console.log('Product options:', {{ product.options | json }});
{% for option in product.options_with_values %}
console.log(
'Option {{ forloop.index0 }}:',
{
name: {{ option.name | json }},
position: {{ forloop.index0 }},
values: {{ option.values | json }}
}
);
{% endfor %}
console.log('=== VARIANT STRUCTURE DEBUG ===');
window.ShopifyProduct.variants.forEach((variant, index) => {
console.log('Variant #' + index, {
id: variant.id,
title: variant.title,
option1: variant.option1,
option2: variant.option2,
option3: variant.option3,
options: variant.options
});
});
</script>
variant-select.js – is in comments, check it out.
NOTE : I found this code written by someone else so might be a bit spagetti , please help in solving .