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 on your theme manually, you should be able to adapt the guide in this article easily. Otherwise, go to this link and follow all the steps.


Step 1

Create a metaobject definition in your store. Name it "product-swatches." Then, add the following fields and it's 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 %}
<style>
  #simple-bundles-options {
    color: black !important;
  }

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

  .inputGroups:has(.inputGroup .IO-swatches-images) {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
  }

  .inputGroup {
    display: inline-block;
  }

  .option-groups-container {
    flex-grow: 1;
  }

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

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

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

  .option-container-with-swatch .product-swatch-img {
    max-width: 80px;
    margin-right: 10px;
  }

  #simple-bundles-options .IO-swatches-images {
    width: 50px !important;
    height: 50px;
    min-width: 50px;
    border-radius: 100%;
    border: 3px solid #cacaca;
    z-index: 10;
  }

  #simple-bundles-options [type="radio"]:checked + .IO-swatches-images {
    border: 3px solid var(--text);
  }

  fieldset.variant-input-wrap {
    border: none;
    padding: 1rem;
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-bottom: 1rem;
  }

  .variant-input label {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    border-radius: 4px;
    padding: 22px 20px;
    margin: 5px 7px 0 0;
    background-color: var(--bg-accent);
  }

  .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 {
    color: white;
  }

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

  .variant-input input:disabled + label {
    opacity: 0.3;
  }
</style>

{% assign image_array = "" %}
{% for metaobject in shop.metaobjects.product_swatches.values %}
{% assign img = metaobject.image | image_url %}
{% assign image_array = image_array | append: img %}
{% if forloop.last == false %}
{% assign image_array = image_array | append: "|" %}
{% endif %}
{% endfor %}


