Shopify Horizon can show a short badge on each variant button, such as Start here or Save 19%, with a variant metafield and one small snippet. Every badge stays visible on the button’s top border, whether or not that option is selected. Buttons without badge text look the same as before.
We use this setup on our own Bug Fix service page, running Horizon 4.2.0. Below are the admin steps, the complete code, and the limits we kept on purpose.

Each badge comes from the Badge Text metafield of its variant. The 1 Fix button is selected.
Check whether this setup fits your product
This version covers the most common case and stays small enough to maintain:
- Products with one option. When a product has one option, such as Size or Quantity, each option value belongs to exactly one variant, so each button has one clear badge. When a product has two or more options, one value such as Small belongs to several variants. The snippet shows no badges on those products.
- Button-style variant pickers. Swatches and dropdowns stay unchanged. The last section explains what they would need.
- Text you control. The badge shows the text you enter. It does not calculate discounts, so you update any savings text when prices change.
Create the badge text metafield
The badge text lives in a variant metafield, so you edit it in Shopify admin without touching code again.
- In Shopify admin, go to Settings > Metafields and metaobjects > Variants and add a definition.
- Name it Badge Text. Make sure the namespace and key are
custom.badge_text, because the snippet reads that exact key. - Choose Single line text and keep it as one value.
- Optional: add a maximum character count, such as 16. Short badges keep the buttons tidy on mobile.

Add badge text to each variant
Open a product and edit each variant, or use the bulk editor when you have many variants. Add the Badge Text column and fill in only the variants that need a badge. Leave the field empty for no badge.

A few tips for the text:
- Type it in normal case. The theme shows it in uppercase.
- Keep it to two or three words. Long badges can crowd narrow mobile buttons.
- Calculate savings from the compare-at price and round down, so the badge never overstates the discount. For example, $119 against a $147 compare-at price saves 19.05%, so we use Save 19%.
- Claims such as Most popular should be true for your store. A neutral label such as Start here works when you have no sales data to support a ranking.
Add the badge snippet
Work on an unpublished duplicate of your theme. In the code editor, create snippets/variant-option-badge.liquid and paste the code below.
{%- liquid
assign badge_text = blank
if product_resource.options.size == 1
assign badge_text = product_option_value.variant.metafields.custom.badge_text.value | strip
endif
assign badge_id = 'VariantBadge-' | append: block_id | append: '-' | append: product_option_value.id
-%}
{%- if badge_text != blank -%}
{%- if attribute -%}
…
Collapse code
{%- liquid
assign badge_text = blank
if product_resource.options.size == 1
assign badge_text = product_option_value.variant.metafields.custom.badge_text.value | strip
endif
assign badge_id = 'VariantBadge-' | append: block_id | append: '-' | append: product_option_value.id
-%}
{%- if badge_text != blank -%}
{%- if attribute -%}
aria-describedby="{{ badge_id }}"
{%- else -%}
<span
id="{{ badge_id }}"
class="variant-option__badge"
>
{{- badge_text | escape -}}
</span>
{%- endif -%}
{%- endif -%}
{% stylesheet %}
/*
The badge straddles the top border, so buttons in a badge row cannot clip overflow.
Horizon's sliding selected pill relies on that clipping, so these rows use a static
selected background instead. Recheck these overrides after theme updates.
*/
.variant-option--buttons:has(.variant-option__badge) {
--variant-badge-offset: 0.8em;
padding-block-start: var(--variant-badge-offset);
row-gap: calc(var(--gap-sm) + var(--variant-badge-offset));
.variant-option__button-label {
overflow: visible;
padding-block: 1em;
}
.variant-option__button-label__text {
font-size: var(--font-size--lg);
}
.variant-option__button-label__pill {
display: none;
}
.variant-option__button-label:has(:checked):not(:has([data-option-available='false'])) {
background-color: var(--color-selected-variant-background);
&:hover {
background-color: var(--color-selected-variant-hover-background);
}
}
}
.variant-option__badge {
position: absolute;
inset-block-start: 0;
left: 50%;
z-index: 3;
pointer-events: none;
padding: 0.35em 0.7em;
border-radius: var(--style-border-radius-xs);
background-color: var(--color-selected-variant-border);
color: var(--color-variant-background);
font-family: var(--font-subheading--family);
font-weight: var(--font-subheading--weight);
font-size: var(--font-size--2xs);
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.2;
transform: translate(-50%, -50%);
}
{% endstylesheet %}
The snippet does three things:
- It reads
custom.badge_textonly when the product has exactly one option, and it outputs nothing when the field is empty. - It outputs either the visible badge or an
aria-describedbyattribute, depending on where you render it. The next step uses both. - It styles the badge with Horizon’s own color, font, and size variables, so the badge follows your color scheme. The CSS applies only to button rows that contain a badge.
Render the badge in the variant picker
Open snippets/variant-main-picker.liquid. Inside the loop that renders the variant buttons, find the radio <input>. Add the first block just before the input’s closing >:
{% unless variant_style == 'swatch' %}
{% render 'variant-option-badge',
product_resource: product_resource,
product_option_value: product_option_value,
block_id: block.id,
attribute: true
%}
{% endunless %}
Then find the variant-option__button-label__text span that prints the option name, and add the second block right after it:
{% render 'variant-option-badge',
product_resource: product_resource,
product_option_value: product_option_value,
block_id: block.id
%}
The first block matters for accessibility. Horizon gives each radio input an aria-label with only the option name, so screen readers skip other text inside the button. The aria-describedby attribute adds the badge text as a description.
These two blocks are the only changes to Horizon’s own files. Everything else lives in the new snippet.
Test the result
Preview the duplicate theme and check a product that has badge text. On our store, we checked these states:
- Every badge shows on desktop and at a 375px mobile width.
- After you switch to another variant, the badges stay in place and the selected style moves to the new button.
- Each radio input with a badge has an
aria-describedbythat points to its badge. - Shopify Theme Check reports no issues in the changed files.

After you switch to 3 Fixes, every badge stays in place.

At 375px wide, the badges fit above each button.
Also check a product without badge text and a product with two or more options. Both should look exactly as they did before. Horizon uses the same variant picker in other places, such as quick add, so check those if your theme uses them. We did not test quick add for this article.
Recheck the badges after a Horizon update
When you update Horizon, copy snippets/variant-option-badge.liquid to the new version as it is. Then add the two render blocks to the new variant-main-picker.liquid instead of copying the old file over it. Our guide to updating Shopify Horizon without losing custom code covers that process.
The badge sits partly outside the button, so the CSS changes two Horizon behaviors in rows that contain a badge:
- Horizon clips anything outside the button with
overflow: clip. The snippet setsoverflow: visibleso the badge is not cut off. - Horizon animates the selected background as a sliding element with the class
variant-option__button-label__pill, and that animation depends on the clipping. The snippet hides it and uses a static selected background instead.
After an update, confirm that these class names still exist and that the badge is not clipped. If Horizon changes this markup, adjust the CSS rather than restoring an old theme file.
Swatches, dropdowns, and products with several options
We left these cases out because each one needs its own design decision:
- Swatches: the swatch button is the color sample itself, so a badge in the same place would cover it. Swatches need a different layout, such as a badge below the swatch.
- Dropdowns: a native dropdown option can hold only plain text. You can add the badge text to the option name, but you cannot style it as a badge.
- Several options: you first need a rule for which badge a shared value such as Small should show, for example only after the shopper selects the other options.
If your store needs one of these versions, we can build it through our Shopify Feature Implementation service, which covers one focused custom feature like this.