Product Page Implementation Intermediate 10 min read

Quantity Controls and Line-Item Properties

Build quantity selectors with increment/decrement buttons, handle min/max limits, and add custom line-item properties for personalization.

Quantity controls let customers order multiple items, while line-item properties enable personalization and customization. Together, they create flexible purchasing options.

Basic Quantity Input

The simplest quantity selector:

<input
type="number"
name="quantity"
value="1"
min="1"
class="quantity-input"
>

Enhanced Quantity Selector

Add increment/decrement buttons for better UX:

{# snippets/quantity-selector.liquid #}
{%- assign input_id = id | default: 'quantity' -%}
{%- assign input_value = value | default: 1 -%}
{%- assign input_min = min | default: 1 -%}
{%- assign input_max = max | default: 99 -%}
<quantity-selector class="quantity-selector">
<button
type="button"
class="quantity-selector__button"
data-action="decrease"
aria-label="Decrease quantity"
{% if input_value <= input_min %}disabled{% endif %}
>
<span aria-hidden="true">−</span>
</button>
<input
type="number"
id="quantity-{{ input_id }}"
name="quantity"
class="quantity-selector__input"
value="{{ input_value }}"
min="{{ input_min }}"
max="{{ input_max }}"
step="1"
aria-label="Quantity"
>
<button
type="button"
class="quantity-selector__button"
data-action="increase"
aria-label="Increase quantity"
{% if input_value >= input_max %}disabled{% endif %}
>
<span aria-hidden="true">+</span>
</button>
</quantity-selector>

Quantity Selector Styles

.quantity-selector {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
}
.quantity-selector__button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
font-size: 1.25rem;
background: none;
border: none;
cursor: pointer;
color: var(--color-text);
transition: background-color 0.2s;
}
.quantity-selector__button:hover:not(:disabled) {
background: var(--color-background-secondary);
}
.quantity-selector__button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.quantity-selector__input {
width: 60px;
height: 44px;
padding: 0;
font-size: 1rem;
text-align: center;
border: none;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
background: transparent;
-moz-appearance: textfield;
}
.quantity-selector__input::-webkit-inner-spin-button,
.quantity-selector__input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.quantity-selector__input:focus {
outline: none;
background: var(--color-background-secondary);
}

Quantity Selector JavaScript

class QuantitySelector extends HTMLElement {
constructor() {
super();
this.input = this.querySelector('.quantity-selector__input');
this.decreaseBtn = this.querySelector('[data-action="decrease"]');
this.increaseBtn = this.querySelector('[data-action="increase"]');
}
connectedCallback() {
this.decreaseBtn.addEventListener('click', () => this.change(-1));
this.increaseBtn.addEventListener('click', () => this.change(1));
this.input.addEventListener('change', () => this.onInputChange());
}
change(delta) {
const currentValue = parseInt(this.input.value) || 1;
const newValue = currentValue + delta;
this.setValue(newValue);
}
setValue(value) {
const min = parseInt(this.input.min) || 1;
const max = parseInt(this.input.max) || 99;
// Clamp value to min/max
value = Math.max(min, Math.min(max, value));
this.input.value = value;
// Update button states
this.decreaseBtn.disabled = value <= min;
this.increaseBtn.disabled = value >= max;
// Dispatch change event
this.dispatchEvent(
new CustomEvent('quantity:changed', {
detail: { quantity: value },
bubbles: true,
})
);
}
onInputChange() {
let value = parseInt(this.input.value);
if (isNaN(value) || value < 1) {
value = 1;
}
this.setValue(value);
}
}
customElements.define('quantity-selector', QuantitySelector);

Inventory-Based Limits

Set max quantity based on stock:

{%- assign max_quantity = 99 -%}
{%- if current_variant.inventory_management and current_variant.inventory_policy == 'deny' -%}
{%- if current_variant.inventory_quantity > 0 -%}
{%- assign max_quantity = current_variant.inventory_quantity -%}
{%- endif -%}
{%- endif -%}
{% render 'quantity-selector',
value: 1,
min: 1,
max: max_quantity
%}

Bundle Quantity

For products sold in multiples:

