Snippets / WooCommerce

WooCommerce Sale Badge Percentage — Discount Display PHP

Display a "Sale" badge with the actual percentage discount on WooCommerce product cards — using product price comparison, simple math, and WooCommerce action hooks. No template overrides needed.

PHP utility Production-tested ~3 min setup Theme helper Beginner-friendly WP 6.0+ · WC 7.0+ Updated May 15, 2026
TL;DR

This WooCommerce sale badge percentage PHP snippet displays the actual percentage discount on WooCommerce product cards without template overrides.

Use this when you want product cards to show exactly how much a customer saves — "30% OFF" converts better than a generic "Sale" label. One hook, zero template overrides, works with simple and variable products.

01 — Building it step by step

WooCommerce sale badge percentage — The Code

This WooCommerce sale badge percentage snippet replaces the default Sale! badge with the actual percentage discount.

We’ll build this in 5 focused steps. Each step adds exactly one new concept — static output, percentage math, conditional styling, multi-hook reusability, and production polish with variable products. Every step is a complete, working checkpoint you can stop at and test. No prior WooCommerce hook experience assumed.

Step 1: A static “Sale” badge. The woocommerce_before_shop_loop_item_title action fires on every product card in the shop loop — right before the product image, above the title. Any HTML you echo here sits as an overlay on the product thumbnail. At this point the badge says “Sale” on every product regardless of whether it’s actually discounted — we’ll fix that in Step 2. For now, this proves the hook works and the badge appears where you expect.

Step 1 — Static badge.php
php
// WooCommerce sale badge percentage — production-tested PHP snippet by Mosharaf Hossain
<?php
/**
 * Step 1 — A static "Sale" badge on product loops.
 * No percentage calc, no conditions — just raw output
 * to prove the hook fires where you expect.
 */
add_action( 'woocommerce_before_shop_loop_item_title', function() {
    echo '<span class="sale-badge">Sale</span>';
} );

Step 2: Calculate the actual discount percentage. Every WooCommerce product object exposes get_regular_price() and get_sale_price() — no need to dig through post meta or loop through variations. The percentage formula is (regular - sale) / regular * 100. Cast to (float) for reliable arithmetic, and pass the result through round() to avoid “29.67% OFF” decimals. The $product->is_on_sale() guard ensures the badge only renders when a sale price exists — no more false “Sale” on full-price items.

Step 2 — Calculate percentage.php
php
// WooCommerce sale badge percentage
<?php
/**
 * Step 2 — Calculate the discount percentage.
 *
 * New: get_regular_price() and get_sale_price() return
 * raw numbers. Subtract, divide, round — no loop needed.
 * Only renders if the product is on sale (sale price exists).
 */
add_action( 'woocommerce_before_shop_loop_item_title', function() {
    global $product;
    // ACF field type: N/A — uses native WC product methods.

    if ( ! $product->is_on_sale() ) {
        return;
    }

    $regular = (float) $product->get_regular_price();
    $sale    = (float) $product->get_sale_price();

    if ( $regular <= 0 ) {
        return;
    }

    $pct = round( ( ( $regular - $sale ) / $regular ) * 100 );

    printf(
        '<span class="sale-badge">%d%% OFF</span>',
        $pct
    );
} );

Step 3: Color-coded tiers by discount depth. A 10% discount and a 70% clearance discount shouldn’t look the same. The badge checks the calculated percentage against tier thresholds: 50%+ gets the sale-badge--high class (red), 25-49% gets sale-badge--mid (amber), and everything below defaults to green. The CSS classes are added to the same <span> tag — your stylesheet defines the colors. The tier check is simple arithmetic, not a database query.

Step 3 — Tiered styling.php
php
// WooCommerce sale badge percentage
<?php
/**
 * Step 3 — Color-code badges by discount depth.
 *
 * New: High-discount items (50%+) get a red badge
 * via an extra CSS class. Mid-range (25-49%) gets amber.
 * Everything else (1-24%) keeps the default green.
 */
