How to make Infinite Options with swatches and feature images per color selection

Prerequisites

  1. Set up infinite options manually on Online Store 1.0
  2. Possible reasons why the bundle does not break down when checking out and how to solve them (Look for the "Recent migration to Simple Bundles V2.0 with manual installation" section for Online Store 2.0)

If you have already installed Infinite Options selectors on your theme manually, you should be able to adapt the guide in this article easily. Otherwise, you need to follow the prerequisites above.


Step 1

Create a metaobject definition in your store. Name it "product-swatches." Then, add the following fields and their type:

  • product-image: Single line text
  • image: File - Image source.


Step 2

Create a new Infinite Options-type Bundle and set it as Active.

The bundle assembly should select "Quickly build using existing product variants Info."

If you add some products, make sure to click "Add product."

NOTE:

If you're adding a product into one option group, do not add different products because the code will be implemented on Step 5 will not work. Therefore, add a single product and select all variants under that product.

For example, the first option group consists of all variants of "VANS | CLASSIC SLIP-ON (PERFORATED SUEDE)" only. If I add another, the solution would cause a malfunction.

Step 3

When naming an option dropdown, it is mandatory that you follow the title format based on the product names on the dropdown.

Product 1 - Option name 1 / Option Name 2

For example, on the screenshot below, you'll see the first product name was "VANS | CLASSIC SLIP-ON (PERFORATED SUEDE) - 4 / Beige / Rubber." Your dropdown title should be "Product 1 - Size / Color / Material"

Step 4

Once you create a bundle, go to Content > Metaobjects > product-swatches. On the page, click "Add Entry." Since the focus of this article is to create color swatches, type the name of the variant color name, and then upload the image of the plain color.

When naming the entry, make sure that it matches on the options (Case-sensitive, meaning that A is not equal to a). For example, your shoe product as child product has Beige, Black, and White variants.


On metaobject entries, I created three Entries "Beige, Black, and White" with their image colors. As you can see here, I matched the metaobject entries to the color variants on the child products.

Step 5

Copy the code below and replace all the contents in the simple-bundles.liquid file.

{% assign current_variant = product.selected_or_first_available_variant %}
{% comment %}
   Selector Style number pattern:
   0 - Select dropdowns
   1 - Color Swatches
{% endcomment %}

{% assign selectorStyle = 1 %}

<style>
  #simple-bundles-options{
    color: black !important
  }

  #simple-bundles-options .sb-io-hidden-property{
    display: none; 
  }

  .inputGroup {
    display: inline-block;
  }
  .option-groups-container {
    flex-grow: 1;
  }

  .option-names-container{
    display: flex;
    flex-wrap: wrap;
  }

  .variant-input {
    margin: 0px 5px 5px 0px; 
  }

  .option-container-with-swatch{
    display: flex; 
    width: 100%;
  }

  fieldset.variant-input-wrap {
      border: none; 
      padding: 1rem;
      display: flex;
      flex-wrap: wrap;
      gap: .4rem;
      margin-bottom: 1rem;
  }
  .variant-input label {
      display: flex;
      align-items: center;
      justify-content: center;
      flex-wrap: nowrap;
      border: 1px solid black;
      width: 100%;
      min-width: 60px !important;
      min-height: 80px !important;
      padding: .3rem;
  }

    .variant-input label img {
      height: 39px;
  }

    .variant-input-wrap .variant-input input{
      display: none !important;
    }

    .is-selected{
      border:solid !important;
    }
    fieldset.variant-input-wrap strong {
      width: 100%;
  }

  .variant-input input:checked + label {
    border: solid 2px #515151;
    color: white;
  }

  .variant-input .text input:checked + label {
    background: black !important; 
  }

  .variant-input input:disabled + label {
      opacity: .3;
  }  

</style>

{% assign image_array = "" %}

{% for metaobject in shop.metaobjects.product_swatches.values %}
    {% assign img = metaobject.product_image | image_url %}
    {% assign image_array = image_array | append: img %}
    {% if forloop.last == false %}
        {% assign image_array = image_array | append: "|" %}
    {% endif %}
{% endfor %}
  
