Skip to main content

Product Sections

Product sections render on the product detail page. They include breadcrumbs, the image gallery, product information, description, reviews, and the sticky buy button.


File: sections/breadcrumbs.liquid

Breadcrumbs show the navigation path to the current product. They appear on the product page. Internal <a href="/collections/..."> links are intercepted for client-side navigation.

Variables

VariableTypeDescription
categoriesarrayParent categories — each has name and slug
product_namestringCurrent product name
home_textstringTranslated "Home" label

Example

<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
<li>
<a href="/">{{ home_text }}</a>
<span class="sep">/</span>
</li>
{% for crumb in categories %}
<li>
<a href="/collections/{{ crumb.slug }}">{{ crumb.name }}</a>
<span class="sep">/</span>
</li>
{% endfor %}
<li aria-current="page">{{ product_name }}</li>
</ol>
</nav>
info

Breadcrumbs can optionally appear inside the product details area instead of above it, controlled by the is_breadcrumbs_in_details flag set in the admin panel.


File: sections/gallery.liquid

The product image gallery shows the main image/video and and the rest of the product images.

Variables

VariableTypeDescription
imagesstring[]All gallery asset URLs (images and/or videos), in display order
mainImagestringThe URL currently shown in the main area (selected image/video)
product_namestringProduct name — use for alt (and similar) text
theme_dataobjectMerchant-configured dynamic settings from the theme editor

Video assets

Treat .mp4 (or any URL you intend as video) as video in Liquid: use a <video> element for the main area and thumbnails when the URL indicates a video. A typical Liquid check is contains '.mp4'.

info

The gallery does not use custom events to talk back to the app. Thumbnail or swipe behavior is entirely up to your Liquid and script.js (purely client-side DOM updates), unless the shopper changes variant or navigates in a way that causes the parent to pass new mainImage / images.

This layout is a tabs gallery: one panel shows the current mainImage; the thumbnail row behaves like tabs—each button targets another asset. On click, your script.js should update the main <img> / <video> src, refresh aria-selected on the buttons, and swap video vs image in the main area if needed.

<div role="region" aria-label="{{ product_name }} — gallery">
<div>
{% if mainImage contains '.mp4' %}
<video
src="{{ mainImage }}"
controls
playsinline
autoplay
muted
preload="metadata"
></video>
{% else %}
<img
id="gallery-main-image"
src="{{ mainImage }}"
alt="{{ product_name }}"
/>
{% endif %}
</div>

{% if images.size > 1 %}
<div role="tablist" aria-label="Product images">
{% for image in images %}
<button
type="button"
role="tab"
aria-selected="{% if image == mainImage %}true{% else %}false{% endif %}"
aria-label="View image {{ forloop.index }}"
data-src="{{ image }}"
>
{% if image contains '.mp4' %}
<video src="{{ image }}" muted playsinline preload="metadata"></video>
{% else %}
<img src="{{ image }}" alt="{{ product_name }}" loading="lazy" />
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
</div>

Product Details

File: sections/product-details.liquid

Displays the product name, price, rating, and optional short description.

Variables

VariableTypeDescription
product_namestringProduct name
pricenumberRegular price
sale_pricenumber | nullSale price (if on sale)
currencystringCurrency symbol/code
ratingnumberAverage rating (0–5)
reviews_countnumberTotal number of reviews
descriptionstring | nullShort description (when is_description_in_details is true)
theme_dataobjectMerchant-configured dynamic settings

Example

<div class="product-details">
<h1>{{ product_name }}</h1>

<div class="price-row">
{% if sale_price and sale_price < price %}
<span class="price-old">{{ price }} {{ currency }}</span>
<span class="price-sale">{{ sale_price }} {{ currency }}</span>
{% else %}
<span class="price">{{ price }} {{ currency }}</span>
{% endif %}
</div>

{% if rating > 0 %}
<div class="rating">
{% assign full_stars = rating | floor %}
{% for i in (1..5) %}
{% if i <= full_stars %}
<span class="star filled"></span>
{% else %}
<span class="star empty"></span>
{% endif %}
{% endfor %}
<span>({{ reviews_count }})</span>
</div>
{% endif %}

