Collection Pages and Merchandising Controls Intermediate 15 min read

Product Grid Section: Cards, Images, Badges

Build a flexible product grid with responsive cards, optimized images, sale badges, and quick-add functionality.

The product grid is the heart of a collection page. A well-designed grid showcases products effectively while giving customers the information they need to make purchasing decisions.

Basic Product Grid Structure

{# sections/collection-product-grid.liquid #}
{%- paginate collection.products by section.settings.products_per_page -%}
<div class="product-grid-section">
<ul
class="product-grid"
style="--columns: {{ section.settings.columns_desktop }};"
>
{%- for product in collection.products -%}
<li class="product-grid__item">
{% render 'product-card', product: product %}
</li>
{%- endfor -%}
</ul>
{%- if paginate.pages > 1 -%}
{% render 'pagination', paginate: paginate %}
{%- endif -%}
</div>
{%- endpaginate -%}

Product Grid CSS

.product-grid {
display: grid;
grid-template-columns: repeat(var(--columns, 4), 1fr);
gap: var(--spacing-lg);
list-style: none;
padding: 0;
margin: 0;
}
/* Responsive columns */
@media (max-width: 1023px) {
.product-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 767px) {
.product-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
}
}
@media (max-width: 479px) {
.product-grid {
gap: var(--spacing-sm);
}
}

The Product Card Snippet

Create a reusable product card:

