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:
- Image with secondary image on hover
- Sale badge when on sale
- Sold out badge when unavailable
- Vendor name
- Product title (truncated to 2 lines)
- Price with compare-at price
- 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
- Use
paginatetag to handle product pagination - Create reusable snippets for product cards
- Use srcset for responsive images
- Show relevant badges (sale, sold out, new)
- Display price correctly with compare-at price
- Add quick-add for single-variant products
- Show secondary image on hover for browsing
- 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...