I trust that you found this blog post to be enjoyable. If you are interested in having my team handle your eCommerce setup and marketing for you, Contact Us Click here.

Box/m² Toggle with Dynamic Quantity, Pricing and Wastage Calculation

Shopify Product Page Solutions

Do you sell tiles, flooring, or any other product that’s typically purchased in boxes but measured in square meters? This guide shows you how to enhance your Shopify product page with a Box/m² toggle, dynamic pricing, and a 15% wastage calculation option—all without an app.

What You’ll Build

  • Toggle between quantity input in Boxes or m².
  • Automatically calculate total m² and total price.
  • Optional 15% wastage toggle.
  • All changes reflect in the Shopify quantity field for proper checkout behavior.
  • Dynamic quantity input based on selected unit.

Use Case

Imagine a tile product where:

  • 1 Box = 1.44 m² (set via metafield).
  • Product’s price is per Box.
  • Customer can input area in m².
  • Cart must still receive quantity in Boxes.
  • Optional checkbox: Add 15% wastage.

1 Setup Product Metafield

Create a product.metafield to define how many square meters are in 1 Box.

  • Namespace: custom  
  • Key: box_in_meter_square
  • Type: Number (decimal)

 

This will allow you to set a different m² coverage per product.

Note : If you want to learn how to create metafields, you can click this link to view the guide.

Step 2: Add JavaScript Logic (global.js)

Add this class to your global.js file (or wherever your custom JS lives):

 

class QuantityInputToggle extends HTMLElement {

  constructor() {

    super();

    this.input = this.querySelector("input");

    this.unitToggle = document.getElementById("unit-toggle-switch");

    this.unitLabel = document.getElementById("unitLabel");

    this.unitProperty = document.getElementById("unitProperty");

    this.conversionDisplay = document.getElementById("conversionDisplay");

    this.totalPriceEl = document.getElementById("totalPrice");

    this.wastageCheckbox = document.getElementById("wastage-checkbox");

    this.shopifyQuantityInput = document.getElementById(

      this.dataset.quantityInputId

    );


    this.coveragePerBox = parseFloat(this.dataset.boxInM2 || 1.0);

    if (this.coveragePerBox <= 0) this.coveragePerBox = 1.0;


    this.pricePerBox = parseFloat(this.dataset.boxPrice || 0);

    this.originalQty = null;

    this.lastBoxQty = null;

    this.lastRawQty = null;

    this.isManualInput = false;

    this.unitChanged = false;

    this.wastageApplied = false;


    this.savedQtyBox = null;

    this.savedQtyM2 = null;


    this.input.addEventListener("input", this.onInputChange.bind(this));

    this.input.addEventListener("blur", this.onInputBlur.bind(this));


    this.unitToggle.addEventListener("change", this.onUnitChange.bind(this));

    this.wastageCheckbox?.addEventListener(

      "change",

      this.onWastageChange.bind(this)

    );


    this.preventDecimalInBox();

    this.updateConversion();

  }


  preventDecimalInBox() {

    this.input.addEventListener("keypress", (e) => {

      if (!this.unitToggle.checked && (e.key === "." || e.key === ",")) {

        e.preventDefault();

      }

    });


    this.input.addEventListener("paste", (e) => {

      if (!this.unitToggle.checked) {

        const pasted = (e.clipboardData || window.clipboardData).getData(

          "text"

        );

        if (!/^\d+$/.test(pasted)) {

          e.preventDefault();

        }

      }

    });

  }


  onInputChange() {

    const raw = this.input.value;


    if (raw === "" || raw === "." || raw === "," || raw === "-") return;


    const val = parseFloat(raw);

    const isM2 = this.unitToggle.checked;


    if (!isNaN(val) && val > 0) {

      if (!isM2 && !Number.isInteger(val)) {

        this.input.value = Math.floor(val);

        return;

      }


      this.isManualInput = true;

      this.lastRawQty = val;


      if (this.wastageCheckbox?.checked) {

        this.wastageCheckbox.checked = false;

        this.originalQty = null;

        this.lastBoxQty = null;

        this.wastageApplied = false;

      }

    }


    this.updateConversion();

    this.isManualInput = false;

  }


