Shopify Product Badge — Tags and Metafields Liquid
Display a product badge (New, Sale, Sold Out, Made in ___) based on product tags, metafields, or inventory status — one Liquid snippet, consistent across collection pages and product pages.
This Shopify product badge Liquid snippet displays New, Sale, Sold Out, or custom product badges from tags, metafields, and inventory status.
Use this when product cards need visual signals — "New Arrival," "On Sale," "Sold Out," or a custom badge like "Made in France." One Liquid snippet renders the right badge based on product tags (simplest), metafields (editor-friendly), or inventory status (automatic). Works on collection grids, product pages, and featured-product sections without duplicating logic.
Shopify product badge — The Code
This Shopify product badge snippet renders a product badge from tags, metafields, or a combination of both.
We’ll build this in 5 progressive steps. Each step adds exactly one new concept — static markup, tag-based conditions, metafield overrides, inventory checks with priority ordering, and the full production snippet with configurable parameters. Every step is a complete, working checkpoint. No prior Liquid snippet experience assumed.
Step 1: Static badge HTML on the product card. Before any logic, establish the HTML structure and CSS classes. The badge sits inside the .product-card__image wrapper as an absolutely-positioned <span> — this lets you overlay it on the product image. Key CSS classes: product-badge (shared base styles — positioning, padding, font), product-badge--sale (modifier — background color, maybe a red tint). At this point every product shows “Sale” regardless of whether it’s discounted. This proves the markup works before we introduce conditions.
{%- comment -%}Shopify product badge - Liquid snippet by Mosharaf Hossain{%- endcomment -%}
{% comment %}Step 1 — Static badge, no conditions{% endcomment %}
<div class="product-card">
<div class="product-card__image">
<img
src="{{ product.featured_image | image_url }}"
alt="{{ product.title | escape }}"
loading="lazy"
>
<span class="product-badge product-badge--sale">
Sale
</span>
</div>
<h3 class="product-card__title">{{ product.title }}</h3>
<span class="product-card__price">
{{ product.price | money }}
</span>
</div>
Step 2: Condition-based badge using product tags. Tags are the simplest signal to trigger a badge. The Liquid expression product.tags contains 'new' checks if the tag exists anywhere in the product’s tag list — no metafields to configure, no app dependencies. The {% elsif %} chain ensures only one badge appears per product (the first matching tag wins). Tag names are case-sensitive — contains 'New' won’t match a tag named 'new'. This is the fastest path to a working badge system: tag products in the Shopify admin and the badge appears automatically.
{%- comment -%}Shopify product badge{%- endcomment -%}
{% comment %}Step 2 — Tag-driven badges{% endcomment %}
<div class="product-card__image">
<img
src="{{ product.featured_image | image_url }}"
alt="{{ product.title | escape }}"
loading="lazy"
>
{% if product.tags contains 'new' %}
<span class="product-badge product-badge--new">New</span>
{% elsif product.tags contains 'sale' %}
<span class="product-badge product-badge--sale">Sale</span>
{% elsif product.tags contains 'clearance' %}
<span class="product-badge product-badge--clearance">
Clearance
</span>
{% endif %}
</div>
Step 3: Add metafield-based badge text. Tags are good triggers, but hardcoded badge text (“Sale” every time) limits your content team. Introduce product.metafields.custom.badge_text — a single-line text metafield that the editor fills in for each product. The default: 'New' filter provides the fallback when the metafield is blank — the tag triggers the badge, the metafield supplies the display text. The custom-badge tag is a special trigger: products tagged with it AND with a non-blank badge_text metafield get a fully custom badge like “Made in France” or “Editor’s Pick.” No Liquid changes needed — the editor changes text in the Shopify admin.
{%- comment -%}Shopify product badge{%- endcomment -%}
{% comment %}Step 3 — Tags trigger, metafield supplies text{% endcomment %}
<div class="product-card__image">
<img
src="{{ product.featured_image | image_url }}"
alt="{{ product.title | escape }}"
loading="lazy"
>
{% if product.tags contains 'new' %}
<span class="product-badge product-badge--new">
{{ product.metafields.custom.badge_text | default: 'New' }}
</span>
{% elsif product.tags contains 'custom-badge' %}
{% if product.metafields.custom.badge_text != blank %}
<span class="product-badge product-badge--custom">
{{ product.metafields.custom.badge_text }}
</span>
{% endif %}
{% endif %}
</div>
Step 4: Add inventory-based badges with priority ordering. Some badges trump others: a sold-out product shouldn’t display a “New” badge. The priority hierarchy is Sold Out > Sale > New > Custom. product.available (boolean) is the fastest sold-out check — it returns false when inventory is 0 and “Continue selling when out of stock” is disabled. The {% assign %} pattern creates a single active_badge and badge_label variable pair that the final line renders — one output, one {% if %} guard. No duplicate HTML, no CSS class collision. The selected_or_first_available_variant is used implicitly via product.available — you don’t need to access variant objects for the basic sold-out check.
{%- comment -%}Shopify product badge{%- endcomment -%}
{% comment %}Step 4 — Inventory + priority order{% endcomment %}
{% assign active_badge = '' %}
{% assign badge_label = '' %}
{% unless product.available %}
{% assign active_badge = 'sold-out' %}
{% assign badge_label = 'Sold Out' %}
{% elsif product.tags contains 'sale' %}
{% assign active_badge = 'sale' %}
{% assign badge_label = 'Sale' %}
{% elsif product.tags contains 'new' %}
{% assign active_badge = 'new' %}
{% assign badge_label = product.metafields.custom.badge_text | default: 'New' %}
{% endunless %}
{% if active_badge != '' %}
<span class="product-badge product-badge--{{ active_badge }}">
{{ badge_label }}
</span>
{% endif %}
Step 5: Full production snippet. All concepts combined into snippets/product-badge.liquid with production refinements: 1) A position parameter (top-left, top-right, bottom-left, bottom-right) controls where the badge sits on the product card — defaults to top-left if omitted. 2) A badge_class parameter passes extra CSS classes for per-instance overrides — theme-specific tweaks without touching the snippet. 3) Priority order: Sold Out trumps everything (you can’t buy it), Sale trumps New (discount urgency beats novelty), New trumps Custom (standardized before bespoke). 4) All logic runs inside a {% liquid %} block for cleaner multi-line variable assignment. 5) The {% render %} call is one line — drop it into any template where the product card lives.
{%- comment -%}Shopify product badge{%- endcomment -%}
{# snippets/product-badge.liquid — production #}
{#
Usage:
{% render 'product-badge', product: product %}
{% render 'product-badge', product: product,
position: 'top-right' %}
#}
{% liquid
assign active_badge = ''
assign badge_label = ''
if product.available == false
assign active_badge = 'sold-out'
assign badge_label = 'Sold Out'
elsif product.tags contains 'sale'
assign active_badge = 'sale'
assign badge_label = 'Sale'
elsif product.tags contains 'new'
assign active_badge = 'new'
assign badge_label = product.metafields.custom.badge_text | default: 'New'
elsif product.tags contains 'custom-badge' and product.metafields.custom.badge_text != blank
assign active_badge = 'custom'
assign badge_label = product.metafields.custom.badge_text
endif
%}
{% if active_badge != '' %}
<span class="product-badge product-badge--{{ active_badge }} {{ position | default: 'top-left' }} {{ badge_class }}">
{{ badge_label }}
</span>
{% endif %}
Where to put the code
Save the Step 5 code as snippets/product-badge.liquid. In your collection template or product card section, replace any hardcoded badge markup with:
{% render 'product-badge', product: product %}
The snippet is a single file with no dependencies — no JavaScript, no theme settings, no app. It reads from three sources: product tags (set in Shopify admin), a product metafield (custom.badge_text), and the product’s availability status. All three sources are part of the standard product drop — no extra API calls.
How Shopify product badge Works — Why Each Layer Exists
Here is how this Shopify product badge works step by step: Every decision in this snippet prevents a real-world failure mode. Here's why each signal source was chosen, how the priority system avoids conflicts, and how the parameter API keeps the snippet reusable across templates.
Every decision in this snippet prevents a real-world failure mode. Here’s why each signal source was chosen, how the priority system avoids conflicts, and how the parameter API keeps the snippet reusable across templates.
Tag-based vs metafield-based triggers — when to use each
Tag-based triggers are the simplest badge signal because Shopify product tags are flat, admin-editable, and already loaded in the product drop. The product.tags Liquid variable is an array of strings — contains checks for exact string membership, not substring matching. A tag named "new-arrival" does NOT match contains 'new' — this is a common mistake. The tag "new" must be added verbatim. Advantages: no metafield setup, works on every Shopify plan, visible in the product list view. Disadvantages: tag names are global — adding "sale" to trigger a badge means that tag appears in your admin tag filter and any third-party app that reads tags will see it. Tags are public metadata, not badge configuration. For stores with clean tag taxonomies (tags only for organization), this is the fastest approach.
Metafield-based triggers decouple the badge signal from product organization. The product.metafields.custom.badge_text field stores editor-facing badge content — “New Arrival,” “Best Seller,” “Made in Italy.” The tag "custom-badge" acts as a gate: only products with this tag AND a filled-in metafield get a custom badge. This two-factor system prevents every product with an accidentally-filled metafield from showing a badge. Editors add the tag, fill the text, and the badge appears. The metafield stores only the display text — the badge color/style is determined by the CSS class, not the metafield value. This separation means editors can’t break the design by typing “hot pink” into the metafield.
The priority system — Sold Out > Sale > New > Custom
The priority system resolves conflicts when a product matches multiple badge conditions simultaneously. A product tagged both "sale" and "new" should show the sale badge — the more urgent signal. A sold-out product with a "new" tag should show “Sold Out” because the customer literally cannot purchase it. The hierarchy is implemented as an {% elsif %} chain in Step 4, upgraded to a {% liquid %} block with if / elsif clauses in Step 5. The order matters: 1) Check product.available == false first (Sold Out). 2) Then check tags contains 'sale' (Sale). 3) Then tags contains 'new' (New). 4) Finally tags contains 'custom-badge' with a non-blank metafield (Custom). If you need to change priority — e.g., “New” badges are more important than “Sale” badges for your brand — swap the elsif clauses. The rest of the snippet doesn’t change.
Inventory checks — product.available vs variant inventory_quantity
Inventory checks use product.available (a boolean) rather than product.selected_or_first_available_variant.inventory_quantity (an integer). Why? product.available returns false when: total inventory ≤ 0 and “Continue selling when out of stock” is unchecked. This matches the customer-facing behavior — if Shopify won’t let someone add the product to cart, the badge should say “Sold Out.” The integer check (inventory_quantity <= 0) alone doesn’t account for the “continue selling” setting — a product with 0 inventory but “continue selling” enabled is technically purchasable and shouldn’t show a Sold Out badge. For “Low Stock” badges (less than 5 units), use product.selected_or_first_available_variant.inventory_quantity directly — but be aware this returns the first variant’s quantity, not a combined total. For multi-variant products, consider tracking inventory_quantity manually or showing “Low Stock” at the product level only when all variants are low. The snippet doesn’t include low-stock logic by default to keep the production version focused; add it as a {% elsif %} branch before the tag checks.
Liquid conditional flow — single-assignment pattern to prevent double badges
Liquid conditional flow in this snippet follows a single-assignment pattern: set active_badge and badge_label to empty strings at the top, populate them in priority order via if/elsif, and render exactly one <span> at the end. This pattern avoids the “double badge” bug where a product matches two conditions and outputs two <span> elements stacked on top of each other. The {% if active_badge != '' %} guard at the bottom means products with no matching tags or conditions render nothing — no empty <span>, no stray whitespace, no broken layout. The {% liquid %} wrapper in Step 5 is Shopify’s whitespace-aware multi-line tag: it lets you write multiple if/assign statements without {% %} delimiters on every line, and it automatically strips whitespace between statements. This produces cleaner output — no blank lines in the rendered HTML from Liquid tag formatting.
Snippet parameter API — render variables, defaults, and scoping
The snippet parameter API uses Shopify’s {% render %} named parameters. When you call {% render 'product-badge', product: product, position: 'top-right' %}, Shopify creates scoped variables inside the snippet — product is the product object, position is the string 'top-right'. These variables don’t leak into the calling template, so you can name parameters whatever you want without collision. If a parameter is omitted (e.g., no position is passed), the snippet uses the | default: 'top-left' filter to provide a fallback. The badge_class parameter is optional — if omitted, it defaults to an empty string and produces no extra CSS class. The parameter API keeps the snippet generic: the same file works on collection pages (smaller cards, maybe bottom-right position), product pages (larger image, top-left), and featured-product sections (custom styling) — each instance passes different parameters. No copy-paste, no inline modifications.
Shopify product badge — Setup & Integration
To integrate this Shopify product badge into your project: One snippet file, one metafield definition, and a few CSS rules — your product cards show dynamic badges everywhere. Here's where everything lives and how to wire it up.
One snippet file, one metafield definition, and a few CSS rules — your product cards show dynamic badges everywhere. Here’s where everything lives and how to wire it up.
Create the badge_text metafield definition in Shopify admin
Create the metafield definition. Go to Settings → Custom data → Products in your Shopify admin. Click Add definition. Name it Badge Text, set the namespace/key to custom.badge_text, and choose type Single line text. This metafield holds the display text for custom badges — “Made in France,” “Best Seller,” “Limited Edition.” Leave it empty on most products; fill it in only on products you also tag with "custom-badge". The metafield is a one-time setup — once the definition exists, every product automatically gets a badge_text field in the admin metafields section.
Add trigger tags — new, sale, custom-badge — to products
Add tags to trigger badges. Open any product in Shopify admin, scroll to the Tags field, and add one of these trigger tags: new, sale, or custom-badge. Tag names are lowercase and hyphen-free — new not New, custom-badge not Custom Badge. After saving, preview the product on your storefront. If the snippet is already in place, the badge appears immediately. To test priority: tag a product with both sale and new — the Sale badge should appear (Sale trumps New). Tag a product with sale and set inventory to 0 with “Continue selling” unchecked — the Sold Out badge should appear (Sold Out trumps Sale).
Style badges with CSS — custom properties for site-wide theming
Style badges with CSS. Add these rules to your theme stylesheet. The base product-badge class handles positioning and shared properties. Each modifier class (--sale, --new, --sold-out, --custom) sets the background color. The position classes (top-left, top-right, etc.) override the absolute positioning. All colors use CSS custom properties for easy site-wide theming:
:root {n --badge-bg-sale: #e74c3c;n --badge-bg-new: #2ecc71;n --badge-bg-sold: #95a5a6;n --badge-bg-custom: #9b59b6;n --badge-color: #fff;n}nn.product-badge {n position: absolute;n z-index: 2;n padding: 4px 10px;n border-radius: 3px;n font-size: 0.75rem;n font-weight: 700;n line-height: 1.4;n color: var(--badge-color);n white-space: nowrap;n}nn.product-badge--sale { background: var(--badge-bg-sale); }n.product-badge--new { background: var(--badge-bg-new); }n.product-badge--sold-out { background: var(--badge-bg-sold); }n.product-badge--custom { background: var(--badge-bg-custom); }nn.top-left { top: 10px; left: 10px; }n.top-right { top: 10px; right: 10px; }n.bottom-left { bottom: 10px; left: 10px; }n.bottom-right { bottom: 10px; right: 10px; }
The product card’s image wrapper (.product-card__image) needs position: relative for the absolute-positioned badge to anchor correctly. If your theme uses a different class for the image container, adjust the CSS selector accordingly.
Call the snippet from collection and product templates
Call the snippet from collection and product templates. In sections/main-collection.liquid (or wherever your collection product loop lives), find the product card markup and insert the render call inside the image wrapper:
{% for product in collection.products %}n <div class="product-card">n <div class="product-card__image">n <img src="{{ product.featured_image | image_url }}" alt="{{ product.title | escape }}">n {% render 'product-badge', product: product %}n </div>n <h3>{{ product.title }}</h3>n <span>{{ product.price | money }}</span>n </div>n{% endfor %}
On the product page (sections/main-product.liquid), use the same call but with a different position if the image layout differs:
{% render 'product-badge', product: product, position: 'top-right' %}
The snippet is one line per product card — drop it in and the badge logic runs automatically based on tags, metafields, and inventory. No template-specific conditions, no duplicated code.
Making This Shopify product badge Your Own
Customise this Shopify product badge to match your specific needs: The badge snippet is a pattern, not a closed box. Change shapes, add animations, combine with sale price display, and A/B test badge styles — all without touching the core Liquid logic.
Custom badge shapes — diagonal ribbons and circular badges via CSS
Custom badge shapes. Replace the rounded-rectangle badge with a diagonal ribbon using CSS transforms:
.product-badge--new {n width: 120px;n padding: 6px 0;n text-align: center;n transform: rotate(45deg);n top: 18px;n right: -30px;n border-radius: 0;n}
This creates a diagonal corner ribbon effect. For a circular badge (like an app icon), use border-radius: 50% and width: 48px; height: 48px; with centered text via display: flex; align-items: center; justify-content: center;. The HTML stays the same — all shape variations are CSS-only. Use the badge_class parameter to pass a shape modifier class per template: {% render 'product-badge', product: product, badge_class: 'product-badge--ribbon' %}.
Animate badge appearance — fade-in scale and staggered collection cards
Animate badge appearance. Add a CSS keyframe animation that fades and scales the badge in when the product card enters the viewport. Since the badge is rendered server-side and appears immediately, use an animation that activates on page load:
.product-badge {n animation: badgeAppear 0.3s ease-out both;n}nn@keyframes badgeAppear {n from {n opacity: 0;n transform: scale(0.5);n }n to {n opacity: 1;n transform: scale(1);n }n}
For staggered animations on collection pages, add an animation-delay using Liquid’s forloop.index: style="animation-delay: {{ forloop.index | times: 0.05 }}s" on the badge’s <span> tag. Each badge pops in 50ms after the previous one — 20 products animate over 1 second. Add this as a snippet parameter (stagger: true) in the production version for opt-in staggered animation.
Combine badges with sale price display for stronger conversion
Combine badges with sale price display. The Sale badge and the sale price should reinforce each other. Show the regular price with a strikethrough and the sale price next to it — the badge and the price together create a stronger conversion signal than either alone:
{% if product.compare_at_price > product.price %}n <span class="product-card__price product-card__price--sale">n <s>{{ product.compare_at_price | money }}</s>n {{ product.price | money }}n </span>n{% else %}n <span class="product-card__price">n {{ product.price | money }}n </span>n{% endif %}
The badge and the price display are independent — the badge checks product.tags for the "sale" tag, the price checks product.compare_at_price for a price difference. A product can be on sale (price reduced) without a sale badge if you don’t add the tag. This separation gives editorial control — the marketing team decides which sale products get the badge treatment.
A/B test badge styles using a metafield for test variant assignment
A/B test badge styles. Use Shopify’s built-in A/B testing (or a third-party tool) to test badge variations — color, text, position — without code changes. Add a metafield for the test variant: custom.badge_variant (values: "control" or "variant"). In the snippet, add a conditional class:
{% assign test_class = product.metafields.custom.badge_variant %}n{% if test_class == 'variant' %}n {% assign badge_class = badge_class | append: ' product-badge--test-variant' %}n{% endif %}
In your CSS, define the variant styles. Track badge click-through rate (clicks to product page from collection) for each variant. The metafield approach lets you assign test groups per product without touching the snippet logic — assign "variant" to half the products, leave "control" on the other half, and split traffic evenly.
Tag collision risks:
1. Tags are global, not badge-specific. The tag "new" triggers a badge — but it also appears in your Shopify admin tag filter, in any tag-based collection automation rules, and in third-party apps that read product tags. If you use tags for internal organization (e.g., "new" means “newly added to inventory,” not “badge this product”), the badge snippet will fire unintentionally. Solution: use a namespaced tag prefix like "badge:new" and check with product.tags contains 'badge:new'. The colon character is valid in Shopify tags but won’t collide with organically-named tags.
2. Bulk tag edits trigger badge changes site-wide. If you bulk-edit 500 products and accidentally tag them all with "sale", every product card on your storefront shows the wrong badge. There’s no undo for the snippet output — it’s rendered server-side from live tag data. Mitigation: before bulk tag operations, preview the impact by temporarily removing the {% render %} call from your collection template, or disable the snippet’s sale branch locally, push, verify tags, then re-enable.
3. Tag removal removes the badge instantly. Removing the "new" tag from a product deletes its badge on the next page load — no cache delay, no grace period. For products transitioning from “New” to normal inventory, remove the tag when you’re ready for the badge to disappear. There’s no “stale badge” cleanup — what you see in admin tags is exactly what renders on the storefront.
Performance notes:
- Zero extra database queries. Product tags (
product.tags) load with the standard product drop — they’re an in-memory array. The metafieldcustomer.badge_textloads as part ofproduct.metafields.custom, which is also part of the product drop. Theproduct.availableboolean is a property of the product object, already populated. Three property reads, no queries. - Metafield caching is automatic at Shopify’s CDN edge. When you update a metafield in the admin, Shopify regenerates the product object cache. The snippet re-renders on the next page request. If 10,000 visitors load the collection page in the next hour, the badge HTML is served from cache for all of them — one Liquid render, 10,000 cached deliveries.
- The
{% liquid %}block is whitespace-optimized. Unlike multiple{% assign %}/{% if %}tags on separate lines, a{% liquid %}block strips whitespace between statements. A product with no matching badge renders zero characters — not even newlines — from the snippet. A collection of 48 products with 12 badged products adds only 12<span>elements to the DOM. The other 36 products contribute zero HTML from this snippet. - CSS-based badge styling means one network request, global coverage. Badge colors, positioning, animation, and shape variants live in your theme’s stylesheet — a single CSS file cached across the entire storefront. Changing the Sale badge from red to orange is one CSS edit, instantly live on every product card on every page. No snippet edits, no Liquid recompile, no server-side rendering cost for visual changes.
containsis an O(n) tag scan, but n is tiny. Shopify products typically have 3-15 tags. Thecontainscomparison scans the array linearly — worst case 15 string comparisons per product. On a 48-product collection page, that’s 720 comparisons total. Each comparison is two string pointer checks in Liquid’s C++ runtime. The cost is immeasurable compared to the image rendering and CSS layout work happening on the same page.

No comments yet. Be the first.