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.
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.
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.
// 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.
// 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.
// 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.
// 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.
// 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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 theWC_Productobject’s in-memory data, which was populated during the main query. Noget_post_meta()calls, no custom table reads. The badge adds one arithmetic operation, oneround(), and oneprintf()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_priceand_max_variation_pricepost 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 theglobal $productline. Full-price products — which are the majority on most stores — pay exactly one method call and onereturnstatement. 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.

No comments yet. Be the first.