Common Patterns
Practical patterns for product cards, responsive grids, conditional badges, and more.
These patterns appear frequently when building themes with Quill. Each one addresses a practical problem that most storefronts need to solve. Use them as starting points and modify the details to match your specific design requirements.
Product card
The product card is the most common snippet in any theme. It shows the image, title, price, and sale or sold-out badges. Put it in a snippet file so you can use it in any section:
{% comment %} snippets/product-card.liquid {% endcomment %}
<div class="product-card">
<a href="{{ product.url }}" class="product-card__link">
{% if product.featured_image %}
{{ product.featured_image | image_tag: class: 'product-card__image' }}
{% else %}
<img src="{{ 'placeholder.png' | asset_url }}" alt="{{ product.title }}" />
{% endif %}
{% if product.compare_at_price > product.price %}
<span class="badge badge--sale">Sale</span>
{% endif %}
{% unless product.available %}
<span class="badge badge--sold-out">Sold Out</span>
{% endunless %}
</a>
<div class="product-card__info">
<h3 class="product-card__title">
<a href="{{ product.url }}">{{ product.title }}</a>
</h3>
<p class="product-card__vendor">{{ product.vendor }}</p>
{% if product.compare_at_price > product.price %}
<span class="product-card__price product-card__price--sale">
{{ product.price | money }}
</span>
<span class="product-card__price product-card__price--compare">
{{ product.compare_at_price | money }}
</span>
{% else %}
<span class="product-card__price">{{ product.price | money }}</span>
{% endif %}
</div>
</div>Include it from any section or template:
{% render 'product-card' with product %}Responsive product grid
This grid adapts to the screen size. It uses CSS Grid and reads the column count from a section setting. On small screens it falls back to two columns, then one:
<div class="product-grid columns-{{ section.settings.columns }}">
{% for product in collection.products %}
{% render 'product-card' with product %}
{% endfor %}
</div>.product-grid {
display: grid;
gap: 1.5rem;
}
.product-grid.columns-2 { grid-template-columns: repeat(2, 1fr); }
.product-grid.columns-3 { grid-template-columns: repeat(3, 1fr); }
.product-grid.columns-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.product-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.product-grid { grid-template-columns: 1fr; }
}Conditional badges
Tags are a simple way to control what badges show up on a product card. You can also use dates to mark new arrivals:
{% if product.tags contains 'new' %}
<span class="badge badge--new">New</span>
{% endif %}
{% if product.tags contains 'bestseller' %}
<span class="badge badge--hot">Best Seller</span>
{% endif %}
{% assign days_old = product.published_at | date: '%s' | minus: 'now' | date: '%s' | abs | divided_by: 86400 %}
{% if days_old < 14 %}
<span class="badge badge--new">Just Added</span>
{% endif %}Sale percentage
When a product has a compare-at price, you can show the saving as a percentage. This is a quick bit of maths:
{% if product.compare_at_price > product.price %}
{% assign saving = product.compare_at_price | minus: product.price %}
{% assign percent = saving | times: 100 | divided_by: product.compare_at_price %}
<span class="badge badge--sale">{{ percent }}% off</span>
{% endif %}Colour swatches
Visual swatches provide a better experience than a standard dropdown for colour selection. This pattern iterates over the product variants and generates a coloured button for each available option:
{% if product.options contains 'Colour' %}
<div class="swatches">
{% for variant in product.variants %}
{% assign colour = variant.option1 | downcase | replace: ' ', '-' %}
<button
class="swatch"
style="background-color: {{ colour }}"
data-variant-id="{{ variant.id }}"
title="{{ variant.option1 }}"
{% unless variant.available %}disabled{% endunless %}
></button>
{% endfor %}
</div>
{% endif %}Breadcrumbs
Breadcrumbs help the visitor find their way back. Build them from the page context. Each level of the path gets a link:
<nav class="breadcrumbs">
<a href="/">Home</a>
{% if collection %}
<span>/</span>
<a href="{{ collection.url }}">{{ collection.title }}</a>
{% endif %}
{% if product %}
<span>/</span>
<span>{{ product.title }}</span>
{% endif %}
{% if page %}
<span>/</span>
<span>{{ page.title }}</span>
{% endif %}
</nav>Empty states
When there is no data to show, tell the visitor what to do next. A blank page with no message is confusing. Always add a fallback:
{% if collection.products.size == 0 %}
<div class="empty-state">
<p>No products found in this collection.</p>
<a href="/collections/all">Browse all products</a>
</div>
{% endif %}{% if cart.item_count == 0 %}
<div class="empty-state">
<h2>Your cart is empty</h2>
<a href="/collections/all" class="btn">Start shopping</a>
</div>
{% endif %}Social sharing links
Let visitors share a product on social media. Build the URLs from the store URL and product handle:
{% capture share_url %}{{ store.url }}{{ product.url }}{% endcapture %}
{% assign share_title = product.title | url_encode %}
<div class="share-buttons">
<a href="https://twitter.com/intent/tweet?url={{ share_url }}&text={{ share_title }}" target="_blank">
Share on X
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ share_url }}" target="_blank">
Share on Facebook
</a>
</div>