  onInputBlur() {

    const raw = this.input.value.trim();


    if (raw === "" || isNaN(parseFloat(raw))) {

      const isM2 = this.unitToggle.checked;

      this.input.value = isM2 ? "0.25" : "1";

      this.updateConversion();

    }

  }


  onUnitChange() {

    const isM2 = this.unitToggle.checked;

    this.unitChanged = true;


    if (this.wastageCheckbox.checked && this.lastBoxQty !== null) {

      this.originalQty = isM2

        ? this.lastBoxQty * this.coveragePerBox

        : this.lastBoxQty;

    }


    if (isM2) {

      this.input.step = 0.25;

      this.input.min = 0.25;

      this.input.dataset.unit = "m2";

      this.unitLabel.textContent = "m²";

      this.unitProperty.value = "m2";

      if (this.lastBoxQty !== null) {

        this.input.value = (this.lastBoxQty *  this.coveragePerBox).toFixed(2);

      }

    } else {

      this.input.step = 1;

      this.input.min = 1;

      this.input.dataset.unit = "box";

      this.unitProperty.value = "box";

      if (this.lastBoxQty !== null) {

        this.input.value = Math.ceil(this.lastBoxQty);

      }

    }


    this.updateConversion(true);

  }


  onWastageChange() {

    const isM2 = this.unitToggle.checked;

    const addWastage = this.wastageCheckbox.checked;


    if (addWastage) {

      const boxQty = this.getCurrentBoxQty();

      this.lastBoxQty = boxQty;

      this.originalQty = isM2 ? boxQty * this.coveragePerBox : boxQty;

      this.savedQtyBox = Math.ceil(boxQty);

      this.savedQtyM2 = (Math.ceil(boxQty) *        this.coveragePerBox).toFixed(2); 

      this.wastageApplied = true;

    } else {

      if (this.savedQtyBox !== null && this.savedQtyM2 !== null) {

        this.input.value = this.unitToggle.checked

          ? this.savedQtyM2

          : this.savedQtyBox;

      }

      this.originalQty = null;

      this.lastBoxQty = null;

      this.wastageApplied = false;

    }


    this.updateConversion();

  }


  getCurrentBoxQty() {

    const isM2 = this.unitToggle.checked;

    const inputQty = parseFloat(this.input.value) || 1;

    return isM2

      ? Math.round((inputQty / this.coveragePerBox) * 1000) / 1000

      : inputQty;

  }


  updateConversion(triggeredByToggle = false) {

    const isM2 = this.unitToggle.checked;

    const addWastage = this.wastageCheckbox?.checked;

    let inputQty = parseFloat(this.input.value) || 1;

    if (inputQty <= 0) inputQty = 1;


    if (addWastage && this.originalQty === null) {

      this.originalQty = inputQty;

    }


    let effectiveQty = inputQty;

    if (addWastage && this.originalQty !== null && !triggeredByToggle) {

      effectiveQty = this.originalQty * 1.15;

    }


    let boxQty = isM2

      ? Math.round((effectiveQty / this.coveragePerBox) * 1000) / 1000

      : effectiveQty;


    const roundedBoxQty = Math.ceil(boxQty);

    const totalM2 = (

      Math.round(roundedBoxQty * this.coveragePerBox * 100) / 100

    ).toFixed(2);

    const total = roundedBoxQty * this.pricePerBox;


    if (!addWastage && this.unitChanged) {

      this.unitChanged = false;

      this.originalQty = null;

      this.lastBoxQty = null;

    }


    if (!addWastage || !triggeredByToggle) {

      this.lastBoxQty = boxQty;

    }


    const boxText = isM2 ? "m²" : roundedBoxQty > 1 ? "Boxes" : "Box";

    this.unitLabel.textContent = boxText;


    if (isM2) {

      const pricePerM2 = (this.pricePerBox / this.coveragePerBox).toFixed(2);

      if (!this.isManualInput || triggeredByToggle) {

        this.input.value = totalM2;

      }

      this.conversionDisplay.textContent = `Equals to ${totalM2} m² (${roundedBoxQty} ${

        roundedBoxQty > 1 ? "Boxes" : "Box"

      }), ₹${pricePerM2}/m²`;

    } else {

      if (!this.isManualInput || triggeredByToggle) {

        this.input.value = roundedBoxQty;

      }

      this.conversionDisplay.textContent = `Equals to ${roundedBoxQty} ${

        roundedBoxQty > 1 ? "Boxes" : "Box"

      } (${totalM2} m²), ₹${this.pricePerBox}/Box`;

    }


    if (this.shopifyQuantityInput) {

      this.shopifyQuantityInput.value = roundedBoxQty;

    } else {

      console.warn("Missing Shopify quantity input element.");

    }


    if (this.totalPriceEl) {

      this.totalPriceEl.textContent = `₹${total.toFixed(2)}`;

    }

  }

}