{% if description and description != "" %}
<p class="short-desc">{{ description }}</p>
{% endif %}
</div>

Product Description

File: sections/product-description.liquid

Displays the full product description in an accordion item, then renders additional merchant policy items (shipping, refund, COD, etc.) from the policies list.

Variables

VariableTypeDescription
descriptionstringFull product description (HTML)
description_labelstringTranslated label (e.g. "Description")
policies{ icon: string; title: string; content: string }[]Policy rows rendered after description
theme_dataobjectMerchant-configured dynamic settings
info

The product description can optionally render inside the product details area instead of below the main product column, controlled by the is_description_in_details flag set in the admin panel. Policy/content rendering can be accordion or tabs based on your preference.

Full Example (Accordion)

This is a full example of the accordion mode using product-description.liquid, style.css, and script.js.

sections/product-description.liquid

<div class="lq-desc-accordion">
{% if description != "" %}
<div class="lq-desc-item" data-open="true">
<button class="lq-desc-toggle" type="button" aria-expanded="true">
<span>{{ description_label }}</span>
<span class="lq-desc-chevron"></span>
</button>
<div class="lq-desc-panel" style="display:block">
<div class="lq-desc-content product_description">
<div class="ql-editor leading-10" style="text-align:start" dir="auto">{{ description }}</div>
</div>
</div>
</div>
{% endif %}

{% for policy in policies %}
<div class="lq-desc-item">
<button class="lq-desc-toggle" type="button" aria-expanded="false">
<span class="lq-desc-toggle-label">
<img src="{{ policy.icon }}" alt="" class="lq-desc-icon" />
<span>{{ policy.title }}</span>
</span>
<span class="lq-desc-chevron"></span>
</button>
<div class="lq-desc-panel" style="display:none">
<div class="lq-desc-content">{{ policy.content }}</div>
</div>
</div>
{% endfor %}
</div>

style.css

.lq-desc-accordion {
margin-top: 1.5rem;
border-top: 1px solid #e5e1dc;
}

.lq-desc-item {
border-bottom: 1px solid #e5e1dc;
}

.lq-desc-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 0;
background: transparent;
border: 0;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--hd-text, #212a2f);
text-align: start;
}

.lq-desc-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
}

.lq-desc-icon {
width: 20px;
height: 20px;
min-width: 20px;
opacity: 0.7;
}

.lq-desc-chevron {
position: relative;
display: inline-block;
width: 14px;
height: 14px;
min-width: 14px;
}

.lq-desc-chevron::before,
.lq-desc-chevron::after {
content: "";
position: absolute;
background: currentColor;
transition: transform 0.25s ease;
}

.lq-desc-chevron::before {
width: 14px;
height: 1.5px;
top: 50%;
left: 0;
transform: translateY(-50%);
}

.lq-desc-chevron::after {
width: 1.5px;
height: 14px;
top: 0;
left: 50%;
transform: translateX(-50%);
}

.lq-desc-item[data-open="true"] .lq-desc-chevron::after {
transform: translateX(-50%) scaleY(0);
}

.lq-desc-panel {
overflow: hidden;
}

.lq-desc-content {
padding: 0 0 1.25rem;
font-size: 0.875rem;
line-height: 1.75;
color: #555;
}

script.js

function initDescriptionAccordion() {
var accordions = document.querySelectorAll(".lq-desc-accordion");

for (var i = 0; i < accordions.length; i++) {
var accordion = accordions[i];
if (accordion.dataset.descInit) continue;

accordion.dataset.descInit = "1";

accordion.addEventListener("click", function (event) {
var toggle = event.target.closest(".lq-desc-toggle");
if (!toggle) return;

var item = toggle.parentElement;
var panel = item && item.querySelector(".lq-desc-panel");
if (!panel) return;

var isOpen = item.getAttribute("data-open") === "true";

if (isOpen) {
item.removeAttribute("data-open");
toggle.setAttribute("aria-expanded", "false");
panel.style.display = "none";
} else {
item.setAttribute("data-open", "true");
toggle.setAttribute("aria-expanded", "true");
panel.style.display = "block";
}
});
}
}