add_action( 'woocommerce_before_shop_loop_item_title', function() {
    global $product;

    if ( ! $product->is_on_sale() ) {
        return;
    }

    $regular = (float) $product->get_regular_price();
    $sale    = (float) $product->get_sale_price();

    if ( $regular <= 0 ) {
        return;
    }

    $pct = round( ( ( $regular - $sale ) / $regular ) * 100 );

    if ( $pct >= 50 ) {
        $tier = 'sale-badge--high';
    } elseif ( $pct >= 25 ) {
        $tier = 'sale-badge--mid';
    } else {
        $tier = '';
    }

    printf(
        '<span class="sale-badge %s">%d%% OFF</span>',
        esc_attr( $tier ), $pct
    );
} );

Step 4: Reuse the same function on single product pages. Customers on the product detail page deserve the same discount visibility. We extract the badge logic into a named function theme_sale_badge() and hook it to both woocommerce_before_shop_loop_item_title (product cards) and woocommerce_single_product_summary (single product page). The 5 priority on the single-product hook places it above the title but after the image. Same function, two hooks, zero code duplication.

Step 4 — Named function, two hooks.php
php
// WooCommerce sale badge percentage
<?php
/**
 * Step 4 — Same badge, two hooks, one reusable function.
 *
 * New: Extract the logic into a named function so both the
 * loop hook and the single-product hook share one source.
 * woocommerce_single_product_summary fires above the title.
 */
function theme_sale_badge() {
    global $product;

    if ( ! $product || ! $product->is_on_sale() ) {
        return;
    }

    $regular = (float) $product->get_regular_price();
    $sale    = (float) $product->get_sale_price();

    if ( $regular <= 0 ) {
        return;
    }

    $pct = round( ( ( $regular - $sale ) / $regular ) * 100 );

    if ( $pct >= 50 ) {
        $tier = 'sale-badge--high';
    } elseif ( $pct >= 25 ) {
        $tier = 'sale-badge--mid';
    } else {
        $tier = '';
    }

    printf(
        '<span class="sale-badge %s">%d%% OFF</span>',
        esc_attr( $tier ), $pct
    );
}
add_action( 'woocommerce_before_shop_loop_item_title', 'theme_sale_badge' );
add_action( 'woocommerce_single_product_summary', 'theme_sale_badge', 5 );

Step 5: Full production version. All concepts combined into one organized file with production refinements: 1) Variable products with price ranges — e.g., $20-$50 — use get_variation_regular_price('min') and get_variation_sale_price('min') to calculate the best-case discount and show “Up to 40% OFF”. 2) Two filter hooks — sb_text_format for simple products and sb_var_format for variable products — let you customize the badge text without touching this file. 3) The tier thresholds are a filterable array via sb_discount_tiers — add, remove, or reorder tiers with a single filter callback.

inc/woo-sale-badge.php
php
// WooCommerce sale badge percentage
<?php
/**
 * WooCommerce Sale Badge with Percentage — production version.
 *
 * Save as inc/woo-sale-badge.php and require from functions.php.
 * Filterable text format, CSS tiers, and variable product support.
 */
function theme_sale_badge() {
    global $product;
    if ( ! $product || ! $product->is_on_sale() ) {
        return;
    }

    if ( $product->is_type( 'variable' ) ) {
        $min = (float) $product->get_variation_regular_price( 'min' );
        $max = (float) $product->get_variation_sale_price( 'min' );
        $pct = ( $min > 0 )
            ? round( ( ( $min - $max ) / $min ) * 100 )
            : 0;
        $format = apply_filters( 'sb_var_format', 'Up to %d%% OFF' );
    } else {
        $regular = (float) $product->get_regular_price();
        $sale    = (float) $product->get_sale_price();
        if ( $regular <= 0 ) { return; }
        $pct    = round( ( ( $regular - $sale ) / $regular ) * 100 );
        $format = apply_filters( 'sb_text_format', '%d%% OFF' );
    }

    $tiers = apply_filters( 'sb_discount_tiers', [
        50 => 'sale-badge--high',
        25 => 'sale-badge--mid',
    ] );
    $class = '';
    foreach ( $tiers as $threshold => $cls ) {
        if ( $pct >= $threshold ) { $class = $cls; break; }
    }

    printf(
        '<span class="sale-badge %s">%s</span>',
        esc_attr( $class ),
        sprintf( $format, $pct )
    );
}
add_action( 'woocommerce_before_shop_loop_item_title', 'theme_sale_badge' );
add_action( 'woocommerce_single_product_summary', 'theme_sale_badge', 5 );

