Variant-Aware Price Snippet With Percentage + Value Savings Badge
Dawn's price snippet extended so a caller can hand it any variant object ahead of the snippet's own lookup, and so a savings badge — a percentage computed in Liquid plus a merchandiser-written value sentence — renders whenever the compare-at price is higher than the price.
Dawn's price snippet prices one thing — the product, or its selected-or-first-available variant — and its only sale treatment is a strikethrough. On LUS Brands we gave price.liquid a variant argument that wins over use_variant, so a bundle card can price the exact variant it lists, and added a savings badge that computes the percentage in Liquid and takes its value sentence from a variant metafield with $X and [value] tokens.
The Problem
Shopify gives every variant two numbers, price and compare_at_price, and Dawn's price snippet decides which object to read them from with a single boolean. use_variant: true reads the product's selected-or-first-available variant; anything else reads the product. That's the whole contract. There's no way for a caller to say price this variant — the size a shopper hasn't picked yet, the component a bundle card is listing, the variant a card was just asked about.
LUS Brands sells curl products in several sizes and in sets, and the merchandising story for a set is the saving: what percentage, and what the pieces would cost bought separately. Dawn's only sale treatment is a struck-through compare-at price. So any block that lists sibling products — the bundle rail on the product page, a card with more than one size — could neither price the right variant nor say what was being saved.
The Constraint
The snippet couldn't be replaced. price.liquid is rendered from a dozen places in the theme — the product sections, the featured-product section, predictive search, every product card — and its class names are what component-price.css styles. A second price component would have meant a second stylesheet and two things to keep in step.
Shopify's money filters format; they don't calculate. A percentage has to be derived in Liquid, and Liquid arithmetic on two integer cent values floors unless something forces a float. The value sentence had to stay editable per product by the merchandising team without a new app and without a section setting — a section setting is one sentence for every product, and the sentence differs by set. And the theme base is Dawn 15.4.1, so every change had to be a delta a future Dawn merge could carry rather than a rewrite.
What We Built
price.liquid resolves its target in a fixed order. If a variant argument is present and not blank, that variant is the target. Otherwise use_variant selects the product's selected-or-first-available variant, placeholder sets no target at all, and the fallback is the product itself. Everything below — price, compare-at, availability, unit price, the badge — reads from target, so a caller that passes a variant gets a price component for exactly that variant, with no change to the markup it renders.
The sale block was reordered. Dawn emits the struck-through compare-at price first and the sale price after it; here the current price comes first, and the compare-at price follows inside a new .price__compare-at wrapper that component-price.css lays out as a column aligned to the end. The reorder is what makes the badge read as an annotation on the price rather than on the strikethrough.
The badge itself renders whenever compare_at_price > price. The percentage is compare_at_price | minus: price | times: 100.0 | divided_by: compare_at_price | round — the 100.0 is doing real work, because without it the division floors. The value half comes from the variant: target.metafields.custom.save_badge_text is read through metafield_text and stripped, then two tokens are replaced. $X and [value] both become the compare-at price, formatted with the currency-code variant of the money filter when settings.currency_code_enabled is on and with plain money otherwise. A product-level flag, product.metafields.custom.show_value_saving, turns the value half off when it's false. The output is one .price__savings-badge element carrying the percentage and, when a sentence exists and the flag allows it, the substituted sentence in parentheses.
The unit-price render follows the same target: when target.unit_price_measurement is set, the unit-price snippet is rendered with that variant's unit price and measurement, so a per-100ml line on a bundle card belongs to the variant on the card.
Two live callers exercise the contract from opposite ends. The product page's price block calls the snippet with use_variant: true, so the badge follows the shopper's selection through the section's variant re-renders. bundle_and_save.liquid calls it with variant: bundle_variant and show_badges: false for each product in a set, so each card prices the component's own variant, with the savings badge and without Dawn's "Sale" and "Sold out" tags.
Why This Way
Passing a variant instead of forking the snippet keeps one price component and one stylesheet for the whole theme. The bundle rail and the product page render identical markup from the same file, and a fix to the badge lands everywhere at once. That's the reason the argument sits in Dawn's own snippet rather than in a price-bundle copy beside it.
Token substitution splits the badge along the line that matters: the theme supplies the number, the merchandiser writes the sentence. A campaign that changes how the saving is described is a metafield edit. A campaign that changes the saving itself is a compare-at price edit. Neither is a deploy.
What this buys is paid for in three places. The arithmetic runs in Liquid on every render of every call site, so it's a small cost multiplied by a dozen callers. Both the percentage and the substituted value derive from compare_at_price, which makes that one field the merchandising control for the whole badge — there's no second source to cross-check it against. And the snippet now carries four deltas from Dawn 15.4.1 that a future Dawn upgrade has to be merged around by hand.
Why Not an App
Savings-badge apps work after the page has rendered: a script finds the price elements, computes or looks up a saving, and inserts a badge next to them. That's a reasonable design for a theme that renders once.
This theme doesn't. The product page re-renders its price block through the Section Rendering API on every variant change, and a badge inserted by a script has to be re-applied after every swap, with a visible gap between the new price arriving and the badge catching up. Computing the badge inside the price snippet means it's present in the server-rendered HTML on first paint and inside every re-rendered fragment afterwards, because the fragment is produced by the same snippet. There's nothing to re-apply.
Implementation Notes
- Set against Dawn 15.4.1, the functional deltas are four: the
variantargument and its precedence overuse_variant, the sale-block reorder with the.price__compare-athook, the savings badge, and the unit-price render readingtargetrather than a fixed variant. - The percentage multiplies by
100.0beforedivided_by, forcing float division, and only then rounds. Dividing first would floor every discount to zero. - The badge sentence is read off the variant (
target.metafields.custom.save_badge_text); the suppression flag is read off the product (product.metafields.custom.show_value_saving). One sentence per size, one switch per product. metafield_textthenstripnormalize the metafield to plain trimmed text before the$Xand[value]replacements run.- The substituted value honours
settings.currency_code_enabledexactly as the struck-through compare-at price does, so the sentence and the strikethrough always agree on format. - In the on-sale state
component-price.cssgives.price__savings-badgeits own row (flex: 0 0 100%), except inside.product__info-container, where it sits inline with the price. bundle_and_save.liquidis the caller that passes an explicitvariant; it also passesshow_badges: false, so bundle cards get the savings badge without Dawn's sale and sold-out tags.
Edge Cases
- No compare-at price, or a compare-at price equal to or below the price: no badge, no
.price--on-saleclass. Full-price variants render exactly as Dawn renders them. show_value_savingset tofalseon the product: the badge degrades to the percentage alone rather than disappearing.save_badge_textunset on the variant: the same degradation — the!= blankcheck drops the parenthetical and keeps the percentage.- A product-level call (no
variant, nouse_variant) on a product whose price varies: the regular price renders through Dawn's "from" translation string, as before. - A product with quantity price breaks configured: the on-sale class is swapped for
volume-pricing--sale-badgeand the regular block shows Dawn's price range, unchanged. placeholder: truewith no product: Dawn's placeholder price renders. No product and no placeholder: the snippet renders nothing at all.
Platform Primitives Used
- Metafields — a single-line text metafield on the variant for the badge sentence, a boolean on the product to suppress the value half.
- Liquid section schema and settings —
settings.currency_code_enableddecides the money filter for both the strikethrough and the substituted token.
Where It Runs
Every template that renders a product section or a product card — the product, collection and cart templates — because every one of them reaches price.liquid. The badge is visible wherever a variant carries a compare-at price above its price: the product page's price block, the bundle rail on the same page, and the cards on collection listings.
What This Demonstrates
- Design system and reusable component library — the primary capability: one price component with one stylesheet, extended by argument rather than by copy, so a dozen callers share a single rendering contract.
- Metafield-driven PDP content blocks — the badge copy is a metafield with tokens, so how a saving is described is a merchandising edit.
- Variant-aware content and media swapping — the badge is computed per variant and rides the product section's re-renders, so it changes with the selection.
How We Know
The snippet itself, roughly 150 lines, read from the theme and set against Dawn 15.4.1, plus one metafield export and three client working-session records. The records establish two things the code alone wouldn't: that strikethrough and value pricing on collection cards was a stated requirement, and that a fixed discount label on product tiles was later asked to come off — which is why the badge sentence is a merchandiser-editable token string and not fixed copy. Confidence is strong. Two neighbouring files the theme's file census groups with this snippet are byte-identical to Dawn 15.4.1 and are not claimed.
Related Builds
- 'Bundle and save' PDP block driven by a product-list metafield — same storefront: the caller that passes an explicit
variant, and the reason the argument exists. - Rebuilt 2026 PDP section with merchandiser-composable blocks and per-product ATC color — same storefront: the product section whose price block calls this snippet with
use_variant: true. - Price snippet with a bundle 'value' metafield line and opt-out labelled price links — Nudestix, on Dawn 6.0.2: the same snippet extended in a different direction, where the argument is about the link, not the variant.
- Collection grid card on a licensed theme: metafield merchandising, a badge precedence chain, a hover-swap swatch element and first-row loading hints — Three Ships: a saving expressed as a card badge on a vendor theme, resolved through a precedence chain rather than a price component.
The Buy-vs-Build Question
A badge app buys a decoration applied from outside the theme and kept current by the vendor; extending the price component bought a badge that exists in the server-rendered markup and in every re-rendered fragment, at the cost of Liquid arithmetic on every call and four hand-merged deltas from Dawn. Where that line falls for a theme's shared components is argued at theme section library and design system: buy or build?.
Provenance & Evidence
- Client: LUS Brands — curly-hair care, loveurcurls.com
- Surfaces: Product detail page, collection listing, cart
- Templates served:
product,collectionandcart - Complexity: Medium — one snippet, roughly 150 lines
- Attribution: Deploi-modified vendor. Built on top of Dawn 15.4.1;
price.liquidis Dawn's snippet and this is our modification of it. Thevariantargument, the sale-block reorder, the savings badge and the unit-price target change are ours; the placeholder, volume-pricing and badge branches remain the vendor's. - Status: Live, verified 2026-09-07
- Evidence: the snippet read from the theme, one metafield export, three client working-session records
- Confidence: Strong — the requirement and the later change of copy are documented; the mechanism is documented from the code
- Not claimed: any figure for what a set saves, and any effect of the badge on anything
- Primary capability: Design system and reusable component library
Ready to Give Your Price Component One More Argument?
If your bundle cards show the wrong variant's price, or your savings badge arrives a beat after the price does, the fix is usually one argument in one snippet — not another app. Contact us today and we'll read your price component with you.