initDescriptionAccordion();

Full Example (Tabs)

This is a full example of the tabs mode using product-description.liquid, style.css, and script.js.

sections/product-description.liquid

<div class="lq-desc-tabs">
<div class="lq-desc-tab-nav" role="tablist">
{% if description != "" %}
<button
class="lq-desc-tab active"
role="tab"
aria-selected="true"
data-tab="description"
>
<span>{{ description_label }}</span>
</button>
{% endif %}

{% for policy in policies %}
<button
class="lq-desc-tab"
role="tab"
aria-selected="false"
data-tab="policy-{{ forloop.index0 }}"
>
<img src="{{ policy.icon }}" alt="" class="lq-desc-icon" />
<span>{{ policy.title }}</span>
</button>
{% endfor %}
</div>

<div class="lq-desc-tab-panels">
{% if description != "" %}
<div class="lq-desc-tab-panel active" data-panel="description">
<div class="lq-desc-content product_description">
<div class="ql-editor leading-10" style="text-align:start" dir="auto">{{ description }}</div>
</div>
</div>
{% endif %}

{% for policy in policies %}
<div class="lq-desc-tab-panel" data-panel="policy-{{ forloop.index0 }}">
<div class="lq-desc-content">{{ policy.content }}</div>
</div>
{% endfor %}
</div>
</div>

style.css

.lq-desc-tabs {
margin-top: 2.5rem;
background: #fff;
border-radius: 0.75rem;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.04);
}

.lq-desc-tab-nav {
display: flex;
gap: 0;
border-bottom: 1px solid #e5e1dc;
overflow-x: auto;
scrollbar-width: none;
}

.lq-desc-tab-nav::-webkit-scrollbar {
display: none;
}

.lq-desc-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.875rem 1.5rem;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #999;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
transition:
color 0.2s,
border-color 0.2s;
}

.lq-desc-tab .lq-desc-icon {
width: 16px;
height: 16px;
min-width: 16px;
opacity: 0.6;
}

