We build custom Shopify features from $99. Every build has a 30-day guarantee.

Build My Feature →

Shopify Horizon: Show a Sale Percentage Badge Without an App

Shopify Field Notes ··Johnny Chen Founder & Shopify Developer at CheckoutWorks
Cover titled Show How Much Shoppers Save, with a magnifying glass over a green Shopify Horizon product card showing a $29.00 price and enlarging a 25% OFF badge.

Shopify Horizon's product cards show a fixed "Sale" badge, and no theme setting turns it into a discount such as "25% off". You can add that with one new snippet, three small edits to the product card block, and a theme setting that switches the badge between Sale text, a discount percentage, and hidden. On a product with several variants, the badge shows the largest discount among the variants that are in stock.

We built and tested this on a Horizon 4.2.0 demo store. It covers product cards, such as those on collection pages, and leaves the product page unchanged.

Shopify Horizon product card for a demo product with a Sale badge, a crossed-out $39.00 price, and a $29.00 price.

Default: the badge says Sale.

The same Horizon product card with the badge changed to 25% off.

With the new setting: the badge shows the discount.

Why the default badge cannot show a percentage

Horizon shows the Sale badge when a product's compare-at price is higher than its price, and the label is fixed text. The Badges group in Theme settings changes the badge's position, colors, corner radius, and font. It has no option that adds a discount to the wording. You can change the word in the theme's language file, but the same word then appears on every product.

Which variant the badge follows

The default check uses two product-level values. Shopify's Liquid documentation defines product.price as the lowest price of any variant and product.compare_at_price as the lowest compare-at price of any variant. The two minimums can come from different variants. On our Bug Fix service product, the variants look like this:

Shopify admin variant list for a product with three variants, one without a compare-at price and two with different compare-at prices.

Two variants have a compare-at price and the discounts differ. Shopify shows 0.00 for a variant with no compare-at price.

For this product, Shopify returns a product.price of $49, from 1 Fix, and a product.compare_at_price of $147, from 3 Fixes. The variant with no compare-at price does not count toward the second value. Subtracting one from the other gives a discount of about 67%. The largest real discount is on 5 Fixes, from $245 to $179, which is 26.9%.

The price on the card is a third source. Horizon's price snippet shows the first available variant, which here is 1 Fix at $49 with no discount.

The badge in this article uses the largest discount among available variants and drops the decimals, so a 26.9% discount shows as 26%. It skips sold-out variants, because a discount on a variant a shopper cannot buy would overstate the offer. Because the badge can be larger than the discount on the variant shown in the price, a product with mixed discounts reads more accurately as "Up to 26% off". The text is one line in the language file, covered below.

If you prefer the badge to match the card's price, calculate the discount from product.selected_or_first_available_variant instead. A card whose first variant has no discount then shows no badge. We tested the largest-discount version only.

Add the badge to your theme

Work on an unpublished duplicate of your theme. The cw- prefix marks the snippet as custom, and any name works if both places that use it match.

Create the snippet

In Edit code, create snippets/cw-sale-badge.liquid and paste the code below. It reads the new theme setting. For Sale text it outputs the theme's own Sale label, for Discount percentage it outputs the largest discount, and for Hidden it outputs nothing.