customElements.define("quantity-input-toggle", QuantityInputToggle);

 

This class controls all the logic: unit switching, pricing, conversion, and wastage.

Step 3: Add Schema to main-product.liquid

{

      "type": "quantity_selector_toggle",

      "name": "Quantity Selector Toggle",

      "limit": 1

  },

Step 4: Add Custom Block to Product Template

In main-product.liquid, locate where your quantity selector lives and replace it with:

 

{%- when 'quantity_selector_toggle' -%}

                <div class="quantity_selector_toggle">

                  <style>

                    .switch {

                      position: relative;

                      display: inline-block;

                      width: 52px;

                      height: 28px;

                      margin: 0 8px;

                    }

                    .switch input {

                      opacity: 0;

                      width: 0;

                      height: 0;

                    }

                    .slider {

                      position: absolute;

                      inset: 0;

                      background-color: #ccc;

                      transition: 0.4s;

                      border-radius: 28px;

                    }

                    .slider::before {

                      position: absolute;

                      content: "";

                      height: 20px;

                      width: 20px;

                      left: 4px;

                      top: 4px;

                      background-color: white;

                      transition: 0.4s;

                      border-radius: 50%;

                    }

                    input:checked + .slider {

                      background-color: #1d4ed8;

                    }

                    input:checked + .slider::before {

                      transform: translateX(24px);

                    }

                  </style>


                  {% assign box_in_m2 = product.metafields.custom.box_in_meter_sqaure.value | default: 1.0 %}

                  {% assign box_price = product.selected_or_first_available_variant.price | money_without_currency %}


                  <div class="product-form__input product-form__quantity">

                    <label class="quantity__label form__label" for="QuantityInput">

                      Select Quantity

                      <span class="quantity__rules-cart hidden">

                        <div class="loading__spinner hidden">

                          <svg xmlns="http://www.w3.org/2000/svg" class="spinner" viewBox="0 0 66 66">

                            <circle stroke-width="6" cx="33" cy="33" r="30" fill="none" class="path"></circle>

                          </svg>

                        </div>

                      </span>

                    </label>


                    <!-- Toggle Switch -->

                    <div class="flex items-center mb-3">

                      <span id="label-box" class="text-sm font-medium text-blue-900">Box</span>

                      <label class="switch">

                        <input type="checkbox" id="unit-toggle-switch">

                        <span class="slider round"></span>

                      </label>

                      <span id="label-m2" class="text-sm font-medium text-gray-500">m²</span>

                    </div>


                    <!-- Quantity Input (UI only) -->

                    <quantity-input-toggle

                      class="quantity flex border border-gray-300 rounded items-center px-3 py-2"

                      data-box-in-m2="{{ box_in_m2 }}"

                      data-box-price="{{ box_price }}"

                      data-quantity-input-id="Quantity-{{ section.id }}"

                    >

                      <input

                        class="quantity__input"

                        type="number"

                        id="QuantityInput"

                        value="1"

                        min="1"

                        step="1"

                        data-unit="box"

                      >

                      <span id="unitLabel" class="ml-auto text-sm text-blue-900 font-semibold uppercase">Box</span>

                    </quantity-input-toggle>


                    <!-- Conversion Info -->

                    <div class="quantity__rules caption mt-2" id="conversionDisplay">

                      Equals to 1 Box ({{ box_in_m2 }} m²)

                    </div>


                    <!-- 15% Wastage Option -->

                    <div class="mt-2 flex items-center gap-2">

                      <input

                        type="checkbox"

                        id="wastage-checkbox"

                        class="w-4 h-4 text-blue-600 border-gray-300 rounded"

                      >

                      <label for="wastage-checkbox" class="text-sm text-gray-700">Add 15% Wastage</label>

                    </div>


                    <!-- Shopify Hidden Quantity Input (box count) -->

                    <input

                      type="number"

                      name="quantity"

                      id="Quantity-{{ section.id }}"

                      value="1"

                      form="{{ product_form_id }}"

                      style="position: absolute; left: -9999px;"

                    >


                    <!-- Hidden properties -->

                    <input type="hidden" name="properties[Unit]" id="unitProperty" value="box">


                    <!-- Total Price Block -->

                    <div class="product_totals mt-4">

                      <h2 class="totals__total-pro text-base font-semibold">

                        Total <small class="text-sm font-normal text-gray-500">Including GST</small>

                      </h2>

                      <p class="totals__total-product-value text-xl font-bold text-blue-900" id="totalPrice">

                        ₹{{ box_price }}

                      </p>

                    </div>

                  </div>

                </div>

