I'm working on a Craft Commerce 3 project and for something I initially thought would be easy to do with variants seems to have completely thrown me off course so any feedback would be great.

I have a site we're building that has one single product - a car registration plate maker. The user needs to input their reg and select from a few options such as styles, colours, badges etc. Some of these options change the price and some don't.

I've built many sites in Craft CMS but only one previously with Craft Commerce 1. For this Craft Commerce site we built a plug-in that updates the product price and worked great so I'm thinking - do I need to go down the plug-in route or am I missing something with variants?

Variants - when I added variants, ideally I wanted a separate field for each variant option (text style, then one for size and then one for colours and so on) - not just one select field with the possible combinations.

Single Purchase Element - I've also been thinking just build the whole thing completely separate from Commerce so I'd in effect just have JS calculating the final price based on options selected. Then when added to cart, a form would just add the price, all the options selected and the custom text (car reg) to the line item.

Any pointers on this would be really appreciated. I've tried many things but nothing seems to be a solid direction yet.

  • Have you seen this? vaersaagod.no/en/… Jul 14, 2020 at 9:36
  • Thanks for that, I saw the article and followed it which was great but we have that many variants and possibilities, it just didn't seem manageable so we've created our own JS UI instead and plugin to change lineitem accordingly.
    – JonnyT
    Jul 18, 2020 at 13:16

1 Answer 1


I built something similar for a client recently (in Craft, but not Craft Commerce) that required two variations (size and colour) where not all variations were compatible (ie. some sizes were only available in some colours, and vice versa).

The approach I took was to mimic Shopify’s approach. Each product has a table with three columns (size, colour, product code). I didn’t include price because it is always the same regardless of variations, but you could add that easily.

On the page Craft outputs a single select input in case JavaScript fails for any reason. Something like:

{% if entry.options|length %}
<div class="product__options">
  <form class="#" action="#" method="post">
    <div class="form__wrapper">
      <fieldset class="form__group">
        <div class="select__wrapper">
          <label class="label" for="opt__opt">Options</label>
          <select class="input--select" id="opt__opt" name="options">
            {% for row in entry.options %}
            <option class="sel__option" data-colour="{{ row.colour }}" data-size="{{ row.size }}" data-code="{{ row.code }}" value="{{row.code}} - {{ row.size }} - {{ row.colour }}">Size: {{ row.size }} | Colour: {{ row.colour }}</option>
            {% endfor %}

      <fieldset class="form__group form__group--submit">
        <button class="button button--submit" type="submit" name="submit" id="opt__btn">Get Quick Quote</button>

{% endif %}

Then, JavaScript lifts the data out of the select, and creates two new selects and a hidden input (the latter being for the product code). When the selects’ value changes the values in the other input are compared to make sure they are a valid combination (if not that option is disabled):

// holds the option data
let option_data = [];

// holds the inputs so can be manipulated
let size_input,

// creates the wrapper for the select
function createSelectWrapper(parent)
  let sw = document.createElement("div");
  sw.setAttribute("class", "select__wrapper");
  return sw;

// creates the label for the select
function createSelectLabel(parent, target, label)
  // create wrapper
  let lw = document.createElement("div");
      lw.setAttribute("class", "label__wrapper");

  // create label
  let lbl = document.createElement("label");
      lbl.setAttribute("class", "label");
      lbl.setAttribute("for", target);
      lbl.innerHTML = label;




// creates a select element
function createSelect(parent, id, name, prompt, array)
  // create select
  let sel = document.createElement("select");

  // set attributes
  sel.setAttribute("id", id);
  sel.setAttribute("name", name);
  sel.setAttribute("class", "input--select");

  // create default
  let def = document.createElement("option");
  def.innerHTML = prompt;
  def.setAttribute("disabled", "disabled");
  def.setAttribute("selected", "selected");

  let l = array.length;
  for(let i = 0; i < l; i++)
    // create option
    let opt = document.createElement("option");

    // set attributes
    let val = array[i];
    opt.innerHTML = val;
    opt.setAttribute("value", val);
    opt.setAttribute("class", "option");

    // append option

  // insert select

  //return reference
  return sel;


// creates a hidden field to hold product code
function createHidden(parent, id, name)

  // create hidden input
  let inp = document.createElement("input");

  // set attributes
  inp.setAttribute("type", "hidden");
  inp.setAttribute("id", id);
  inp.setAttribute("name", name);

  // add to page

  // return reference
  return inp;


// checks if value is unique
function getValueUnique(arr, val)
  let i = arr.length,
      isFound = false;
    if(arr[i] == val)
      isFound = true;

  return !isFound;