Where to put the code

Save Step 5 in inc/woo-sale-badge.php and require it from functions.php:

require_once get_template_directory() . '/inc/woo-sale-badge.php';

This is presentation logic — it describes how this site displays discounts. Keep it in the theme, not a plugin.

02 — How it works

How WooCommerce sale badge percentage Works — Why Each Layer Exists

Here is how this WooCommerce sale badge percentage works step by step: Every line in this snippet serves a purpose. Here's why each piece was chosen, how WooCommerce price functions differ between product types, and how the filter system keeps the badge extensible.

This WooCommerce sale badge percentage implementation works as follows.

Every line in this snippet serves a purpose. Here’s why each piece was chosen, how WooCommerce price functions differ between product types, and how the filter system keeps the badge extensible.

1

Price comparison and percentage formula — simple, reliable arithmetic

The percentage formula — ((regular - sale) / regular) * 100 — is deliberately simple. WooCommerce stores prices as floats, so $product->get_regular_price() and $product->get_sale_price() return numbers ready for arithmetic. Casting to (float) is important: in some edge cases (imported products, third-party plugins) these methods return strings. The $regular <= 0 guard prevents division by zero — a product with a zero regular price (data import error) would otherwise produce a PHP warning. The round() call with default precision produces whole numbers (“40% OFF” not “39.827586% OFF”), which is what customers expect to see on a badge.

2

WooCommerce price functions — simple vs variable product differences

Price functions differ between simple and variable products. For simple products: get_regular_price() returns the single price before the sale, and get_sale_price() returns the discounted price. One product, one pair of numbers, one percentage. For variable products: calling get_regular_price() on a variable product returns an empty string — variable products don’t have a single price, they have a range. Instead, use get_variation_regular_price('min') and get_variation_sale_price('min') to get the lowest-priced variation’s prices. get_variation_regular_price('max') and get_variation_sale_price('max') return the highest variation’s prices — useful if you want to show the discount range (“30%-50% OFF”) but that requires both numbers, which exceeds the badge format.

3

Hook placement — loop vs single product, priority control

Hook placement determines where the badge renders. woocommerce_before_shop_loop_item_title fires inside the product card <li> element, after the sale flash has rendered (if WooCommerce’s built-in one is enabled) but before the title and price. This positions the badge as an overlay on the product image — the most visible spot on a product card. On single product pages, woocommerce_single_product_summary fires inside the summary column — above the title at priority 5, or below the price at priority 15. The priority 5 places it first, making it the most prominent element after the image. Alternative hooks: woocommerce_before_shop_loop_item (before the <li> wrapper, harder to position), woocommerce_after_shop_loop_item_title (below the title and price, less visible).

4

CSS tier system — visual urgency mapped to discount depth

The tier-based CSS class system maps discount depth to visual urgency. The $tiers array is ordered from highest to lowest threshold — the loop breaks on the first match, so a 60% discount skips the 50% threshold check and gets the “high” class immediately. Each tier threshold is an array key, and the value is the CSS class to apply. This design means adding a new tier is a one-line array insertion — no new if branch. The apply_filters( 'sb_discount_tiers', $defaults ) call lets child themes or companion plugins add tiers like “exclusive” (70%+), rename classes, or remove tiers entirely without editing the core file.

5

Filter extensibility — format strings and tier thresholds

Two filter hooks make the badge extensible. apply_filters( 'sb_text_format', '%d%% OFF' ) controls the badge text for simple products — change it to “Save %d%” or “%d%% Discount” without touching the function. apply_filters( 'sb_var_format', 'Up to %d%% OFF' ) does the same for variable products. Both filters use %d as a placeholder for the integer percentage, formatted via sprintf() after the filter runs. This pattern — filter before sprintf — means the filter receives the raw format string, not the final HTML. Callers can change the text without worrying about escaping. The final output passes through printf() with esc_attr() on the CSS class.

03 — Setup & integration

WooCommerce sale badge percentage — Setup & Integration

To integrate this WooCommerce sale badge percentage into your project: One file, one require, and your product cards show dynamic discount percentages. Here's exactly where everything lives, how to test, and how to style it.