.lq-desc-tab.active,
.lq-desc-tab:hover {
color: var(--hd-text, #212a2f);
}

.lq-desc-tab.active {
border-bottom-color: var(--hd-text, #212a2f);
}

.lq-desc-tab-panels {
padding: 1.5rem;
}

.lq-desc-tab-panel {
display: none;
}

.lq-desc-tab-panel.active {
display: block;
}

.lq-desc-tab-panel .lq-desc-content {
padding: 0;
font-size: 0.875rem;
line-height: 1.75;
color: #555;
}

script.js

function initDescriptionTabs() {
var tabsWrappers = document.querySelectorAll(".lq-desc-tabs");

for (var i = 0; i < tabsWrappers.length; i++) {
var tabsWrapper = tabsWrappers[i];
if (tabsWrapper.dataset.tabsInit) continue;

tabsWrapper.dataset.tabsInit = "1";

tabsWrapper.addEventListener("click", function (event) {
var tab = event.target.closest(".lq-desc-tab");
if (!tab) return;

var tabKey = tab.getAttribute("data-tab");
if (!tabKey) return;

var tabs = tabsWrapper.querySelectorAll(".lq-desc-tab");
var panels = tabsWrapper.querySelectorAll(".lq-desc-tab-panel");

for (var j = 0; j < tabs.length; j++) {
tabs[j].classList.remove("active");
tabs[j].setAttribute("aria-selected", "false");
}

for (var k = 0; k < panels.length; k++) {
panels[k].classList.remove("active");
}

tab.classList.add("active");
tab.setAttribute("aria-selected", "true");

var activePanel = tabsWrapper.querySelector('[data-panel="' + tabKey + '"]');
if (activePanel) activePanel.classList.add("active");
});
}
}

initDescriptionTabs();

Fixed Buy Button

File: sections/fixed-buy-button.liquid

A sticky bar at the bottom of the product page with the product thumbnail, price, quantity controls, and a buy button.

Variables

VariableTypeDescription
product_namestringProduct name
pricenumberRegular price
sale_pricenumber | nullSale price
currencystringCurrency symbol/code
thumbstringProduct thumbnail URL
buy_now_textstringTranslated buy button label
quantitynumberCurrent quantity
disabledbooleanWhether the buy button is disabled
hide_quantitybooleanWhether to hide quantity controls
increase_disabledbooleanWhether the + button is disabled (max qty reached)
theme_dataobjectMerchant-configured dynamic settings

Events

EventDetail
buy-nowTriggers the add-to-cart / buy action
increment-quantityIncreases quantity by 1
decrement-quantityDecreases quantity by 1

Example

<div class="fixed-buy-bar">
{% if thumb and thumb != "" %}
<img src="{{ thumb }}" alt="{{ product_name }}" />
<div class="fixed-buy-info">
<span>{{ product_name }}</span>
{% if sale_price and sale_price < price %}
<span class="price-old">{{ price }} {{ currency }}</span>
<span class="price-sale">{{ sale_price }} {{ currency }}</span>
{% else %}
<span>{{ price }} {{ currency }}</span>
{% endif %}
</div>
{% endif %}

{% unless hide_quantity %}
<div class="qty-controls">
<button type="button"
onclick="this.dispatchEvent(new CustomEvent('increment-quantity',{bubbles:true}));"
{% if increase_disabled %}disabled{% endif %}>+</button>
<span>{{ quantity }}</span>
<button type="button"
onclick="this.dispatchEvent(new CustomEvent('decrement-quantity',{bubbles:true}));">-</button>
</div>
{% endunless %}

<button type="button" {% if disabled %}disabled{% endif %}
onclick="event.preventDefault();this.dispatchEvent(new CustomEvent('buy-now',{bubbles:true}));">
{{ buy_now_text }}
</button>
</div>
warning

All three events (buy-now, increment-quantity, decrement-quantity) must be dispatched as CustomEvent with bubbles: true. Without them, the buy button and quantity controls will not work.


Reviews

File: sections/reviews.liquid

Displays product reviews with ratings, comments, and images. Includes a button to open the review submission modal (handled by the app via React).

Variables

VariableTypeDescription
reviewsarrayReview objects — each has rating, user_name, comment, image
reviews_countnumberTotal review count
average_ratingnumberAverage rating (0–5)
average_rating_displaystringFormatted average (e.g. "4.5")
t_users_reviewsstringTranslated "Customer Reviews" heading
t_reviewsstringTranslated "reviews" label
t_share_your_reviewstringTranslated "Write a review" button text
t_no_reviewsstringTranslated "No reviews yet" text
theme_dataobjectMerchant-configured dynamic settings

Data Attributes

AttributeElementPurpose
data-review-openbuttonOpens the review submission modal

Example

<div class="reviews">
<div class="reviews-header">
<h2>{{ t_users_reviews }}</h2>
<span>{{ average_rating_display }} ({{ reviews_count }} {{ t_reviews }})</span>
<button data-review-open type="button">{{ t_share_your_review }}</button>
</div>

{% for review in reviews %}
<div class="review-card">
<div class="review-stars">
{% for i in (1..5) %}
{% if i <= review.rating %}
<span class="star filled"></span>
{% else %}
<span class="star empty"></span>
{% endif %}
{% endfor %}
</div>
{% if review.user_name and review.user_name != "" %}
<h3>{{ review.user_name }}</h3>
{% endif %}
<p>{{ review.comment }}</p>
{% if review.image and review.image != "" %}
<img src="{{ review.image }}" alt="{{ review.user_name }}" loading="lazy" />
{% endif %}
</div>
{% endfor %}

{% if reviews.size == 0 %}
<p>{{ t_no_reviews }}</p>
{% endif %}
</div>