// toggles the enabled/disabled of the submit button
function toggleSubmitBtn(willEnable)
  let submitBtn = document.getElementById("opt__btn");

    submitBtn.setAttribute("disabled", "disabled");

// inits the options fields in the form
function initOptions()

  // grab the current input
  let orig_options = document.getElementById("opt__opt");
  let orig_options_parent = orig_options.parentNode;
  let orig_options_grandparent = orig_options_parent.parentNode;
  let orig_options_children = orig_options.getElementsByClassName("sel__option");

  // create colour Array
  let colour_arr = [];

  // create size Array
  let size_arr = [];

  // get the current data
  let l = orig_options_children.length;
  for(let i=0; i < l; i++)
    const t = orig_options_children[i].dataset;
    var json = JSON.stringify({
      colour: t.colour,
      size: t.size,
      code: t.code

    // check if it needs to add to arrays
    if(getValueUnique(colour_arr, t.colour)) { colour_arr.push(t.colour); }
    if(getValueUnique(size_arr, t.size)) { size_arr.push(t.size); }


  //sort arrays (just in case)
  size_arr.sort(function(a, b) {return a - b});

  // remove the old dropdown

  // create select wrapper
  let size_wrapper = createSelectWrapper(orig_options_grandparent);
  let colour_wrapper = createSelectWrapper(orig_options_grandparent);

  // add labels
  createSelectLabel(size_wrapper, "opt__size", "Size", true);
  createSelectLabel(colour_wrapper, "opt__colour", "Colour");

  // create new selects
  size_input = createSelect(size_wrapper, "opt__size", "size", "Choose Size", size_arr);
  colour_input = createSelect(colour_wrapper, "opt__colour", "colour", "Choose Colour", colour_arr);

  // create hidden field to hold product code
  code_input = createHidden(orig_options_grandparent, "opt__code", "product_code");

  // listen for changes on the options
  size_input.addEventListener("change", onOptionChange);
  colour_input.addEventListener("change", onOptionChange);


// handles the disabling of an option
function disableOption(option, isSize)
  option.setAttribute("disabled", "disabled");
  option.innerHTML = option.getAttribute("value") + " - not available in your chosen " + (isSize ? "colour" : "size");

// handles the enabling of an option
function enableOption(option)
  option.innerHTML = option.getAttribute("value");

// event triggered by one of the option dropdowns changing
function onOptionChange(event)

  // identify selects
  let triggerSelect = event.target,
      targetSelect = triggerSelect == size_input ? colour_input : size_input;

  // first set the targetSelect to all be not available
  let targetOptions = targetSelect.getElementsByClassName("option");
  let l = targetOptions.length;
  for(let i = 0; i < l; i++)
    disableOption(targetOptions[i], targetSelect == size_input);

  // next loop through the data to find the ones that should be turned back on
  let triggerProp = triggerSelect == size_input ? "size" : "colour";
  let targetProp = triggerProp == "size" ? "colour" : "size";

  let triggerVal = event.target.value;
  let availVals = [];

  l = option_data.length;
  for(i=0; i < l; i++)
    let jsonData = JSON.parse(option_data[i]);

    if(triggerVal == jsonData[triggerProp])


  // turn the target select options back on
  l = availVals.length;
  for(i = 0; i < l; i++)
    let m = targetOptions.length;
    for(let j = 0; j < m; j++)
      if(targetOptions[j].getAttribute("value") == availVals[i])
        // found a match

  // see if there’s a product code
  // (triggerVal is already set)
  targetVal = targetSelect.value;

  l = option_data.length;
  for(i = 0; i < l; i++)
    let jsonData = JSON.parse(option_data[i]);
    if(jsonData[triggerProp] == triggerVal && jsonData[targetProp] == targetVal)
      code_input.value = jsonData.code; // set product code in hidden input
      toggleSubmitBtn(true); // enable submit button


// inits page
function init()
  // disable button

  // init options dropdown


// check document is ready
document.addEventListener('DOMContentLoaded', function(){

(Some of that might look verbose, I’ve copied and pasted from a large file and tried to strip out anything irrelevant.)

It’s not live yet, but it’s testing fine.

I think for multi-variant products it’s the simplest approach for the client, because they can update the product in a simple table and it’s reasonably idiot-proof.

  • Thanks for replying with that detail! We build sites in Shopify and that's exactly what I was hoping for is a similar way to manage variants.
    – JonnyT
    Jul 18, 2020 at 13:20
  • 1
    We ended up doing something similar to this, however, we created a single global that included all individual variants as their own blocks and used JS to create our own UI.
    – JonnyT
    Jul 18, 2020 at 13:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.