Pricing table
Three tiers with a highlighted plan and a monthly-to-yearly toggle.
<div class="pricing">
<div class="billing">
<span id="lab-m">Monthly</span>
<button class="switch" id="cycle" role="switch" aria-checked="false"
aria-labelledby="lab-m lab-y"><span class="knob"></span></button>
<span id="lab-y">Yearly <em>save 20%</em></span>
</div>
<div class="tiers">
<article class="tier">
<h3>Starter</h3>
<p class="blurb">For one person testing an idea.</p>
<p class="cost"><span class="amt" data-m="0" data-y="0">£0</span><span class="per">/month</span></p>
<a class="pick ghost" href="#">Start free</a>
<ul>
<li>1 project</li>
<li>10,000 requests a month</li>
<li>Community support</li>
</ul>
</article>
<article class="tier best">
<span class="flag">Most popular</span>
<h3>Studio</h3>
<p class="blurb">For a small team shipping regularly.</p>
<p class="cost"><span class="amt" data-m="24" data-y="19">£24</span><span class="per">/month</span></p>
<a class="pick" href="#">Choose Studio</a>
<ul>
<li>10 projects</li>
<li>500,000 requests a month</li>
<li>Email support, same working day</li>
<li>Custom domains</li>
</ul>
</article>
<article class="tier">
<h3>Agency</h3>
<p class="blurb">For client work and larger volumes.</p>
<p class="cost"><span class="amt" data-m="79" data-y="63">£79</span><span class="per">/month</span></p>
<a class="pick ghost" href="#">Choose Agency</a>
<ul>
<li>Unlimited projects</li>
<li>5,000,000 requests a month</li>
<li>Priority support</li>
<li>Custom domains and SSO</li>
<li>Invoiced billing</li>
</ul>
</article>
</div>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; background: #f6f8fb; padding: 28px 20px; }
.pricing { max-width: 1000px; margin: 0 auto; }
/* ---- billing toggle ---- */
.billing {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 28px;
font-size: .92rem;
color: #5b6b83;
}
.billing em { font-style: normal; color: #1f8a5b; font-weight: 600; }
.switch {
width: 46px;
height: 26px;
padding: 3px;
border: 0;
border-radius: 999px;
background: #c9d3e2;
cursor: pointer;
transition: background .18s ease;
}
.switch[aria-checked="true"] { background: #3b4fe4; }
.knob {
display: block;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform .18s ease;
}
.switch[aria-checked="true"] .knob { transform: translateX(20px); }
/* ---- tiers ---- */
.tiers {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
align-items: start;
}
.tier {
position: relative;
display: flex;
flex-direction: column;
padding: 26px 24px 28px;
background: #fff;
border: 1px solid #e2e7ef;
border-radius: 14px;
}
.tier.best {
border-color: #3b4fe4;
box-shadow: 0 2px 4px rgba(59,79,228,.06), 0 18px 40px -24px rgba(59,79,228,.5);
}
.flag {
position: absolute;
top: -11px;
left: 24px;
background: #3b4fe4;
color: #fff;
font-size: .72rem;
font-weight: 500;
padding: 4px 11px;
border-radius: 999px;
}
.tier h3 { font-size: 1.05rem; color: #16202e; }
.blurb { margin-top: 4px; font-size: .87rem; color: #8593ab; }
.cost { margin-top: 18px; display: flex; align-items: baseline; gap: 4px; }
.amt { font-size: 2.2rem; font-weight: 700; color: #16202e; letter-spacing: -0.02em; }
.per { font-size: .86rem; color: #8593ab; }
.pick {
margin-top: 18px;
display: block;
text-align: center;
text-decoration: none;
font-size: .92rem;
font-weight: 500;
padding: 11px;
border-radius: 9px;
background: #3b4fe4;
color: #fff;
}
.pick:hover { background: #2a3abf; }
.pick.ghost { background: #fff; color: #16202e; border: 1px solid #e2e7ef; }
.pick.ghost:hover { background: #f2f5f9; }
.tier ul { margin: 22px 0 0; padding: 0; list-style: none; display: grid; gap: 9px; }
.tier li {
position: relative;
padding-left: 24px;
font-size: .89rem;
color: #5b6b83;
}
.tier li::before {
content: "";
position: absolute;
left: 0;
top: .45em;
width: 14px;
height: 8px;
border-left: 2px solid #1f8a5b;
border-bottom: 2px solid #1f8a5b;
transform: rotate(-45deg);
}
@media (prefers-reduced-motion: reduce) {
.switch, .knob { transition: none; }
}const cycle = document.getElementById("cycle");
const amounts = [...document.querySelectorAll(".amt")];
const periods = [...document.querySelectorAll(".per")];
cycle.addEventListener("click", () => {
const yearly = cycle.getAttribute("aria-checked") !== "true";
cycle.setAttribute("aria-checked", yearly ? "true" : "false");
amounts.forEach((el) => {
el.textContent = "£" + el.dataset[yearly ? "y" : "m"];
});
periods.forEach((el) => {
el.textContent = yearly ? "/month, billed yearly" : "/month";
});
});How it works
The toggle is a real switch. role="switch" with aria-checked is the correct pattern, and because it is a <button> it already responds to Enter and Space. aria-labelledby points at both labels so a screen reader announces "Monthly / Yearly save 20%, switch, off".
Prices live in data attributes. Both figures are in the markup from the start, so switching is instant and the yearly price is present for search engines even before any JavaScript runs.
Yearly still shows a monthly figure. "£19/month, billed yearly" converts better than "£228/year" because it is directly comparable to the monthly number next to it. Saying what is actually charged is also what consumer regulations expect.
The tick is CSS, not an icon. Two borders on a rotated pseudo-element. Fifty ticks cost nothing and inherit the colour.
align-items: start on the grid stops the shorter cards stretching to match the tallest. Equal-height pricing cards with a big empty gap at the bottom look like a bug.
Accessibility notes
The switch announces its state, which a styled checkbox with a hidden input often does not.
Feature lists are real <ul> elements, so a screen reader announces "list, 4 items" and the user can skip it. The tick marks are pseudo-elements and generate no announcement — correct, since they carry no information beyond the list item itself.
Check the contrast on your highlight colour. White text on a mid-tone brand colour frequently fails at small sizes even when it passes at large ones.
Making it yours
The middle tier is highlighted with a border and a tinted shadow rather than by making it physically larger. Scaling it up is common and it breaks the grid's alignment, pushing the popular plan's button out of line with the others.
Three tiers is the usual maximum before the comparison becomes work. If you need more, a comparison table serves better than more cards.
Related templates
Product card
An e-commerce card with image, price, rating and an add-to-basket action.
HTMLCSSTailwindArticle card
A blog post card with cover image, category, date and reading time.
HTMLCSSProfile card
A team or author card with avatar, role, bio and social links.
HTMLCSSHTML email boilerplate
A table-based email shell that survives Outlook and Gmail.
HTMLCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.