{# Product sold in packs of 6 #}
{%- assign quantity_step = 6 -%}
<input
type="number"
name="quantity"
value="{{ quantity_step }}"
min="{{ quantity_step }}"
step="{{ quantity_step }}"
>

Line-Item Properties

Line-item properties add custom data to cart items:

<input
type="hidden"
name="properties[Gift]"
value="true"
>
<label>
<input type="checkbox" name="properties[Gift]" value="Yes">
This is a gift
</label>

Text Customization

Allow customers to personalize products:

<div class="product-form__customization">
<label for="engraving" class="product-form__label">
Engraving Text
<span class="product-form__optional">(optional)</span>
</label>
<input
type="text"
id="engraving"
name="properties[Engraving]"
maxlength="20"
placeholder="Enter up to 20 characters"
class="product-form__text-input"
>
<p class="product-form__hint">
Text will appear exactly as entered
</p>
</div>

Multiple Property Inputs

<div class="product-form__properties">
{# Text input #}
<div class="property-field">
<label for="prop-name">Name</label>
<input type="text" id="prop-name" name="properties[Name]" required>
</div>
{# Select dropdown #}
<div class="property-field">
<label for="prop-font">Font Style</label>
<select id="prop-font" name="properties[Font]">
<option value="Script">Script</option>
<option value="Block">Block</option>
<option value="Modern">Modern</option>
</select>
</div>
{# Checkbox #}
<div class="property-field">
<label>
<input type="checkbox" name="properties[Gift Wrap]" value="Yes">
Add gift wrapping (+$5)
</label>
</div>
{# Date picker #}
<div class="property-field">
<label for="prop-date">Delivery Date</label>
<input type="date" id="prop-date" name="properties[Delivery Date]">
</div>
{# Textarea #}
<div class="property-field">
<label for="prop-message">Gift Message</label>
<textarea
id="prop-message"
name="properties[Gift Message]"
rows="3"
maxlength="200"
></textarea>
</div>
</div>

Hidden Properties

Track internal data without showing to customer:

{# Hidden properties start with underscore #}
<input type="hidden" name="properties[_source]" value="product-page">
<input type="hidden" name="properties[_added_at]" value="{{ 'now' | date: '%Y-%m-%d' }}">

Properties starting with underscore (_) are hidden in the cart and checkout.

File Upload Property

Allow file uploads for custom products:

<div class="product-form__upload">
<label for="custom-image">Upload Your Image</label>
<input
type="file"
id="custom-image"
name="properties[Custom Image]"
accept="image/*"
>
<p class="product-form__hint">
Accepted formats: JPG, PNG. Max 5MB.
</p>
</div>

Note: File uploads require additional configuration.

Dynamic Properties with JavaScript

Add properties dynamically:

class ProductProperties extends HTMLElement {
connectedCallback() {
this.form = this.closest('form');
this.setupDynamicProperties();
}
setupDynamicProperties() {
// Add property when checkbox changes
const giftWrap = this.querySelector('[name="properties[Gift Wrap]"]');
giftWrap?.addEventListener('change', (e) => {
if (e.target.checked) {
this.showGiftOptions();
} else {
this.hideGiftOptions();
}
});
}
showGiftOptions() {
this.querySelector('.gift-options').hidden = false;
}
hideGiftOptions() {
this.querySelector('.gift-options').hidden = true;
// Clear values
this.querySelector('[name="properties[Gift Message]"]').value = '';
}
}
customElements.define('product-properties', ProductProperties);

Displaying Properties in Cart

Show line-item properties in the cart:

{%- for item in cart.items -%}
<div class="cart-item">
<p>{{ item.product.title }}</p>
{# Show custom properties #}
{%- if item.properties.size > 0 -%}
<ul class="cart-item__properties">
{%- for property in item.properties -%}
{%- unless property.first contains '_' -%}
<li>
<strong>{{ property.first }}:</strong> {{ property.last }}
</li>
{%- endunless -%}
{%- endfor -%}
</ul>
{%- endif -%}
</div>
{%- endfor -%}

Character Counter

For text inputs with limits:

<div class="property-field">
<label for="engraving">
Engraving
<span class="character-count">
<span data-current-count>0</span>/20
</span>
</label>
<input
type="text"
id="engraving"
name="properties[Engraving]"
maxlength="20"
data-character-counter
>
</div>
document.querySelectorAll('[data-character-counter]').forEach((input) => {
const counter = input.closest('.property-field').querySelector('[data-current-count]');
input.addEventListener('input', () => {
counter.textContent = input.value.length;
});
});

Complete Quantity and Properties Example

{# snippets/product-form-extended.liquid #}
<div class="product-form__options">
{# Quantity #}
<div class="product-form__group">
<label class="product-form__label">Quantity</label>
{%- render 'quantity-selector',
id: section.id,
value: 1,
max: current_variant.inventory_quantity | default: 99
-%}
</div>
{# Custom properties based on product #}
{%- if product.tags contains 'personalized' -%}
<product-properties class="product-form__properties">
<div class="product-form__group">
<label for="custom-text-{{ section.id }}" class="product-form__label">
Personalization Text
<span class="product-form__required">*</span>
</label>
<input
type="text"
id="custom-text-{{ section.id }}"
name="properties[Custom Text]"
maxlength="15"
required
class="product-form__input"
placeholder="Enter your text"
>
<p class="product-form__hint">
15 characters max. Will appear exactly as typed.
</p>
</div>
</product-properties>
{%- endif -%}
{# Gift options #}
<div class="product-form__group">
<label class="product-form__checkbox">
<input
type="checkbox"
name="properties[Gift]"
value="Yes"
data-gift-toggle
>
<span>This is a gift</span>
</label>
<div class="gift-options" hidden data-gift-options>
<div class="product-form__group">
<label for="gift-message-{{ section.id }}" class="product-form__label">
Gift Message
</label>
<textarea
id="gift-message-{{ section.id }}"
name="properties[Gift Message]"
rows="3"
maxlength="200"
class="product-form__textarea"
placeholder="Add a personal message..."
></textarea>
</div>
</div>
</div>
</div>
<script>
document.querySelector('[data-gift-toggle]')?.addEventListener('change', (e) => {
document.querySelector('[data-gift-options]').hidden = !e.target.checked;
});
</script>

Practice Exercise

Build a product form with:

  1. Quantity selector with +/- buttons
  2. Max quantity based on inventory
  3. Text customization field with character limit
  4. Optional gift message checkbox
  5. Show gift options when checked

Test by:

  • Changing quantities
  • Hitting min/max limits
  • Adding customization text
  • Toggling gift options
  • Adding to cart and checking properties

Key Takeaways

  1. Use increment/decrement buttons for better mobile UX
  2. Set min/max based on inventory
  3. Line-item properties use name="properties[Name]"
  4. Hidden properties start with underscore (_)
  5. Display properties in cart except hidden ones
  6. Validate required properties before submission
  7. Character counters help with limits
  8. Conditional properties based on checkboxes

What’s Next?

The next lesson covers Dynamic Content: Metafields and Tabs for displaying rich product information.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...