This block includes:

  • The Box/m² switch
  • Visible quantity input
  • Hidden Shopify quantity field
  • Total price calculator
  • 15% wastage checkbox
  • Metafield-driven dynamic logic

Note: It uses : product.metafields.custom.box_in_meter_square and Product.selected_or_first_available_variant.price

Step 5: Customize via Theme Editor

Now that the block is available:

  • Go to Online Store → Customize
  • Select your product template
  • Remove the default Quantity Selector block
  • Add block → Quantity Selector Toggle

 


Done! You now have a powerful, flexible quantity selection system live on your product page.

 

Final Result

  • Clean toggle switch: Box ↔ m²
  • Accurate conversion based on box_in_meter_square metafield
  • Live pricing updates
  • Optional 15% wastage for real-world project use
  • Invisible hidden input sends correct box quantity to Shopify cart

Conclusion

This toggle setup is perfect for flooring stores, construction materials, tiles, wallpapers, or any industry where products are measured in square meters but sold in Boxes. It’s user-friendly, technically sound, and fully Shopify compatible.

Frequently Asked Questions

Q1. How does the Box/m² toggle affect the final cart quantity ?

A. The toggle allows customers to input quantity in either Boxes or m², but the Shopify cart always receives the quantity in Boxes to ensure proper inventory and pricing logic.

Q2. What if a customer enters a decimal value in Box mode ?

A. Box mode automatically prevents decimal inputs. It only allows whole numbers, as partial boxes are typically not sold.

Q3. How is the 15% wastage applied ?

A. When the “Add 15% Wastage” checkbox is enabled, the input area (in m² or Boxes) is increased by 15%, and the final required quantity is recalculated and rounded up to the nearest box.

Q4. Can I set different m² coverage for each product ?

A. Yes. The system uses a metafield (custom.box_in_meter_square) that allows each product to define how much area one box covers. This makes it flexible for tiles, flooring, wallpapers, etc.

Q5. Will this work with all Shopify themes ?

A. This solution works with any Online Store 2.0 theme, including Dawn, as long as the custom block and script are correctly inserted. Minimal CSS may be required for design consistency.

Back to blog

Are you interested in boosting your sales orders?

"PTI WebTech has a proven strategy to boost your sales. Contact us to learn more."

Conact Us

Newsletter

Subscribe our Newsletter for our blog posts and our new updates. Let's stay updated!

Our Latest Post