<script>
  class SimpleBundlesCustomSwatches {
    constructor() {
      this.metafieldVariants = {{ current_variant.metafields.simple_bundles.variant_options.value | json }};
      this.metaObjectsValues = {{ shop.metaobjects.product_swatches.values | json }};
      this.selectorGroups = [];
      this.productNames = [];
      this.init();
    }

    init() {
      this.getSelectors();
      this.renderWidget();
      setTimeout(() => {
        this.checkFirstOptionsAvailable();
        this.checkSelection();
      }, 500);
    }

    getSelectors() {
      this.metafieldVariants.forEach(metafieldVariant => {
        if (metafieldVariant.optionName.includes('-')) {
          this.getProductNames(metafieldVariant);
          this.getCombinedSelectors(metafieldVariant);
        } else {
          this.getOrdinarySelector(metafieldVariant);
        }
      });
    }

    getProductNames(metafieldVariant) {
      let productname = metafieldVariant.optionValues.split(",")[0];
      const productNameLastIndexDash = this.checkIndexProductSeparator(productname, "-");
      productname = productname.split('-').slice(0, productNameLastIndexDash).join("-").trim();
      this.productNames.push(productname);
    }

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

    checkIndexProductSeparator(text, separator) {
      let index = 0;
      text = text.split("-");
      let mergedText = "";
      let end = false;
      while (!end) {
        mergedText += text[index];
        index++;
        if (text[index].includes("/") || text.length - 2 == index)
          end = true;
      }
      return index;
    }

    getOrdinarySelector(metafieldVariant) {
      const selectorGroup = { selectors: [] };
      const originalName = metafieldVariant.optionName;
      const productNameLastIndexDash = this.checkIndexProductSeparator(originalName, "-");
      const nameRemovedProduct = originalName.split('-').slice(1, productNameLastIndexDash + 1).join("-").trim();
      const optionName = originalName.split('-').slice(0, productNameLastIndexDash).join("-");
      const selector = {
        optionName: optionName.trim(),
        optionValues: this.getOptionValues(metafieldVariant, 0)
      };
      selectorGroup.selectors.push(selector);
      this.selectorGroups.push(selectorGroup);
    }

    optionProductNames(metafieldVariant, groupIndex) {
      const optionValues = [];
      metafieldVariant.optionValues.split(',').forEach(optionValue => {
        const productNameLastIndexDash = this.checkIndexProductSeparator(optionValue, "-");
        const splitValues = optionValue.split('-').slice(0, productNameLastIndexDash + 2).join("-").trim();
        const valueForName = splitValues.split('-')[groupIndex];
        if (!optionValues.includes(valueForName.trim())) optionValues.push(valueForName.trim());
      });
      return optionValues;
    }

    getOptionValues(metafieldVariant, groupIndex) {
      const optionValues = [];
      metafieldVariant.optionValues.split(',').forEach(optionValue => {
        const productNameLastIndexDash = this.checkIndexProductSeparator(optionValue, "-");
        const splitValues = optionValue.split('-').slice(productNameLastIndexDash - optionValue.split("-").length).join("-").trim();
        const valueForName = splitValues.split('-')[groupIndex];
        if (!optionValues.includes(valueForName.trim())) optionValues.push(valueForName.trim());
      });
      return optionValues;
    }

    getAvailables(metafieldVariant) {
      const availables = [];
      const inventories = metafieldVariant.optionInventories?.split(',');
      inventories.forEach((inventory, index) => {
        if (parseInt(inventory)) {
          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() {
      const form = document.querySelector('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.renderSelectorGroups()}
      `;

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

      this.setListeners();
    }

    renderSelectorGroups() {
      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(groupIndex) {
      let selectors = '';
      this.selectorGroups[groupIndex].selectors.forEach((selector, selectorIndex) => {
        const inputTemplates = `
          <div class="variant-input">
            <div class="optionName">${selector.optionName}</div>
            <div class="inputGroups">${this.renderInputs(selector, groupIndex, selectorIndex)}</div>
          </div>
        `;
        selectors += inputTemplates;
      });
      return selectors;
    }

    renderInputs(selector, groupIndex, selectorIndex) {
      let inputs = '';
      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 (let i = 0; i < metaobjects.length; i++) {
          let valueDowncase = this.productNames[groupIndex].toLowerCase() + " - " + optionValue.toLowerCase();
          let productItemDowncase = metaobjects[i].product_image.toLowerCase();

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

        const input = `
          <div class="inputGroup ${titleMatches ? '' : '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 ? 'IO-swatches-images' : ''}" ${titleMatches ? `style="background-image: url('${URLimage}'); background-size: inherit; background-position: center; color: transparent; background-size: cover"` : ''}> ${optionValue} </label>
          </div>
        `;
        inputs += input;
      });
      return inputs;
    }

    setListeners() {
      this.onSelectSwatches();
    }

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

    checkFirstOptionsAvailable() {
      const optionGroupElements = document.querySelectorAll('.option-groups-container');
      optionGroupElements.forEach((optionGroupElement, groupIndex) => {
        this.checkBasedOn(optionGroupElement, groupIndex, 0);
      });
    }

    checkBasedOn(optionGroupElement, groupIndex, selectorIndex) {
      const basedInput = optionGroupElement.querySelector(`input[data-selectorgroup-index="${groupIndex}"][data-selector-index="${selectorIndex}"]`);
      const value = basedInput?.value;
      if (!value) return;
      this.checkBasedOnDisable(optionGroupElement, groupIndex, selectorIndex);
    }

    checkBasedOnDisable(optionGroupElement, groupIndex, selectorIndex) {
      const basedInput = optionGroupElement.querySelector(`input[data-selectorgroup-index="${groupIndex}"][data-selector-index="${selectorIndex}"]`);
      const value = basedInput?.value;
      if (!value) return;
      const radioGroups = optionGroupElement.querySelectorAll(`input[data-selectorgroup-index="${groupIndex}"]`);
      let selectorGroup = this.selectorGroups[groupIndex];
      let currentAvailableCombination = [];
      let currentValues = [];

      for (let i = 0; i <= selectorIndex; i++) {
        const checkedInput = optionGroupElement.querySelector(`input[data-selectorgroup-index="${groupIndex}"][data-selector-index="${i}"]:checked`);
        currentValues.push(checkedInput?.value || basedInput.value);
      }

      selectorGroup.availables.forEach(availableCombination => {
        let isAvailable = true;
        currentValues.forEach((value, index) => {
          if (availableCombination[index] !== value) {
            isAvailable = false;
          }
        });
        if (isAvailable) {
          currentAvailableCombination = availableCombination;
        }
      });

      radioGroups.forEach((radioGroup, radioIndex) => {
        if (radioIndex > selectorIndex) {
          let isAvailable = false;
          selectorGroup.availables.forEach(availableCombination => {
            let matches = true;
            currentValues.forEach((value, valueIndex) => {
              if (availableCombination[valueIndex] !== value) {
                matches = false;
              }
            });
            if (matches && availableCombination[radioIndex] === radioGroup.value) {
              isAvailable = true;
            }
          });
          radioGroup.disabled = !isAvailable;
        }
      });
    }

    disableButtons(inputGroup) {
      const groupIndex = parseInt(inputGroup.dataset.selectorgroupIndex);
      const selectorIndex = parseInt(inputGroup.dataset.selectorIndex);
      const optionGroupElement = inputGroup.closest('.option-groups-container');
      this.checkBasedOnDisable(optionGroupElement, groupIndex, selectorIndex);
    }

    checkSelection(inputGroup) {
      if (!inputGroup) {
        this.selectorGroups.forEach((selectorGroup, groupIndex) => {
          selectorGroup.selectors.forEach((selector, selectorIndex) => {
            const input = document.querySelector(`input[data-selectorgroup-index="${groupIndex}"][data-selector-index="${selectorIndex}"]:checked`);
            if (input) {
              this.checkSelection(input);
            }
          });
        });
        return;
      }

      const groupIndex = parseInt(inputGroup.dataset.selectorgroupIndex);
      const selectorIndex = parseInt(inputGroup.dataset.selectorIndex);
      const optionGroupElement = inputGroup.closest('.option-groups-container');
      this.checkBasedOnDisable(optionGroupElement, groupIndex, selectorIndex);
    }
  }

  // Initialize the swatches functionality
  document.addEventListener('DOMContentLoaded', () => {
    new SimpleBundlesCustomSwatches();
  });

</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