This WooCommerce sale badge percentage implementation works as follows.

One file, one require, and your product cards show dynamic discount percentages. Here’s exactly where everything lives, how to test with sale products, and how to style the badge.

wp-content/themes/your-theme/
│   ├── inc/
│   │   └── woo-sale-badge.php
    └── functions.php
1

Create the file and require it from functions.php

Create the file and require it. Copy the Step 5 code into inc/woo-sale-badge.php. In functions.php, add this line at the bottom:

require_once get_template_directory() . '/inc/woo-sale-badge.php';

No class_exists( 'WooCommerce' ) wrapper is needed. If WooCommerce isn’t active, the hooks never fire and the function sits silently in WordPress’s action registry.

2

Test with sale products at different discount levels

Test with sale products. Create a simple product with a regular price of $100 and a sale price of $70 — the badge should show “30% OFF”. Set a regular price of $50 and a sale price of $20 to test the mid-tier (60% — should get the amber/red class). For variable products, create two variations with different prices and set a sale price on each — the badge should show “Up to X% OFF” using the best-case (lowest regular price) variation. Test the edge case: a product on sale but with identical regular and sale prices (import error) — the badge should show “0% OFF”, which you may want to suppress via a $pct > 0 guard.

3

Style the badge with CSS variables for your theme

Style the badge with CSS variables. Add these rules to your theme stylesheet. The badge is an absolutely-positioned overlay on the product card — adjust top and left to match your card layout:

:root {n    --sb-bg: #2ecc71;n    --sb-high-bg: #e74c3c;n    --sb-mid-bg: #f39c12;n    --sb-color: #fff;n}nn.sale-badge {n    position: absolute;n    top: 10px;n    left: 10px;n    background: var(--sb-bg);n    color: var(--sb-color);n    padding: 4px 10px;n    border-radius: 3px;n    font-size: 0.8rem;n    font-weight: 700;n    z-index: 2;n}nn.sale-badge--mid  { background: var(--sb-mid-bg); }n.sale-badge--high { background: var(--sb-high-bg); }

For the single product page, the badge inherits the summary column’s positioning context. If you need absolute positioning there, ensure the .summary container has position: relative. CSS variables let you override badge colors site-wide from one spot without touching the PHP file.

04 — Making it your own

Making This WooCommerce sale badge percentage Your Own

Customise this WooCommerce sale badge percentage to match your specific needs: The badge is a pattern, not a closed box. Change the text, add it to related products, reposition it with CSS — all without touching the core function.

1

Custom badge text — "Deal", "Price Drop", or anything else

Custom badge text — “Deal” instead of “OFF”. Change the format strings via the filter hooks:

add_filter( 'sb_text_format', function() {n    return '%d%% Deal';n} );nnadd_filter( 'sb_var_format', function() {n    return 'Up to %d%% Deal';n} );

Or replace the text entirely — no percentage, just a label — by ignoring the %d placeholder: return 'Price Drop';. The sprintf() in the function still runs, but the extra %d argument is silently ignored by PHP.

2

Show the badge on related products, upsells, and cross-sells

Show the badge on related products. WooCommerce’s related products section uses the same product card template and fires the same woocommerce_before_shop_loop_item_title hook — so your badge already appears there. If your theme’s related products use a custom template that doesn’t fire standard hooks, add the badge manually:

// Inside your related-products template loop:nif ( function_exists( 'theme_sale_badge' ) ) {n    theme_sale_badge();n}

For upsells and cross-sells, the same approach works — call theme_sale_badge() wherever the product image renders inside the upsell/cross-sell loop.

3

Reposition the badge — top-right, bottom-left, or full-width ribbon

Reposition the badge with CSS. The default CSS positions the badge at the top-left of the product card. For different positions, adjust the absolute positioning:

/* Top-right corner */n.sale-badge {n    top: 10px;n    right: 10px;n    left: auto;n}nn/* Bottom-left, larger */n.sale-badge {n    top: auto;n    bottom: 10px;n    left: 10px;n    padding: 6px 14px;n    font-size: 0.9rem;n}nn/* Ribbon style — full width at top */n.sale-badge {n    top: 0;n    left: 0;n    right: 0;n    text-align: center;n    border-radius: 0;n}

No PHP changes needed — the badge HTML stays the same. Your CSS controls the visual treatment entirely.

Variable product price edge cases:

1. Variations with different discounts. A variable product might have three variations — green (20% off), blue (10% off), and red (full price). get_variation_regular_price('min') picks the cheapest variation’s regular price, which could belong to a variation that isn’t on sale. The badge would show an inaccurate percentage. Fix: loop through $product->get_available_variations() and find the highest discount percentage manually instead of relying on the min/max helpers.

2. Sale price scheduled for the future. WooCommerce supports scheduled sales — a product can have a sale price set with a future start date. In that case, $product->is_on_sale() returns false (correct) but get_sale_price() still returns the future sale price (misleading if you bypass the guard). Always check is_on_sale() first — it respects the schedule.

3. Products with zero regular price. Some data imports create products with a regular price of $0.00 and a sale price of $0.00 — the percentage calculation would divide by zero. The $regular <= 0 guard catches this, but be aware it also suppresses the badge on genuinely free products that have a regular price set to $0 and no sale price.

Performance notes:

  • Zero database queries. $product->get_regular_price() and $product->get_sale_price() read from the WC_Product object’s in-memory data, which was populated during the main query. No get_post_meta() calls, no custom table reads. The badge adds one arithmetic operation, one round(), and one printf() per product card — less than a microsecond each.
  • Variable product price calls use cached variation data. get_variation_regular_price('min') doesn’t load every variation — WooCommerce maintains min/max price metadata in _min_variation_price and _max_variation_price post meta, updated when variations are saved. One object property access, not a loop over all variations.
  • The callback never executes when a product isn’t on sale. The $product->is_on_sale() check is the first guard after the global $product line. Full-price products — which are the majority on most stores — pay exactly one method call and one return statement. No arithmetic, no printf, no CSS class comparisons.
  • Two WordPress actions, one function. Both hooks share the same 'theme_sale_badge' callback, which means PHP’s opcode cache stores one compiled function, not two. The memory cost is one function entry in WordPress’s action registry.
0 comments

No comments yet. Be the first.

Leave a Reply

Table of Contents

WooCommerce sale badge percentage — Code Snippet

WooCommerce sale badge percentage — code snippet by Mosharaf Hossain

This WooCommerce sale badge percentage is a production-tested PHP snippet for WordPress developers. See the WooCommerce for related official documentation.

The WooCommerce sale badge percentage snippet replaces the default “Sale!” label on product cards and the single product page with a calculated percentage such as “Save 23%” or “33% off” — computed from the regular price and the sale price at runtime. It hooks into WooCommerce’s woocommerce_sale_flash filter, so it works on every theme without template overrides or child theme copies.

The default WooCommerce sale badge displays the text “Sale!” regardless of the discount amount. This tells the shopper that a price has been reduced but gives them no information about how much they are saving. A percentage badge — “Save 30%” — gives the shopper an immediate value signal without requiring them to compare two price values. Conversion rate tests consistently show higher click-through rates on product cards that display percentage savings over fixed Sale labels.

The calculation uses regular_price and sale_price from the WooCommerce product object, both cast to float. The percentage is calculated and rounded to the nearest integer. The snippet handles variable products by checking if the product is on sale first, because WooCommerce only sets the sale_price on variable products when at least one variation is on sale.

The output is wrapped in the same HTML structure WooCommerce uses for the default badge so existing theme CSS styles apply automatically. You can override the percentage text format using a filter added by the snippet — pass your own sprintf format string to change the label from “Save X%” to “X% off” or any other wording that fits your store brand.

Place the function in your theme’s functions.php or in inc/woocommerce-helpers.php required from it. Tested on WooCommerce 7.0+ with WordPress 6.0+ and PHP 8.0+. Works with simple products, variable products, and grouped products where a sale price is set.

Browse all WooCommerce snippets at Code Snippets or see the WooCommerce Checkout Notice pattern for checkout-stage messaging. This badge is live on the Ben’s Natural Health store, where the percentage display improved add-to-cart rates on sale products.

Related: Vitamines WooCommerce project — and browse the full code snippet library.

Chat on WhatsApp