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)


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.