{# snippets/product-card.liquid #}
{%- liquid
assign show_vendor = show_vendor | default: settings.show_vendor
assign show_rating = show_rating | default: false
assign show_quick_add = show_quick_add | default: true
assign image_ratio = image_ratio | default: 'portrait'
-%}
<div class="product-card">
{# Product link wrapper #}
<a href="{{ product.url }}" class="product-card__link">
{# Image container #}
<div class="product-card__media" style="--ratio: {{ image_ratio }};">
{%- if product.featured_image -%}
<img
src="{{ product.featured_image | image_url: width: 600 }}"
srcset="
{{ product.featured_image | image_url: width: 300 }} 300w,
{{ product.featured_image | image_url: width: 450 }} 450w,
{{ product.featured_image | image_url: width: 600 }} 600w,
{{ product.featured_image | image_url: width: 800 }} 800w
"
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
alt="{{ product.featured_image.alt | default: product.title }}"
loading="lazy"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
class="product-card__image"
>
{# Secondary image on hover #}
{%- if product.images.size > 1 and show_secondary_image -%}
<img
src="{{ product.images[1] | image_url: width: 600 }}"
alt=""
loading="lazy"
class="product-card__image product-card__image--secondary"
>
{%- endif -%}
{%- else -%}
<div class="product-card__placeholder">
{{ 'product-1' | placeholder_svg_tag }}
</div>
{%- endif -%}
{# Badges #}
{% render 'product-card-badges', product: product %}
</div>
{# Product info #}
<div class="product-card__info">
{%- if show_vendor and product.vendor -%}
<p class="product-card__vendor">{{ product.vendor }}</p>
{%- endif -%}
<h3 class="product-card__title">{{ product.title }}</h3>
{# Price #}
{% render 'product-price', product: product %}
{# Rating #}
{%- if show_rating -%}
{% render 'product-rating', product: product %}
{%- endif -%}
{# Color swatches preview #}
{%- if show_swatches -%}
{% render 'product-card-swatches', product: product %}
{%- endif -%}
</div>
</a>
{# Quick add button #}
{%- if show_quick_add -%}
{% render 'product-card-quick-add', product: product %}
{%- endif -%}
</div>

Product Card CSS

.product-card {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.product-card__link {
display: flex;
flex-direction: column;
height: 100%;
color: inherit;
text-decoration: none;
}
/* Image container with aspect ratio */
.product-card__media {
position: relative;
overflow: hidden;
background: var(--color-background-secondary);
}
/* Aspect ratio options */
.product-card__media[style*='--ratio: portrait'] {
aspect-ratio: 3 / 4;
}
.product-card__media[style*='--ratio: square'] {
aspect-ratio: 1 / 1;
}
.product-card__media[style*='--ratio: landscape'] {
aspect-ratio: 4 / 3;
}
.product-card__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
/* Secondary image (hover) */
.product-card__image--secondary {
opacity: 0;
}
.product-card:hover .product-card__image--secondary {
opacity: 1;
}
.product-card:hover .product-card__image:not(.product-card__image--secondary) {
opacity: 0;
}
/* Placeholder */
.product-card__placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--spacing-xl);
}
.product-card__placeholder svg {
width: 100%;
height: auto;
opacity: 0.5;
}
/* Product info */
.product-card__info {
display: flex;
flex-direction: column;
flex: 1;
padding: var(--spacing-sm) 0;
}
.product-card__vendor {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-light);
margin: 0 0 var(--spacing-xs);
}
.product-card__title {
font-size: 0.9375rem;
font-weight: 500;
margin: 0 0 var(--spacing-xs);
line-height: 1.3;
}
/* Truncate long titles */
.product-card__title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

Product Badges

{# snippets/product-card-badges.liquid #}
{%- liquid
assign on_sale = false
assign sold_out = false
if product.compare_at_price > product.price
assign on_sale = true
endif
unless product.available
assign sold_out = true
endunless
-%}
<div class="product-card__badges">
{%- if sold_out -%}
<span class="badge badge--sold-out">Sold out</span>
{%- elsif on_sale -%}
<span class="badge badge--sale">Sale</span>
{%- endif -%}
{%- for tag in product.tags -%}
{%- if tag == 'new' -%}
<span class="badge badge--new">New</span>
{%- endif -%}
{%- if tag == 'bestseller' -%}
<span class="badge badge--bestseller">Bestseller</span>
{%- endif -%}
{%- endfor -%}
</div>
.product-card__badges {
position: absolute;
top: var(--spacing-sm);
left: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
z-index: 1;
}
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1;
}
.badge--sale {
background: var(--color-sale);
color: white;
}
.badge--sold-out {
background: var(--color-text);
color: var(--color-background);
}
.badge--new {
background: var(--color-primary);
color: white;
}
.badge--bestseller {
background: #f59e0b;
color: white;
}

Product Price Display

{# snippets/product-price.liquid #}
{%- liquid
assign compare_at_price = product.compare_at_price
assign price = product.price
assign price_varies = product.price_varies
-%}
<div class="product-price {% if compare_at_price > price %}product-price--on-sale{% endif %}">
{%- if price_varies -%}
<span class="product-price__from">From</span>
{%- endif -%}
<span class="product-price__regular">
{{ price | money }}
</span>
{%- if compare_at_price > price -%}
<span class="product-price__compare">
<span class="visually-hidden">Regular price</span>
{{ compare_at_price | money }}
</span>
{%- assign savings = compare_at_price | minus: price -%}
{%- assign savings_percent = savings | times: 100 | divided_by: compare_at_price -%}
<span class="product-price__savings">
Save {{ savings_percent }}%
</span>
{%- endif -%}
</div>
.product-price {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-xs);
font-size: 0.9375rem;
}
.product-price__from {
font-size: 0.8125rem;
color: var(--color-text-light);
}
.product-price__regular {
font-weight: 600;
}
.product-price--on-sale .product-price__regular {
color: var(--color-sale);
}
.product-price__compare {
color: var(--color-text-light);
text-decoration: line-through;
}
.product-price__savings {
font-size: 0.75rem;
color: var(--color-sale);
font-weight: 600;
}

Quick Add Button

{# snippets/product-card-quick-add.liquid #}
{%- if product.available -%}
{%- if product.variants.size == 1 -%}
{# Single variant: Direct add to cart #}
<form action="/cart/add" method="post" class="product-card__quick-add">
<input type="hidden" name="id" value="{{ product.variants.first.id }}">
<button type="submit" class="product-card__quick-add-button">
Add to Cart
</button>
</form>
{%- else -%}
{# Multiple variants: Link to product page #}
<a href="{{ product.url }}" class="product-card__quick-add-button">
Select Options
</a>
{%- endif -%}
{%- else -%}
<button class="product-card__quick-add-button" disabled>
Sold Out
</button>
{%- endif -%}
.product-card__quick-add {
margin-top: auto;
}
.product-card__quick-add-button {
display: block;
width: 100%;
padding: var(--spacing-sm);
font-size: 0.875rem;
font-weight: 600;
text-align: center;
text-decoration: none;
color: var(--color-button-text);
background: var(--color-button);
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.product-card__quick-add-button:hover {
opacity: 0.9;
}
.product-card__quick-add-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Show on hover only (optional) */
.product-card__quick-add {
opacity: 0;
transform: translateY(10px);
transition:
opacity 0.2s,
transform 0.2s;
}
.product-card:hover .product-card__quick-add {
opacity: 1;
transform: translateY(0);
}

Color Swatches Preview

{# snippets/product-card-swatches.liquid #}
{%- assign color_option = product.options_by_name['Color'] -%}
{%- if color_option -%}
<div class="product-card__swatches">
{%- for value in color_option.values limit: 5 -%}
{%- assign color_handle = value | handleize -%}
<span
class="product-card__swatch"
style="background-color: {{ value | downcase }};"
title="{{ value }}"
></span>
{%- endfor -%}
{%- if color_option.values.size > 5 -%}
<span class="product-card__swatch-more">
+{{ color_option.values.size | minus: 5 }}
</span>
{%- endif -%}
</div>
{%- endif -%}
.product-card__swatches {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: var(--spacing-xs);
}
.product-card__swatch {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--color-border);
}
.product-card__swatch-more {
font-size: 0.75rem;
color: var(--color-text-light);
line-height: 16px;
}

Complete Product Grid Section

{# sections/collection-product-grid.liquid #}
{%- liquid
assign products_per_page = section.settings.products_per_page
assign columns = section.settings.columns_desktop
-%}
{%- paginate collection.products by products_per_page -%}
<div class="collection-product-grid">
{%- if collection.products.size > 0 -%}
<ul
class="product-grid"
style="
--columns-desktop: {{ columns }};
--columns-tablet: {{ section.settings.columns_tablet }};
--columns-mobile: {{ section.settings.columns_mobile }};
"
>
{%- for product in collection.products -%}
<li class="product-grid__item">
{%- render 'product-card',
product: product,
show_vendor: section.settings.show_vendor,
show_rating: section.settings.show_rating,
show_quick_add: section.settings.show_quick_add,
show_secondary_image: section.settings.show_secondary_image,
image_ratio: section.settings.image_ratio
-%}
</li>
{%- endfor -%}
</ul>
{%- if paginate.pages > 1 -%}
{%- render 'pagination', paginate: paginate -%}
{%- endif -%}
{%- else -%}
{%- render 'collection-empty' -%}
{%- endif -%}
</div>
{%- endpaginate -%}
{% schema %}
{
"name": "Product Grid",
"settings": [
{
"type": "header",
"content": "Grid layout"
},
{
"type": "range",
"id": "products_per_page",
"label": "Products per page",
"min": 8,
"max": 48,
"step": 4,
"default": 24
},
{
"type": "range",
"id": "columns_desktop",
"label": "Desktop columns",
"min": 2,
"max": 5,
"step": 1,
"default": 4
},
{
"type": "range",
"id": "columns_tablet",
"label": "Tablet columns",
"min": 2,
"max": 4,
"step": 1,
"default": 3
},
{
"type": "range",
"id": "columns_mobile",
"label": "Mobile columns",
"min": 1,
"max": 2,
"step": 1,
"default": 2
},
{
"type": "header",
"content": "Product cards"
},
{
"type": "select",
"id": "image_ratio",
"label": "Image ratio",
"options": [
{ "value": "portrait", "label": "Portrait (3:4)" },
{ "value": "square", "label": "Square (1:1)" },
{ "value": "landscape", "label": "Landscape (4:3)" }
],
"default": "portrait"
},
{
"type": "checkbox",
"id": "show_secondary_image",
"label": "Show second image on hover",
"default": true
},
{
"type": "checkbox",
"id": "show_vendor",
"label": "Show vendor",
"default": false
},
{
"type": "checkbox",
"id": "show_rating",
"label": "Show rating",
"default": false
},
{
"type": "checkbox",
"id": "show_quick_add",
"label": "Show quick add button",
"default": true
}
]
}
{% endschema %}

Practice Exercise

Build a product card that includes:

  1. Image with secondary image on hover
  2. Sale badge when on sale
  3. Sold out badge when unavailable
  4. Vendor name
  5. Product title (truncated to 2 lines)
  6. Price with compare-at price
  7. Quick add button

Test with products in different states (on sale, sold out, multiple variants).

Try it live: Prototype your product card loops and badge logic in our Liquid Playground—includes products with sale prices, variants, and tags to test all scenarios.

Key Takeaways

  1. Use paginate tag to handle product pagination
  2. Create reusable snippets for product cards
  3. Use srcset for responsive images
  4. Show relevant badges (sale, sold out, new)
  5. Display price correctly with compare-at price
  6. Add quick-add for single-variant products
  7. Show secondary image on hover for browsing
  8. Make cards flexible with settings for display options

🛠 Speed Up Schema Creation: Use our Schema Builder to visually create section schemas with settings for columns, badges, and display options—all with live JSON preview.

What’s Next?

With the product grid built, the next lesson covers Sorting UI and Wiring to Shopify for letting customers organize products.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...