Best approach for Craft Commerce 3 - Variants & Custom Text
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 %}
&lt;div class=&quot;product__options&quot;&gt;
  &lt;form class=&quot;#&quot; action=&quot;#&quot; method=&quot;post&quot;&gt;
    &lt;div class=&quot;form__wrapper&quot;&gt;
    
      &lt;fieldset class=&quot;form__group&quot;&gt;
        &lt;div class=&quot;select__wrapper&quot;&gt;
          &lt;label class=&quot;label&quot; for=&quot;opt__opt&quot;&gt;Options&lt;/label&gt;
          &lt;select class=&quot;input--select&quot; id=&quot;opt__opt&quot; name=&quot;options&quot;&gt;
            {% for row in entry.options %}
            &lt;option class=&quot;sel__option&quot; data-colour=&quot;{{ row.colour }}&quot; data-size=&quot;{{ row.size }}&quot; data-code=&quot;{{ row.code }}&quot; value=&quot;{{row.code}} - {{ row.size }} - {{ row.colour }}&quot;&gt;Size: {{ row.size }} | Colour: {{ row.colour }}&lt;/option&gt;
            {% endfor %}
          &lt;/select&gt;
        &lt;/div&gt;
      &lt;/fieldset&gt;

      &lt;fieldset class=&quot;form__group form__group--submit&quot;&gt;
        &lt;button class=&quot;button button--submit&quot; type=&quot;submit&quot; name=&quot;submit&quot; id=&quot;opt__btn&quot;&gt;Get Quick Quote&lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/div&gt;
  &lt;/form&gt;

&lt;/div&gt;
{% 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,
    colour_input,
    code_input;

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

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

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

  lw.appendChild(lbl);

  parent.appendChild(lw);

}


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

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

  // create default
  let def = document.createElement(&quot;option&quot;);
  def.innerHTML = prompt;
  def.setAttribute(&quot;disabled&quot;, &quot;disabled&quot;);
  def.setAttribute(&quot;selected&quot;, &quot;selected&quot;);
  sel.appendChild(def);

  let l = array.length;
  for(let i = 0; i &lt; l; i++)
  {
    // create option
    let opt = document.createElement(&quot;option&quot;);

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

    // append option
    sel.appendChild(opt);
  }

  // insert select
  parent.appendChild(sel);

  //return reference
  return sel;

}


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

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

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

  // add to page
  parent.appendChild(inp);

  // return reference
  return inp;

}


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

  return !isFound;
}


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

  if(willEnable)
  {
    submitBtn.removeAttribute(&quot;disabled&quot;);
  }
  else
  {
    submitBtn.setAttribute(&quot;disabled&quot;, &quot;disabled&quot;);
  }
}

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

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

  // 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 &lt; l; i++)
  {
    const t = orig_options_children[i].dataset;
    var json = JSON.stringify({
      colour: t.colour,
      size: t.size,
      code: t.code
    })
    option_data.push(json);

    // 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)
  colour_arr.sort();
  size_arr.sort(function(a, b) {return a - b});

  // remove the old dropdown
  orig_options_grandparent.removeChild(orig_options_parent);

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

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

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

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


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

}


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

// handles the enabling of an option
function enableOption(option)
{
  option.removeAttribute(&quot;disabled&quot;);
  option.innerHTML = option.getAttribute(&quot;value&quot;);
}


// 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(&quot;option&quot;);
  let l = targetOptions.length;
  for(let i = 0; i &lt; 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 ? &quot;size&quot; : &quot;colour&quot;;
  let targetProp = triggerProp == &quot;size&quot; ? &quot;colour&quot; : &quot;size&quot;;

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

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

    if(triggerVal == jsonData[triggerProp])
    {
      availVals.push(jsonData[targetProp]);
    }

  }

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

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

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

}



// inits page
function init()
{
  // disable button
  toggleSubmitBtn(false);

  // init options dropdown
  initOptions();

}



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

(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.