Three-Tier Pricing
The canonical Starter / Pro / Enterprise grid, with the middle tier promoted.
6 frameworksBeginner
A pricing grid with a real switch that recomputes every price.
<!--
A switch is an on/off control, so it takes ONE label describing the on state
("Bill annually") - not the "Monthly | Annually" pair you usually see, which
is really a two-option choice wearing a switch's clothes. If both periods
deserve equal weight, use the radio fieldset in the Customization notes
instead; role="switch" then becomes a lie about what the control does.
The savings badge sits outside the label so "Save 20%" is not read as part of
the switch's accessible name.
-->
<section class="billing" aria-labelledby="billing-heading">
<h2 class="billing__heading" id="billing-heading">Pricing</h2>
<div class="billing__control">
<span class="billing__label" id="billing-switch-label">Bill annually</span>
<button
class="billing__switch"
type="button"
role="switch"
aria-checked="false"
aria-labelledby="billing-switch-label"
aria-describedby="billing-save"
>
<span class="billing__thumb" aria-hidden="true"></span>
</button>
<span class="billing__save" id="billing-save">Save 20%</span>
</div>
<ul class="billing__list">
<li class="billing__item">
<article class="billing__card" aria-labelledby="billing-starter">
<h3 class="billing__name" id="billing-starter">Starter</h3>
<p class="billing__price">
<!--
data-* holds both amounts so the script never does arithmetic on a
rendered string - parsing "$19" back into a number is how currency
symbols and thousands separators break a pricing page.
-->
<span class="billing__amount" data-monthly="$0" data-annual="$0">$0</span>
<span class="billing__period">/month</span>
</p>
<p class="billing__note" data-monthly="Free forever" data-annual="Free forever">Free forever</p>
<a class="billing__cta" href="#">Choose plan<span class="billing__sr"> Starter</span></a>
</article>
</li>
<li class="billing__item">
<article class="billing__card billing__card--highlighted" aria-labelledby="billing-pro">
<h3 class="billing__name" id="billing-pro">Pro</h3>
<p class="billing__price">
<span class="billing__amount" data-monthly="$19" data-annual="$15">$19</span>
<span class="billing__period">/month</span>
</p>
<p class="billing__note" data-monthly="Billed monthly" data-annual="Billed $180 annually">Billed monthly</p>
<a class="billing__cta billing__cta--solid" href="#">Choose plan<span class="billing__sr"> Pro</span></a>
</article>
</li>
<li class="billing__item">
<article class="billing__card" aria-labelledby="billing-enterprise">
<h3 class="billing__name" id="billing-enterprise">Enterprise</h3>
<p class="billing__price">
<span class="billing__amount" data-monthly="$49" data-annual="$39">$49</span>
<span class="billing__period">/month</span>
</p>
<p class="billing__note" data-monthly="Billed monthly" data-annual="Billed $468 annually">Billed monthly</p>
<a class="billing__cta" href="#">Choose plan<span class="billing__sr"> Enterprise</span></a>
</article>
</li>
</ul>
</section>
<script>
document.querySelectorAll('.billing').forEach(function (root) {
var toggle = root.querySelector('.billing__switch');
var swappable = root.querySelectorAll('[data-monthly]');
function render(annual) {
toggle.setAttribute('aria-checked', String(annual));
swappable.forEach(function (node) {
node.textContent = annual ? node.dataset.annual : node.dataset.monthly;
});
}
toggle.addEventListener('click', function () {
render(toggle.getAttribute('aria-checked') !== 'true');
});
// role="switch" on a <button> already gives Enter and Space for free - the
// browser fires click for both - so there is no keydown handler here.
render(false);
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
ctaLabel | string | 'Choose plan' | Call-to-action button text. |
className | string | - | Additional classes merged onto the root element. |
The control is a `<button role="switch">` with one label describing its on state ("Bill annually") - not the "Monthly | Annually" pair you usually see, which is a two-option choice wearing a switch’s clothes. If both periods deserve equal weight, use a `<fieldset>` of two radios instead and `role="switch"` stops being a lie about what the control does. Two details keep it honest: the checked styling hangs off `[aria-checked="true"]` rather than a class, so making it *look* on forces telling assistive tech it is on; and the amounts live as numbers, never as parsed "$19" strings - the annual total is arithmetic, and formatting a number is far easier than parsing a formatted one back. The note line has a reserved `min-h-10` because the annual copy is longer, and without it the whole grid reflows mid-toggle.