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' -%}
    
{% case option.name %} {% when 'Color' or '\u0644\u0648\u0646' %} {{- option.selected_value -}} {% when 'Size' or '\u0645\u0642\u0627\u0633' %} {% capture gender %}{{ product.options_by_name['Gender'].selected_value}}'s{% endcapture %} {{ 'sections.main-product.size.eu' | t: gender: gender }} {% endcase %} {% render 'product-variant-options', product: product, option: option, block: block, picker_type: picker_type, linked_to: linked_to %}
{%- elsif picker_type == 'button' -%} {% assign opti | downcase %} {% if request.locale.iso_code == 'ar' %}
\u0627\u0644\u0631\u062c\u0627\u0621 \u0627\u062e\u062a\u064a\u0627\u0631 \u0627\u0644\u0645\u0642\u0627\u0633
{% else %}
{{ 'products.product.option_error' | t: option_name: option_name }}
{% endif %} {%- else -%}
{%- if picker_type == 'swatch_dropdown' -%} {% render 'swatch', swatch: option.selected_value.swatch, shape: block.settings.swatch_shape %} {%- endif -%} {% render 'icon-caret' %}
{%- endif -%} {%- endfor -%}
{%- endunless -%}

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.find call also uses v.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:

  1. Standardize data-value Attributes in Liquid:

    • In your product-variant-picker.liquid (or the partial that renders individual swatches, likely product-variant-options.liquid), ensure that the data-value attribute on your elements always contains the untranslated variant option value. This is typically what comes in option.value when 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-value should consistently be "Red" regardless of the active language. The visible text inside the label can be {{ option.value | t }} or similar localized text.
  2. Verify Storefront API Data Consistency:

    • Confirm that the node.selectedOptions[X].value returned 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 mapVariant function correctly uses node.selectedOptions.map(function(o) { return o.value; }). Ensure these o.values are indeed the untranslated ones.
  3. Refine injectMissingColorSwatches:

    • The existing logic for building combosByGender, allColors, existingColors, and missingColors is 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 to existingColors, or ensure Liquid never renders a translated value into data-value. The latter is strongly recommended.
    • When creating the newInput and newLabel, ensure newInput.value = color; and newLabel.dataset.value = color; both use the untranslated color value derived from allColors.
  4. Review filterOptions and applyFallback:

    • These functions rely on this.selectedOptions (which gets el.value from the checked input) and el.dataset.value from labels. Ensure these values are untranslated.
    • The comparisons like v.options[0] === g && v.options[1] === c will work correctly if g and c (derived from el.value/el.dataset.value) are untranslated, matching the v.options array.

A good debugging strategy here would be to use console.log() extensively. In both English and Arabic modes, log the values of:

  • el.dataset.value for all swatches.
  • v.option1, v.option2, v.options from window.ShopifyProduct.variants.
  • The contents of existingColors, allColors, and missingColors in injectMissingColorSwatches.

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.

Share:

Use cases

Explore use cases

Agencies, store owners, enterprise — find the migration path that fits.

Explore use cases