<script>
   const SimpleBundlesCustomSwatches = {
    init: function () {
      this.metafieldVariants =  {{ current_variant.metafields.simple_bundles.variant_options.value | json }}
      this.metaObjectsValues = {{ shop.metaobjects.product_swatches.values | json }}
      this.selectorInputStyle = {{ selectorStyle }}
      this.selectorGroups = []
      this.productNames = [];
      this.getSelectors();
      this.renderWidget();
      setTimeout(()=>{
        this.checkFirstOptionsAvailable();
        this.checkSelection();
      }, 1000)
    },

    getSelectors: function () {
      this.metafieldVariants.forEach(metafieldVariant => {
        const { optionName } = metafieldVariant;
    
        if (optionName.includes('/')) {
          this.getProductNames(metafieldVariant);
          this.getCombinedSelectors(metafieldVariant);
        } else {
          this.getOrdinarySelector(metafieldVariant);
        }
      });
    },
    
    getProductNames: function (metafieldVariant) {
      const firstOptionValue = metafieldVariant.optionValues.split(',')[0];
      const parts = firstOptionValue.split('-');
    
      const productNameLastIndexDash = this.checkIndexProductSeparator(firstOptionValue, "-");
      const productName = parts
        .slice(0, productNameLastIndexDash)
        .join('-')
        .trim();
    
      this.productNames.push(productName);
    },

    getCombinedSelectors: function (metafieldVariant) {
      const selectorGroup = {
        selectors: [],
        originalName: metafieldVariant.optionName
      };
    
      const originalName = selectorGroup.originalName;
      const parts = originalName.split('-');
      const productNameLastIndexDash = this.checkIndexProductSeparator(originalName, "-");
    
      selectorGroup.originalProductPart = parts.slice(0, productNameLastIndexDash).join('-');
    
      const nameRemovedProduct = parts.slice(1, productNameLastIndexDash + 1).join('-').trim();
      const optionNames = nameRemovedProduct.split('/');
    
      optionNames.forEach((optionName, index) => {
        selectorGroup.selectors.push({
          optionName: optionName.trim(),
          optionValues: this.getOptionValues(metafieldVariant, index)
        });
      });
    
      selectorGroup.availables = this.getAvailables(metafieldVariant);
      this.selectorGroups.push(selectorGroup);
    },

    checkIndexProductSeparator: function (text, separator) {
      const parts = text.split(separator);
      
      for (let i = 0; i < parts.length; i++) {
        if (parts[i].includes('/')) {
          return i;
        }
      }
      
      return parts.length;
    },

    getOrdinarySelector: function (metafieldVariant) {
      const selectorGroup = {
        selectors: []
      };
    
      const originalName = metafieldVariant.optionName;
      const parts = originalName.split('-');
      const nameRemovedProduct = parts.length > 1 ? parts[1] : originalName;
      const optionNames = nameRemovedProduct.split('/');
    
      optionNames.forEach((optionName, index) => {
        selectorGroup.selectors.push({
          optionName: optionName.trim(),
          optionValues: this.getOptionValues(metafieldVariant, index)
        });
      });
    
      this.selectorGroups.push(selectorGroup);
    },

    getOptionValues: function (metafieldVariant, groupIndex) {
      const optionValues = [];
    
      metafieldVariant.optionValues.split(',').forEach(optionValue => {
        const parts = optionValue.split('-');
        const productNameLastIndexDash = this.checkIndexProductSeparator(optionValue, "-");
        
        // Get the option part: slice from where the product name ends
        const optionPart = parts.slice(productNameLastIndexDash).join('-').trim();
        
        const splitBySlash = optionPart.split('/');
        const valueForName = splitBySlash[groupIndex] ? splitBySlash[groupIndex].trim() : '';
    
        if (valueForName && !optionValues.includes(valueForName)) {
          optionValues.push(valueForName);
        }
      });
    
      return optionValues;
    },

    getAvailables: function(metafieldVariant){
      const availables = []
      const invetories =  metafieldVariant.optionInventories?.split(',')
      invetories.forEach((invetory, index)=>{
        if(parseInt(invetory) > 0){
          const availableCombination = []
          const productNameLastIndexDash = this.checkIndexProductSeparator(metafieldVariant?.optionValues?.split(',')[index],"-");
          const availableItems = metafieldVariant?.optionValues?.split(',')[index].split('-').slice(productNameLastIndexDash-metafieldVariant?.optionValues?.split(',')[index].split('-').length).join("-").trim();
          availableItems.split('/').forEach(availableItem => availableCombination.push(availableItem.trim()))
          availables.push(availableCombination);
        }
      });
      return availables;
    },

    renderWidget: function () {
      const form = document.querySelector('.product__info-wrapper form[action$="/cart/add"]:not([id^="product-form-installment"])');

      const container = document.createElement('div');
      container.id = 'simple-bundles-options'

      const template = `
      <h3>Bundle options</h3>
      ${this.renderSelectorsGropups()}
      `;

      container.innerHTML = template;
      form.prepend(container)

      this.setListeners();
    },

    renderSelectorsGropups: function(){
      let selectorGroups = '';
      this.selectorGroups.forEach((selectorGroup, index) =>{
        const fieldset = `
          <fieldset class="variant-input-wrap" style="margin: 15px 0">
            <div class="option-container-with-swatch">
              <div class="option-groups-container">
                ${this.renderSelectors(index)}
                <input type="text" id="group-${index}" data-value="" name="properties[${selectorGroup.originalName}]" value="${selectorGroup.originalProductPart}" data-inventory="10" autocomplete="off" class="sb-io-hidden-property"> 
              </div>
            </div>
          </fieldset> 
          <hr style="margin: 0px">
        `;
        selectorGroups += fieldset;
      });

      return selectorGroups;
    },

    renderSelectors : function(groupIndex){
      let selectors = '';
      this.selectorGroups[groupIndex].selectors.forEach(( selector, selectorIndex)=>{
        const inputTemplates = `
        <div class="variant-input">
          <div class="optionName">${selector.optionName}</div>
          ${this.renderInputs(selector, groupIndex, selectorIndex)}
        </div>
        `
        selectors += inputTemplates
      })
      return selectors && selectors;
    },

    renderInputs : function(selector, groupIndex, selectorIndex){
      let inputs = '';
      switch (this.selectorInputStyle ) {
        case 0:
          inputs = `<select name="${selector.optionName}-${groupIndex}" data-selectorGroup-index="${groupIndex}" data-selector-index="${selectorIndex}">`
          break;
        case 1:
          break;
          
      }
      
      selector.optionValues.forEach((optionValue,inputIndex) =>{

        let titleMatches = false; 
          let URLimage = ""; 
                
          let metaobjects = {{ shop.metaobjects.product_swatches.values  | json}};
          let metaobjects_image = "{{ image_array }}"
          metaobjects_image = metaobjects_image.split("|")
                
          for(var i = 0; i < metaobjects.length; i++) {
            let valueDowncase = optionValue.toLowerCase(); 
            let productItemDowncase = metaobjects[i].option_name.toLowerCase()

            
            if (valueDowncase === productItemDowncase) { 
              titleMatches = true;
              URLimage = metaobjects_image[i];
              break;
            }
          }

        
        let input;
        switch(this.selectorInputStyle) {
          case 0:
            input = `
                <option id="${optionValue}-${groupIndex}" class="inputGroup ${titleMatches ? '' : 'text'}" data-value="${optionValue}" name="${selector.optionName}-${groupIndex}" value="${optionValue}">${optionValue}</option>
              `
            break;
          case 1:
            input = `
              <div class="inputGroup ${titleMatches ? 'swatch' : 'text'}">
                <input type="radio" id="${optionValue}-${groupIndex}" data-selector-index="${selectorIndex}" ${titleMatches ? `data-image-url="${URLimage}"` : ''} data-selectorGroup-index="${groupIndex}" data-value="${optionValue}" name="${selector.optionName}-${groupIndex}" value="${optionValue}"/>
                <label for="${optionValue}-${groupIndex}" class="variant__button-label" ${titleMatches ? `style="background-image: url('${URLimage}'); background-size: cover; background-position: center; color: transparent;"` : ''} > ${optionValue} </label>
              </div>
              `
            break;
          default:
            break;
        }
            
        inputs += input
      })
      
      switch (this.selectorInputStyle) {
        case 0:
          inputs += `</select>`
          break;
        case 1:
          break;
      }
      return inputs;
    },


    setListeners : function(){
      this.onSelectSwatches()
    },

    onSelectSwatches: function(){
      document.addEventListener('click', (e)=>{
        if(e.target.matches('[type="radio"]')){
          const inputGroup = e.target
          //this.checkFirstOptionsAvailable();
          this.checkSelection(inputGroup);
          this.disableButtons(inputGroup);
        }
        
      })

      document.addEventListener('change', (e)=>{
        if(e.target.matches('select[data-selectorgroup-index]')){
          const inputGroup = e.target
          this.checkSelection(inputGroup);
          this.disableButtons(inputGroup);
        }
      })
    },

    checkFirstOptionsAvailable : function(){
      const optioGroupElemets = document.querySelectorAll('.option-groups-container');
      optioGroupElemets.forEach((optioGroupElemet, groupIndex) =>{
        this.checkbasedOn(optioGroupElemet, groupIndex, 0)
      })
    },

    checkbasedOn: function(optioGroupElemet, groupIndex) {
      const availables = this.selectorGroups[groupIndex]?.availables;
    
      const selectedColorInput = optioGroupElemet.querySelector(`.variant-input:nth-child(1) input:checked`);
      if (!selectedColorInput) {
        return;
      }
    
      const selectedColor = selectedColorInput.dataset.value;
      const sizeInputGroup = optioGroupElemet.querySelector(`.variant-input:nth-child(2)`);
      const sizeInputs = sizeInputGroup.querySelectorAll('.inputGroup input');
    
      let firstAvailableInput = null;
    
      sizeInputs.forEach(input => {
        const sizeValue = input.dataset.value;
        let isAvailable = false;
    
        for (let i = 0; i < availables.length; i++) {
          const combo = availables[i];
    
          if (combo[0] === selectedColor && combo[1] === sizeValue) {
            isAvailable = true;
            break;
          }
        }
    
        input.disabled = !isAvailable;
    
        if (isAvailable) {
          if (!firstAvailableInput) {
            firstAvailableInput = input;
          }
        } 
      });
    
      // Auto-select the first available size
      if (firstAvailableInput) {
        firstAvailableInput.checked = true;
      } 
    },

    checkSelection: function(inputGroup = ""){
      let inputFormGroups = "";
      let productName = "";
      let selector = "";
      let inputTypeSelector;

      switch(this.selectorInputStyle) {
        case 0:
          inputTypeSelector = "select[data-selectorgroup-index]";
          break;
        case 1:
          inputTypeSelector = ".inputGroup [type='radio']:checked";
          break;
        default:
          break;
      }

      if(inputGroup) {
        inputFormGroups = document.querySelectorAll(".option-groups-container")[inputGroup.dataset.selectorgroupIndex].querySelectorAll(`.variant-input ${inputTypeSelector}`);
        productName = this.productNames[inputGroup.dataset.selectorgroupIndex];
        
        selector = `${productName} - `;

        for(var i = 0; i < inputFormGroups.length; i++) {
          selector += `${inputFormGroups[i].value} ${ i != inputFormGroups.length - 1 ? "/ " : "" }`
        }


        this.assignHiddenFields(inputGroup.dataset.selectorgroupIndex,selector)
      } else {
        let inputFormGroupsAll = document.querySelectorAll(".option-groups-container")
        inputFormGroupsAll.forEach((inputFormGroups,index) => {
          inputFormGroups = document.querySelectorAll(".option-groups-container")[index].querySelectorAll(`.variant-input ${inputTypeSelector}`);
          productName = this.productNames[index];
          
          selector = `${productName} - `;

          for(var i = 0; i < inputFormGroups.length; i++) {
            selector += `${inputFormGroups[i].value} ${ i != inputFormGroups.length - 1 ? "/ " : "" }`
          }

          this.assignHiddenFields(index,selector)
        })
      }
        
    },

    assignHiddenFields: function(index,selector) {
      let hiddenPropertyField = document.querySelector(`[name="properties[${this.selectorGroups[index].originalName}]"]`)
      hiddenPropertyField.value = selector;
      this.updateHiddenInputValue();
    },

    updateHiddenInputValue: function() {
      var inputs = document.querySelectorAll('#simple-bundles-options .sb-io-hidden-property');
      var values = [];
      
      inputs.forEach(function(input) {
        values.push(input.value);
      
      });
      
      if (values.length === 0) {
        return;
      }
      var hiddenInputValue = values.join(' <> ');

      var hiddenInput = document.querySelector('#simple-bundles-options input[name="properties[_bundle_selection]"]');
      if (!hiddenInput) {
          hiddenInput = document.createElement('input');
          hiddenInput.type = 'hidden';
          hiddenInput.name = 'properties[_bundle_selection]';
          document.getElementById('simple-bundles-options').appendChild(hiddenInput);
      }
      hiddenInput.value = hiddenInputValue;
    },


    disableButtons: function(inputGroup) {
      let index = inputGroup.dataset.selectorgroupIndex;
      let optionName = inputGroup.name.split("-")[0];

      let inputTypeSelector;

      switch(this.selectorInputStyle) {
        case 0:
          inputTypeSelector = "select[data-selectorgroup-index]";
          break;
        case 1:
          inputTypeSelector = ".inputGroup [type='radio']:checked";
          break;
        default:
          break;
      }
      
      let inputFormGroups = document.querySelectorAll(".option-groups-container")[index].querySelectorAll(`.variant-input ${inputTypeSelector}`);
      let selectionGroups = this.metafieldVariants[index].optionValues.split(",");
      let inventories = this.metafieldVariants[index].optionInventories.split(",");
      let selectorIndex = parseInt(inputGroup.dataset.selectorIndex) + 1;
      this.enableAllButtons(index,selectorIndex)
      let selectedOptions = [];
      let selectedOptions2 = [];

      
      
      for(var i = 0; i < selectorIndex; i++) {
        selectedOptions.push(inputFormGroups[i].value);
      }

      
      for(var i = 0; i < inputFormGroups.length-1; i++) {
        selectedOptions2.push(inputFormGroups[i].value);
      }

      selectedOptions = selectedOptions.join(" / ");
      selectedOptions2 = selectedOptions2.join(" / ");
      
      incre = selectorIndex;
      
      inputGroup.disabled = this.checkStockAndDisable(index,selectedOptions, selectedOptions2, inventories, selectionGroups, inputFormGroups, incre);
      this.disableAddToCartButtons(index)
    },

    checkStockAndDisable: function(index, selectedOptions, selectedOptions2, inventories, selectionGroups, inputFormGroups, incre) {
      let isOutOfStock = true;
      let hasStock = true;
    
      for (let i = 0; i < selectionGroups.length; i++) {
        let selectionGroup = selectionGroups[i];
        const OptionLastIndexDash = this.checkIndexProductSeparator(selectionGroup, "-");
        let optionValues = selectionGroup
          .split('-')
          .slice(OptionLastIndexDash - selectionGroup.split('-').length)
          .join("-")
          .trim();
        let optionValueSplit = optionValues.split("/");
    
        if (inputFormGroups.length === incre) {
          if (optionValues.includes(selectedOptions.trim()) && inventories[i] <= 0) {
            if (selectedOptions === selectedOptions2) {
              document.getElementById(`${optionValueSplit[incre - 1].trim()}-${index}`).disabled = true;
            }
          } 
          else if (optionValues.includes(selectedOptions.trim()) && isOutOfStock) {
            isOutOfStock = false;
          }
    
        } else {
          if (optionValues.includes(selectedOptions)) {
            let selectedOptions_temp;
            if (inputFormGroups.length > (incre + 1)) {
              selectedOptions_temp = `${selectedOptions} / ${optionValueSplit[incre].trim()}`;
            } else {
              selectedOptions_temp = selectedOptions;
            }
    
            isOutOfStock = this.checkStockAndDisable(
              index,
              selectedOptions_temp,
              selectedOptions2,
              inventories,
              selectionGroups,
              inputFormGroups,
              incre + 1
            );
    
            document.getElementById(`${optionValueSplit[incre - 1].trim()}-${index}`).disabled = isOutOfStock;
    
            if (!isOutOfStock || !hasStock) {
              isOutOfStock = false;
              hasStock = false;
            }
          }
        }
      }
    
      return isOutOfStock;
    },

    enableAllButtons: function(index="", selectedIndex="") {
      let inputTypeSelector;

      switch(this.selectorInputStyle) {
        case 0:
          inputTypeSelector = "select[data-selectorgroup-index] options:not(:first-child)";
          break;
        case 1:
          inputTypeSelector = ".inputGroup [type='radio']";
          break;
        default:
          break;
      }
      
      if(index && selectedIndex) {
        inputFormGroups = document.querySelectorAll(".option-groups-container")[index].querySelectorAll(".variant-input");
        inputFormGroups.forEach((inputFormGroup,i) => {
          if(i >= selectedIndex) {
            inputForms = inputFormGroup.querySelectorAll(inputTypeSelector);
            inputForms.forEach(inputForm => {
              inputForm.disabled = false;
            })
          }
        })
      } else {
        inputFormGroups = document.querySelectorAll(".option-groups-container .variant-input:not(:first-child) " + inputTypeSelector);
        inputFormGroups.forEach((inputFormGroup) => {
          inputFormGroup.disabled = false;
        })
      }
        
    },

    disableAddToCartButtons: function(index="") {
      let selectedOutOfStockSwatch = false;
      inputFormGroups = index != "" ? document.querySelectorAll(".option-groups-container")[index] : document.querySelectorAll(".option-groups-container");
      inputFormGroups = inputFormGroups.querySelectorAll(".variant-input .inputGroup [type='radio']:checked");
      inputFormGroups.forEach((inputFormGroup,i) => {
         if(inputFormGroup.disabled)
          selectedOutOfStockSwatch = true;
      })

     
      document.querySelector('form[action*="/cart/add"] [type="submit"]').disabled = selectedOutOfStockSwatch;
   }

  }

  document.addEventListener('DOMContentLoaded', function() {
  function initializeFieldset(fieldset) {
      const variantInputs = document.querySelectorAll('#simple-bundles-options .variant-input');

      // Check if none of the variantInputs are checked
      let anyChecked = Array.from(variantInputs).some(variantInput => {
        const radio = variantInput.querySelector('input[type="radio"]');
        return radio && radio.checked;
      });

      if(!anyChecked){
        // Iterate over each variant-input div
        variantInputs.forEach(variantInput => {
            // Select the first radio button inside this variant-input div
            const firstRadioButton = variantInput.querySelector('input[type="radio"]');
            
            // Check if the radio button exists and set its checked attribute to true
            if (firstRadioButton) {
                firstRadioButton.checked = true;
            }
        });
      }

      let options = fieldset.querySelectorAll('#simple-bundles-options input[type="radio"]');
  }

  // Process each fieldset
  let fieldsets = document.querySelectorAll('.variant-input-wrap');
    fieldsets.forEach((fieldset) => {
        initializeFieldset(fieldset);
    });
  });

  SimpleBundlesCustomSwatches.init();
</script>

Expected output:

Please take a loot at the screenshots below. You'll see the comparison of before and after implementing the solution.


BEFORE:

The IO selectors are just a static and plain dropdown. Each dropdown has every variants with different combinations that you are required to scroll down and taking a look properly.


AFTER:

With the code, it will split the variant names into different option groups. Also, the color Beige, Black and White swatches are now appeared as color, instead of plain text.

Still need help? Contact Us Contact Us