{%- liquid
  assign style = settings.cw_badge_sale_style | default: 'text'
  assign on_sale = false
  if product.compare_at_price > product.price
    assign on_sale = true
  endif

  if style == 'percent' and on_sale and product.variants_count <= 250
    assign max_percent = 0
…
Show full codeCollapse code
{%- liquid
  assign style = settings.cw_badge_sale_style | default: 'text'
  assign on_sale = false
  if product.compare_at_price > product.price
    assign on_sale = true
  endif

  if style == 'percent' and on_sale and product.variants_count <= 250
    assign max_percent = 0

    for variant in product.variants
      if variant.available and variant.compare_at_price > variant.price
        assign percent = variant.compare_at_price | minus: variant.price | times: 100 | divided_by: variant.compare_at_price
        if percent > max_percent
          assign max_percent = percent
        endif
      endif
    endfor

    if max_percent > 0
      echo 'content.product_badge_sale_percent' | t: percent: max_percent
    endif
  elsif style != 'hidden' and on_sale
    echo 'content.product_badge_sale' | t
  endif
-%}

Liquid returns at most 250 variants for a product, so the snippet shows the plain Sale text for products with more than that. Dividing whole numbers drops the decimals, which is why the discount rounds down.

Edit the product card block

Open blocks/_product-card-gallery.liquid and search for product.compare_at_price. It appears three times, in the opening if, in the badge's CSS class, and in the badge text. Replace each one as follows.

Edit 1 of 3. Replace the opening {%- if product.available == false or product.compare_at_price > product.price -%} with:

{%- capture sale_badge -%}
  {%- render 'cw-sale-badge', product: product -%}
{%- endcapture -%}
{%- assign sale_badge = sale_badge | strip -%}
{%- if product.available == false or sale_badge != blank -%}

Edit 2 of 3. In the CSS class of the badge, replace {% elsif product.compare_at_price > product.price %} so the line reads:

{% if product.available == false %} color-custom-badge-sold-out{% elsif sale_badge != blank %} color-custom-badge-sale{% endif %}

Edit 3 of 3. In the badge text, replace the elsif branch and the {{ 'content.product_badge_sale' | t }} line under it with:

{%- elsif sale_badge != blank -%}
  {{ sale_badge }}

The Sold out badge keeps its own branch and stays unchanged.

Add the text

In locales/en.default.json, add the new line next to the existing Sale text:

"product_badge_sold_out": "Sold out",
"product_badge_sale": "Sale",
"product_badge_sale_percent": "{{ percent }}% off",

To show "Up to 26% off", change the new line to "Up to {{ percent }}% off". If your store uses other languages, add the same key to each of those language files with a translation. We tested English only.

Add the theme setting

In config/settings_schema.json, find the group named t:names.badges. Add this setting right after the badge_position setting:

{
  "type": "select",
  "id": "cw_badge_sale_style",
  "label": "Sale badge",
  "default": "text",
  "options": [
    { "value": "text", "label": "Sale text" },
    { "value": "percent", "label": "Discount percentage" },
    { "value": "hidden", "label": "Hidden" }
  ]
},

Keep the comma after the closing brace, because another setting follows it. The default is text, so the theme looks the same until you choose another option.

Switch it on and check the cards

In the theme editor, open Theme settings > Badges and choose an option under Sale badge.

Shopify theme settings showing the Sale badge dropdown with the options Sale text, Discount percentage, and Hidden.

The new setting sits in the Badges group, below Position on cards.

  • Sale text keeps Horizon's default wording.
  • Discount percentage shows the largest discount, such as 25% off.
  • Hidden removes the sale badge. The Sold out badge still appears.

The badge keeps your other Badges settings, so the text transform in Badges decides whether it reads "25% off" or "25% OFF". Our demo product has one discounted variant, $39 down to $29, which is 25.6%, and its card shows 25%. On the Bug Fix product, the card shows the largest discount:

Horizon product card for the Bug Fix product showing a 26% off badge.

The badge shows 26%, the discount on 5 Fixes. The $49.00 price belongs to the first variant, 1 Fix, which has no discount.

Check a collection page with a product that has one discounted variant, one with mixed discounts, and one with no compare-at prices. The last one should show no badge.

Limits to know

  • Product cards only. The product page price and any badge there stay unchanged.
  • Compare-at prices drive it. A variant counts only when its compare-at price is higher than its price.
  • Sold-out variants are skipped. By the code, a product whose only discounted variants are sold out shows no sale badge. We have not tested this case in a store.
  • Large products fall back to Sale. Products with more than 250 variants show the plain Sale text.
  • English wording only. Other languages need their own translation of the new line.

If you want a badge on the variant buttons of the product page instead, see our guide to Horizon variant button badges.

After a theme update

The snippet is a new file, so an update leaves it alone. Three upstream files carry the integration, and each one is worth a look after an update:

  • blocks/_product-card-gallery.liquid holds the three edits. If an update replaces the file, the badge goes back to Sale until you reapply them.
  • config/settings_schema.json holds the setting. If an update drops it, the snippet falls back to the Sale text.
  • locales/en.default.json holds the new line. If an update replaces the file, add the line back.

Add these three files to your list of customizations before the next update. Our Horizon update workflow includes a checklist for that.

Get Shopify help _

Want a sale percentage badge?

We can add it to your theme.

$99 USD

ONE SALE BADGE ·30-DAY GUARANTEE

Add my sale percentage badge