Here's the short answer first. A pricing table is a CSS grid of cards that wraps on its own with repeat(auto-fit, minmax(...)). Each card is a flex column, and the button gets margin-top: auto so every button sits on the same line. The favourite plan gets a border and a badge, not a zoom. Prices use tabular numbers and live in cents. That's the whole thing.
If you're a beginner, read it top to bottom and click every live demo, because seeing the difference is worth more than my words. If you already write CSS every day, jump to the section about the highlighted card and then to the one about subgrid. Those are the two places where people who know the basics still get it wrong. The part about prices, money and the Plan B is for anyone who has ever had to put a real price on a real page.
This article is part of a series I call Concepts in practice. The idea is simple. Instead of learning CSS properties one by one from a list, we take a piece of a website you already know from real life, rebuild it, and look at the tricks behind it. You learn faster when you already know what the finished thing should look like.
I'll be honest about why this matters to me. When I started, I built everything from my head. No plan, no sketch, no visual reference. I just kept changing things until I liked how it looked. That works right up until it doesn't, and with a pricing table it stops working very fast, because the page has to look right with three different amounts of text, on a phone and on a big screen, and with a price that changes when someone clicks a switch.
The good news is that the people who design the big websites have already solved most of these problems. Look at enough pricing pages and you'll notice they all use the same few moves. That's what we'll steal today: the moves, not the look.
warning
Rebuild the pattern, never the brand. The layout idea of a pricing table belongs to nobody. A company's logo, its exact wording, its illustrations and its colours do belong to someone. In this article every plan name, price and colour is made up. Do the same when you practise, and especially before anything you build goes on a public page.
Let's look at the problem before we fix it. Below is the version almost everyone writes first. Three cards in a row with display: flex, and the middle card made bigger with transform: scale(1.08) so it stands out. Press "Apply the fixes" to switch to the fixed version and back again.
<button type="button" class="switch" aria-pressed="false">Apply the fixes</button>
<div class="plans naive">
<article class="card">
<h3>Starter</h3>
<p class="price">$0 <span>/ month</span></p>
<ul>
<li>3 projects</li>
<li>Community support</li>
</ul>
<a class="cta" href="#">Start free</a>
</article>
<article class="card featured">
<p class="badge">Most popular</p>
<h3>Pro</h3>
<p class="price">$12 <span>/ month</span></p>
<ul>
<li>Unlimited projects</li>
<li>Custom domain</li>
<li>Email support</li>
<li>Version history</li>
</ul>
<a class="cta" href="#">Choose Pro</a>
</article>
<article class="card">
<h3>Team</h3>
<p class="price">$30 <span>/ month</span></p>
<ul>
<li>Everything in Pro</li>
<li>5 seats</li>
<li>Shared folders</li>
</ul>
<a class="cta" href="#">Choose Team</a>
</article>
</div>
* { box-sizing: border-box; }
body { margin: 0; padding: 1.5rem 1rem; font-family: system-ui, sans-serif; color: #1f2933; background: #f7f7f5; }
.switch { display: block; margin: 0 auto 1.75rem; font: inherit; padding: 0.5rem 1rem; border: 1px solid #1f2933; border-radius: 999px; background: #fff; color: #1f2933; cursor: pointer; }
.switch[aria-pressed="true"] { background: #1f2933; color: #fff; }
.plans { gap: 1rem; max-width: 46rem; margin: 0 auto; }
.card { background: #fff; border: 1px solid #d9dde1; border-radius: 12px; padding: 1.25rem; }
.card h3 { margin: 0 0 0.25rem; font-size: 1.1rem; }
.price { margin: 0 0 1rem; font-size: 2rem; font-weight: 700; }
.price span { font-size: 0.9rem; font-weight: 400; color: #52606d; }
.card ul { margin: 0 0 1.25rem; padding-left: 1.1rem; }
.card li { margin: 0.3rem 0; }
.cta { display: block; text-align: center; padding: 0.6rem; border-radius: 8px; background: #1f2933; color: #fff; text-decoration: none; }
.badge { margin: 0 0 0.5rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: #0b6e4f; }
/* Before: what most first attempts look like */
.naive { display: flex; }
.naive .card { flex: 1; }
.naive .featured { transform: scale(1.08); }
/* After: the three fixes */
.fixed { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); }
.fixed .card { display: flex; flex-direction: column; }
.fixed .cta { margin-top: auto; }
.fixed .featured { position: relative; border: 2px solid #0b6e4f; }
.fixed .badge { position: absolute; top: 0; left: 50%; translate: -50% -50%; margin: 0; padding: 0.2rem 0.6rem; border-radius: 999px; background: #0b6e4f; color: #fff; white-space: nowrap; }
const plans = document.querySelector(".plans");
const toggle = document.querySelector(".switch");
toggle.addEventListener("click", () => {
const fixed = plans.classList.toggle("fixed");
plans.classList.toggle("naive", !fixed);
toggle.setAttribute("aria-pressed", String(fixed));
});
for (const link of document.querySelectorAll(".cta")) {
link.addEventListener("click", (event) => event.preventDefault());
}
Look at the first version closely and you'll see three things that feel wrong, even if you can't name them yet.
- The buttons are at different heights. Each button sits right under its own list, and the lists have two, four and three items. Your eye expects them on one line, because they do the same job.
- The middle card is zoomed, but the cards next to it don't know that. The gap on each side gets smaller and the card pokes out above and below the row. With a slightly bigger zoom or a smaller gap, it would cover its neighbours.
- On a phone the row never breaks. Flexbox keeps all three cards on one line, so each one gets about a third of a small screen and the text gets squashed. Open the demo in full screen on your phone and you'll see it.
Why does this happen? Because each of those properties does exactly what it says, and nothing more. Flexbox with no wrapping puts things in one row. A card is only as tall as its content tells it to be, unless something stretches it. And a transform, by design, is a visual effect that the layout ignores. The W3C specification says it in so many words. None of this is a bug. It's us asking for the wrong thing.
The transform part surprises most people, so here's the exact line from the CSS Transforms specification: "the transform property does not affect the flow of the content surrounding the transformed element." In plain words, the browser first lays out the page as if the zoom didn't exist, and only then paints the card bigger on top of that layout.
Before any CSS, decide what each piece of the card is. This sounds boring and it's the step I used to skip. It's also what makes the page readable for a screen reader and for Google, and it makes the CSS shorter, because you style real elements instead of a pile of divs.
<article class="card featured">
<p class="badge">Most popular</p>
<h3>Pro</h3>
<p class="desc">For one person who ships every week.</p>
<p class="price" data-monthly="1200" data-yearly="12000">
<span class="amount">$12</span> <span class="per">/ month</span>
</p>
<p class="billed">Billed every month</p>
<ul role="list">
<li>Unlimited projects</li>
<li>Custom domain</li>
</ul>
<a class="cta" href="/signup?plan=pro">Choose Pro</a>
</article>Let's go through it line by line.
- article: each plan is a self-contained thing that would make sense on its own, which is what article means in HTML. A div would work visually, but it tells nobody anything.
- p class="badge": the "Most popular" label is just a short paragraph. Only the featured card has it. We'll lift it onto the card's top border later with CSS, but in the HTML it's ordinary text, so it's read out like ordinary text.
- h3: the plan name is a heading. Someone using a screen reader can jump from heading to heading and hear Starter, Pro, Team. It's h3 because the section around the table has its own h2, like "Pick a plan". Headings go in order; don't pick a level because of its size.
- p class="price" with two data attributes: data-monthly and data-yearly hold the price in cents, as plain numbers. The text inside, $12, is what the visitor sees when the page loads, even if JavaScript never runs. We'll come back to why cents.
- span class="amount" and span class="per": the number and the "/ month" are separate, because they look different. The number is big, the "/ month" is small and grey.
- ul role="list": the features are a real list. The role="list" looks redundant, and I'll explain later why it's there.
- a class="cta": the button is a link, not a button element. It takes you to another page, the sign-up page with the plan in the address. That's a link's job. A button element is for things that happen on the same page, like the monthly and yearly switch.
tip
Quick rule for links and buttons: if it goes somewhere, it's a link. If it does something here, it's a button. Get this right and keyboard users, screen readers and the browser's back button all behave the way people expect, without extra code.
Now the layout. We want three cards side by side when there's room, two when there's a bit less, and one on a phone. The old way was three media queries. The modern way is one line. If grid itself is new to you, my visual guide to CSS Grid covers the basics, and Flexbox vs Grid on real layouts explains why this job belongs to grid and not to flexbox.
.plans {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 1.25rem;
}That middle line looks scary, so let's read it from the inside out.
- 13rem is the narrowest a card is allowed to be. With the default font size that's 208 pixels. Below that, the text inside a card starts to feel cramped.
- min(100%, 13rem) picks the smaller of the two. On a normal screen that's 13rem. On a very narrow phone, where 13rem would be wider than the screen, it becomes 100%, so the card never sticks out sideways. Without this little min(), a 13rem card on a tiny screen causes a horizontal scrollbar.
- minmax(that, 1fr) means: each column is at least that wide, and at most one equal share of the free space. So the cards grow to fill the row, all the same width.
- repeat(auto-fit, ...) tells the browser: you count how many columns fit. Three fit on a laptop, two on a tablet, one on a phone. You never write a breakpoint.
- gap: 1.25rem is the space between cards, both between columns and between rows when they wrap. No margins on the cards, so nothing doubles up at the edges.
Think of a long bench where people only sit if they have at least shoulder width. When the bench is long, three people sit and spread out a bit. When it's shorter, only two fit, and the third person starts a new row behind them. Nobody has to tell them where to sit; the rule does it.
You'll also see auto-fill instead of auto-fit, and the difference matters with only three cards. MDN puts it like this: auto-fit "behaves as auto-fill, except that after placing grid items, any empty repeated tracks are collapsed." On a very wide screen, auto-fill keeps empty columns as ghosts, so your three cards stay narrow and huddle on the left. auto-fit throws the empty columns away, and the three cards stretch across the row. For a pricing table you want auto-fit. You'll also want a max-width on the section, so the cards don't stretch into pancakes on a huge monitor.
Grid already gives us one gift for free. By default, every card in a grid row is stretched to the height of the tallest one. So the white boxes are already the same height. The problem is what's inside them: the button still sits right under the list. We need the button to fall to the bottom of its card.
.card {
display: flex;
flex-direction: column;
}
.cta {
margin-top: auto;
}- display: flex with flex-direction: column turns the inside of each card into a vertical stack. Badge, name, description, price, list, button, one under the other, like before.
- margin-top: auto on the button is the trick. In a flex container, an auto margin takes all the free space it can. So all the empty space in the card ends up above the button, and the button lands at the bottom.
Picture a compressed spring placed right above the button. The spring pushes out until it hits the content above it, and whatever space is left, it fills. Every card has its own spring, so every button ends up at the bottom of its card, and because grid made the cards the same height, the buttons line up. MDN's page on aligning items in a flex container explains it with a row instead of a column: the auto margin "consumes all the space it can in its axis". Same idea, turned 90 degrees.
What breaks if you remove it? Take away margin-top: auto and the buttons jump back under their lists. Take away display: flex on the card and the auto margin does nothing, because in normal block layout an auto top margin is just zero. You need both lines.
You'll find people doing this with a fixed height on every card, something like height: 420px. Please don't. The day someone adds a fifth feature to the Pro plan, or a visitor makes the text bigger in their browser, the text spills out of the card. The spring adapts. A fixed height doesn't.
Every pricing page wants one plan to stand out. The zoom we saw in the first demo is the most common way, and now you know why it fights the layout. Here's the version that works with the layout instead.
.featured {
position: relative;
border: 2px solid #0b6e4f;
}
.badge {
position: absolute;
top: 0;
left: 50%;
translate: -50% -50%;
white-space: nowrap;
}- .featured gets a thicker border in our accent colour. That alone makes it stand out, and it takes up real space, so the layout knows about it. The cards next to it make room.
- position: relative on the card doesn't move the card at all. It only says: anything inside me with position: absolute should measure from my corners, not from the page.
- position: absolute on the badge takes it out of the stack, so it doesn't push the plan name down. Then top: 0 and left: 50% put the badge's top-left corner at the middle of the card's top edge.
- translate: -50% -50% moves the badge back by half of its own width and half of its own height. Now its centre sits exactly on the card's border, half outside and half inside. That's the classic "label sitting on the edge" look.
- white-space: nowrap keeps "Most popular" on one line, even when the card gets narrow.
Wait, didn't I just say transforms are bad here? Look closely: translate on the badge is fine, because we want the badge to float over the border, and it's out of the layout anyway. The problem was never transforms. The problem was using a transform on something that the rest of the layout should make room for.
If you want the favourite to look bigger as well, give it more padding at the top and bottom, or a slightly larger price. Those are real sizes, so the grid adapts. Just remember that grid stretches every card in the row, so a taller featured card makes the others taller too. That's usually fine, and it keeps the buttons in line.
This one is small and almost nobody notices it consciously, which is the point. When you switch from monthly to yearly, the numbers change. In most fonts, the digit 1 is narrower than the digit 0, so $12 and $10 are different widths and the price jumps sideways a little when it changes.
.price { font-size: 2.25rem; font-weight: 700; line-height: 1; }
.amount { font-variant-numeric: tabular-nums; }
.per { font-size: 0.9rem; font-weight: 400; color: #52606d; }- .price makes the number big and bold, and line-height: 1 stops a big font from adding a lot of empty space above and below the line.
- .amount uses font-variant-numeric: tabular-nums. That asks the font for its table digits, which are all the same width. Same idea as numbers in a bank statement lining up in a column.
- .per makes "/ month" small, normal weight and grey, so the eye reads the number first and the period second.
MDN describes tabular-nums as the set of figures "where numbers are all of the same size, allowing them to be easily aligned like in tables." It depends on the font having those figures; the system fonts on the common operating systems do, and most good web fonts do. If a font doesn't have them, nothing breaks, you just get the normal digits.
Most pricing pages have a pill-shaped switch on top: Monthly or Yearly. It looks like a custom widget, and a lot of people build it from divs and click handlers. But think about what it really is. You pick one option out of two, and exactly one is always selected. HTML has had an element for that since forever: radio buttons.
<fieldset class="billing">
<legend class="visually-hidden">Billing period</legend>
<input type="radio" id="monthly" name="billing" value="monthly" checked>
<label for="monthly">Monthly</label>
<input type="radio" id="yearly" name="billing" value="yearly">
<label for="yearly">Yearly, 2 months free</label>
</fieldset>- fieldset groups the two options, and legend gives the group a name, "Billing period". A screen reader announces it when you enter the group. We hide the legend visually because the pills explain themselves to sighted visitors, but we don't remove it.
- Both inputs share name="billing", and that's what makes them one group: choosing one automatically un-chooses the other. You get that for free.
- checked on the monthly input makes it the starting choice, and it matches the prices written in the HTML.
- Each label is tied to its input with for and id. Clicking the label selects the radio, so the whole pill is clickable. The arrow keys move between the two options, again for free.
.billing input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.billing label { padding: 0.5rem 1rem; border: 1px solid #1f2933; border-radius: 999px; }
.billing input:checked + label { background: #1f2933; color: #fff; }
.billing input:focus-visible + label { outline: 3px solid #0b6e4f; outline-offset: 2px; }- The first line hides the round radio circles, but not with display: none. That's important. display: none would remove the inputs completely, and the keyboard couldn't reach them anymore. opacity: 0 and a 1-pixel size keep them in the page, just invisible.
- The labels become the pills: some padding, a border, and a border-radius so big that the ends are fully round.
- input:checked + label means: the label that comes right after a checked input. That's why each label sits directly after its input in the HTML. The selected pill gets the dark background.
- input:focus-visible + label draws a clear outline on the pill when someone reaches it with the keyboard. Since the real input is invisible, the focus ring has to show on the label, otherwise a keyboard user can't see where they are.
If you'd rather build it with two buttons, that's fine too, but then you need aria-pressed on each button and you have to keep exactly one pressed yourself. The first demo above uses aria-pressed on its single "Apply the fixes" button, which is the right tool for one on/off switch. For one choice out of two, the radio group does that work for you.
Now the JavaScript. It has one job: when the switch changes, show the right numbers. Before the code, one rule that will save you real trouble one day. Never do arithmetic with prices as decimals.
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(119.99 / 12); // 9.999166666666666I ran both lines under Node 26, and that's the real output. Computers store most decimal fractions as tiny approximations, so sums and divisions of dollars drift. That's why the HTML stores 1200 instead of 12.00. Whole cents are whole numbers, and whole numbers stay exact. We only turn them into dollars at the very last moment, when we print them.
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
function showPrices(period) {
for (const price of document.querySelectorAll(".price")) {
const cents = Number(price.dataset[period]);
if (cents === 0) continue;
const perMonth = period === "yearly" ? cents / 12 : cents;
price.querySelector(".amount").textContent = money.format(perMonth / 100);
price.nextElementSibling.textContent =
period === "yearly" ? `${money.format(cents / 100)} billed once a year` : "Billed every month";
}
}
document.querySelector(".billing").addEventListener("change", (event) => {
showPrices(event.target.value);
document.querySelector("#status").textContent = `Showing ${event.target.value} prices`;
});Line by line, because this is where beginners usually start copying without reading.
- new Intl.NumberFormat("en-US", {...}) creates a formatter once. Give it a number, it gives you back a properly written price, with the dollar sign in the right place for that language. We create it once at the top instead of inside the loop, because it never changes.
- minimumFractionDigits: 0 and maximumFractionDigits: 0 say: no cents after the point. Our prices are whole dollars, so $10 looks cleaner than $10.00. Set both. Dollars come with default digit settings, and MDN warns that these defaults can clash with a value you set yourself and throw a RangeError, even if you only set one of the two.
- showPrices(period) receives "monthly" or "yearly", the value of the radio that was picked.
- document.querySelectorAll(".price") finds every price paragraph, and the for...of loop visits them one by one. Same pattern as rendering a list of cards: one piece of code, many items.
- price.dataset[period] reads data-monthly or data-yearly, depending on the period. dataset gives you every data- attribute of an element, and they always come back as text, so Number() turns "1200" into 1200.
- if (cents === 0) continue skips the free plan. Its text, "$0" and "Free forever", is already correct in the HTML for both periods, so we leave it alone.
- perMonth: for yearly billing we show the monthly equivalent, because that's the number people compare. 12000 cents a year divided by 12 is 1000 cents a month.
- money.format(perMonth / 100) turns cents into dollars only now, at the moment of printing, and writes the result into the .amount span with textContent. textContent sets plain text, never HTML, so nothing in there can turn into markup.
- price.nextElementSibling is the paragraph right after the price, the "billed" line. It changes to "$120 billed once a year" or back to "Billed every month", so nobody is surprised at checkout by a bigger number than the one they saw.
- The change listener sits on the fieldset, not on each radio. A change event from either radio bubbles up to the fieldset, so one listener covers both. event.target is the radio that was just picked.
Every option of the formatter, including that warning about digit defaults, is on MDN's Intl.NumberFormat page. Keep it open the first few times you format money.
If the loop over every .price feels familiar, it should. It's the same idea as rendering many cards without repeating code: one function, a list of items, and the data decides what each one shows. In a real project you'd go one step further and build the three cards from an array of plans too.
warning
Rounding hides cents. With maximumFractionDigits: 0, a yearly price of 9900 cents shows as $8 a month, because 825 cents gets rounded. I ran it: the zero-decimals formatter prints $8, the normal one prints $8.25. Pick prices that divide evenly by 12, or show cents. Either way, the number on the page is only a display. The amount the customer actually pays must come from your server or your payment provider, never from a number the browser sent back.
One quiet line for screen reader users
The last line of the listener writes "Showing yearly prices" into a paragraph with aria-live="polite". That paragraph is visually hidden. When its text changes, a screen reader reads it out after it finishes what it's saying. Without it, a blind visitor presses the switch and hears nothing, while every price on the page has changed. We announce one short sentence instead of reading all three prices, because hearing three numbers at once is noise.
Here is everything together: the markup, the grid, the spring, the badge, the switch and the script. Switch to Yearly and watch the Pro and Team prices change while their width stays the same. Then press Tab to walk through the page with the keyboard.
<section class="pricing" aria-labelledby="pricing-title">
<h2 id="pricing-title">Pick a plan</h2>
<fieldset class="billing">
<legend class="visually-hidden">Billing period</legend>
<input type="radio" id="monthly" name="billing" value="monthly" checked>
<label for="monthly">Monthly</label>
<input type="radio" id="yearly" name="billing" value="yearly">
<label for="yearly">Yearly, 2 months free</label>
</fieldset>
<p class="visually-hidden" id="status" aria-live="polite"></p>
<div class="plans">
<article class="card">
<h3>Starter</h3>
<p class="desc">For trying it out.</p>
<p class="price" data-monthly="0" data-yearly="0"><span class="amount">$0</span> <span class="per">/ month</span></p>
<p class="billed">Free forever</p>
<ul role="list">
<li>3 projects</li>
<li>Community support</li>
</ul>
<a class="cta" href="#">Start free</a>
</article>
<article class="card featured">
<p class="badge">Most popular</p>
<h3>Pro</h3>
<p class="desc">For one person who ships every week.</p>
<p class="price" data-monthly="1200" data-yearly="12000"><span class="amount">$12</span> <span class="per">/ month</span></p>
<p class="billed">Billed every month</p>
<ul role="list">
<li>Unlimited projects</li>
<li>Custom domain</li>
<li>Email support</li>
<li>Version history</li>
</ul>
<a class="cta" href="#">Choose Pro</a>
</article>
<article class="card">
<h3>Team</h3>
<p class="desc">For small teams.</p>
<p class="price" data-monthly="3000" data-yearly="30000"><span class="amount">$30</span> <span class="per">/ month</span></p>
<p class="billed">Billed every month</p>
<ul role="list">
<li>Everything in Pro</li>
<li>5 seats</li>
<li>Shared folders</li>
</ul>
<a class="cta" href="#">Choose Team</a>
</article>
</div>
</section>
* { box-sizing: border-box; }
body { margin: 0; padding: 1.5rem 1rem 2rem; font-family: system-ui, sans-serif; color: #1f2933; background: #f7f7f5; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.pricing { max-width: 46rem; margin: 0 auto; }
.pricing h2 { margin: 0 0 1rem; text-align: center; font-size: 1.5rem; }
.billing { display: flex; justify-content: center; gap: 0.25rem; margin: 0 0 2rem; padding: 0; border: 0; }
.billing input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.billing label { padding: 0.5rem 1rem; border: 1px solid #1f2933; border-radius: 999px; cursor: pointer; }
.billing input:checked + label { background: #1f2933; color: #fff; }
.billing input:focus-visible + label { outline: 3px solid #0b6e4f; outline-offset: 2px; }
.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); gap: 1.25rem; }
.card { display: flex; flex-direction: column; background: #fff; border: 1px solid #d9dde1; border-radius: 12px; padding: 1.5rem 1.25rem 1.25rem; }
.card h3 { margin: 0; font-size: 1.1rem; }
.desc { margin: 0.25rem 0 1rem; color: #52606d; }
.price { margin: 0; font-size: 2.25rem; font-weight: 700; line-height: 1; }
.amount { font-variant-numeric: tabular-nums; }
.per { font-size: 0.9rem; font-weight: 400; color: #52606d; }
.billed { margin: 0.4rem 0 1.25rem; font-size: 0.85rem; color: #52606d; }
.card ul { margin: 0 0 1.5rem; padding: 0; list-style: none; }
.card li { position: relative; margin: 0.4rem 0; padding-left: 1.5rem; }
.card li::before { content: ""; position: absolute; left: 0.3rem; top: 0.2em; width: 0.4rem; height: 0.75rem; border: solid #0b6e4f; border-width: 0 2px 2px 0; rotate: 45deg; }
.cta { margin-top: auto; display: block; text-align: center; padding: 0.7rem; border-radius: 8px; background: #1f2933; color: #fff; text-decoration: none; font-weight: 600; }
.cta:focus-visible { outline: 3px solid #0b6e4f; outline-offset: 2px; }
.featured { position: relative; border: 2px solid #0b6e4f; }
.featured .cta { background: #0b6e4f; }
.badge { position: absolute; top: 0; left: 50%; translate: -50% -50%; margin: 0; padding: 0.2rem 0.7rem; border-radius: 999px; background: #0b6e4f; color: #fff; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; white-space: nowrap; }
@media (prefers-reduced-motion: no-preference) {
.card { transition: translate 0.2s ease; }
.card:hover { translate: 0 -4px; }
}
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
function showPrices(period) {
for (const price of document.querySelectorAll(".price")) {
const cents = Number(price.dataset[period]);
if (cents === 0) continue;
const perMonth = period === "yearly" ? cents / 12 : cents;
price.querySelector(".amount").textContent = money.format(perMonth / 100);
price.nextElementSibling.textContent =
period === "yearly" ? `${money.format(cents / 100)} billed once a year` : "Billed every month";
}
}
document.querySelector(".billing").addEventListener("change", (event) => {
showPrices(event.target.value);
document.querySelector("#status").textContent = `Showing ${event.target.value} prices`;
});
for (const link of document.querySelectorAll(".cta")) {
link.addEventListener("click", (event) => event.preventDefault());
}
Our buttons line up, but look at the lists. If one plan has a longer description, its list starts lower than the lists next to it, and so does its price. On a pricing page people compare across the row, Starter's price next to Pro's price, so rows that don't line up make comparing harder. The demo below has one long description on purpose. Press "Use subgrid" and watch the prices and lists snap into line.
<button type="button" class="switch" aria-pressed="false">Use subgrid</button>
<div class="plans">
<article class="card">
<h3>Starter</h3>
<p class="desc">For trying it out.</p>
<p class="price">$0</p>
<ul>
<li>3 projects</li>
<li>Community support</li>
</ul>
<a class="cta" href="#">Start free</a>
</article>
<article class="card">
<h3>Pro</h3>
<p class="desc">For one person who ships every week and needs a custom domain.</p>
<p class="price">$12</p>
<ul>
<li>Unlimited projects</li>
<li>Custom domain</li>
<li>Version history</li>
</ul>
<a class="cta" href="#">Choose Pro</a>
</article>
<article class="card">
<h3>Team</h3>
<p class="desc">For small teams.</p>
<p class="price">$30</p>
<ul>
<li>Everything in Pro</li>
<li>5 seats</li>
</ul>
<a class="cta" href="#">Choose Team</a>
</article>
</div>
* { box-sizing: border-box; }
body { margin: 0; padding: 1.5rem 1rem; font-family: system-ui, sans-serif; color: #1f2933; background: #f7f7f5; }
.switch { display: block; margin: 0 auto 1.5rem; font: inherit; padding: 0.5rem 1rem; border: 1px solid #1f2933; border-radius: 999px; background: #fff; color: #1f2933; cursor: pointer; }
.switch[aria-pressed="true"] { background: #1f2933; color: #fff; }
.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); gap: 1rem; max-width: 46rem; margin: 0 auto; }
.card { display: flex; flex-direction: column; background: #fff; border: 1px solid #d9dde1; border-radius: 12px; padding: 1.25rem; }
.card > * { margin: 0; }
.card h3 { font-size: 1.1rem; }
.desc { color: #52606d; }
.price { font-size: 2rem; font-weight: 700; }
.card ul { padding-left: 1.1rem; margin-bottom: 1rem; }
.cta { margin-top: auto; display: block; text-align: center; padding: 0.6rem; border-radius: 8px; background: #1f2933; color: #fff; text-decoration: none; }
/* The upgrade: every card shares the same five rows */
.plans.aligned .card { display: grid; grid-row: span 5; grid-template-rows: subgrid; }
const plans = document.querySelector(".plans");
const toggle = document.querySelector(".switch");
toggle.addEventListener("click", () => {
const on = plans.classList.toggle("aligned");
toggle.setAttribute("aria-pressed", String(on));
});
for (const link of document.querySelectorAll(".cta")) {
link.addEventListener("click", (event) => event.preventDefault());
}
.plans .card {
display: grid;
grid-row: span 5;
grid-template-rows: subgrid;
}- display: grid turns the card into a grid instead of a flex column.
- grid-row: span 5 makes each card occupy five rows of the outer grid, one row for each of its five children: name, description, price, list and button.
- grid-template-rows: subgrid is the key line. Instead of having its own rows, the card borrows the five rows of the outer grid. Since all three cards share the same five rows, each row is as tall as the tallest piece in it, across all cards. The long description makes row two taller for everyone, and every price lands on the same line.
This used to be impossible without JavaScript measuring heights. MDN marks subgrid as widely available and says it has worked across browsers since September 2023. Two things to watch. First, the number in span 5 must match the number of children in the card; if one plan has a badge as a sixth child, either take the badge out of the flow with position: absolute like we did, or count it. Second, the subgrid inherits the outer grid's gap between its rows, so the spacing inside the card is the same as the spacing between cards unless you set your own row-gap. In our demo that's fine. In a real design you may want to tune it.
Is it overkill for a beginner? Yes, a bit. The flex version with the auto margin is perfectly fine for most pages. Reach for subgrid when the rows really need to line up, like a pricing table with long feature lists that people read across.
Feature lists on pricing pages almost never show bullets. They show checkmarks. The usual move is list-style: none and a little icon. Here's a version that draws the check with plain CSS, no image, no icon font.
.card ul { list-style: none; padding: 0; }
.card li { position: relative; padding-left: 1.5rem; }
.card li::before {
content: "";
position: absolute;
left: 0.3rem;
top: 0.2em;
width: 0.4rem;
height: 0.75rem;
border: solid #0b6e4f;
border-width: 0 2px 2px 0;
rotate: 45deg;
}- list-style: none removes the bullets, and padding: 0 removes the space the browser kept for them.
- Each li gets position: relative, so the checkmark can be placed against its own corner, and padding-left makes room for it.
- ::before creates an extra, empty box in front of the text. content: "" is required; without content, the box doesn't exist at all.
- The box is narrow and tall, with only a right and a bottom border. That's an L shape. rotate: 45deg turns the L into a check. It's the same trick as drawing a tick with two strokes of a pen.
- Because content is empty, there's no character for a screen reader to read out. A checkmark character in content would be read aloud in some screen readers, on every single line.
Now the role="list" from earlier. Safari with VoiceOver stops treating a list as a list when CSS removes its bullets. Scott O'Hara has written up this behaviour, including that WebKit does it on purpose. Adding role="list" to the ul puts the list back for that combination, and it changes nothing anywhere else. On a pricing table, "list, 4 items" is useful to hear, because it tells you how much a plan includes before you listen to all of it.
One more thing about colour. I checked our colours with the standard WCAG contrast formula: white text on the green #0b6e4f comes out at 6.25 to 1, and the grey #52606d on white at 6.46 to 1. Both pass the 4.5 to 1 that normal text needs. Pricing pages love pale grey for the small print, and the small print is exactly the text people need to read.
Look back at what we built. A grid that decides on its own how many cards fit in a row. Cards that are flex columns, with a spring above the button. One card with a thicker border and a label sitting on its edge. Numbers that keep their width. A switch that is really two radio buttons, and ten lines of JavaScript that read cents from the HTML and print dollars.
That's it. No framework, no library, nothing you couldn't type in a plain HTML file today. Every pricing page you admire is some version of these moves, plus good colours and good words. When you can see the moves, you stop seeing a design you could never make, and start seeing a few decisions you can make yourself.
- The zoomed card. If you inherit a design with transform: scale on the featured plan, swap it for a border and extra padding. If the designer insists on the bigger look, give the featured card real extra padding so the layout makes room, rather than a zoom that the layout ignores.
- Fixed heights. Any height: 400px on a card breaks the day the text changes, or the day a visitor zooms their browser to 200%. Let content decide the height; use the auto margin or subgrid to line things up.
- Prices that exist only in JavaScript. If the cards are empty until a script fills them, a visitor on a slow connection sees blank boxes, and a search engine may see no prices at all. Write the default prices in the HTML and let JavaScript only change them.
- Trusting the page's price. The price on the page is a label. If your checkout takes the amount from the browser, someone will send a smaller one. The server looks up the plan and its price itself. This is the same rule as checking a login form twice, once in the browser for comfort and once on the server for real.
- Hiding the yearly total. Showing $10 a month without saying it's $120 paid at once is how people end up feeling tricked at checkout. The "billed" line costs one sentence and buys trust.
- Too many plans. Three columns fit the layout and the head. If you need five, the grid still works, but ask whether a visitor can really compare five things at once. That's a product question, not a CSS one, and it's worth asking.
- Copying a real company's page. Rebuilding the pattern to learn is fine. Shipping their logo, their wording, their illustrations or their exact colour scheme is not, and it also makes your product look like a copy.
The "check twice" idea is the core of why you validate a login form on the client and the server, and when you get to real payments, the Stripe setup guide for Next.js shows where the real price comes from.
info
If an AI assistant wrote your pricing table, check exactly the things in this article. Is the favourite card zoomed with a transform? Are there fixed heights? Is the switch a pile of divs that the keyboard can't reach? Are prices calculated with decimals? I use AI for a lot of my work, and I check what it writes as far as I can, because it can make these mistakes without meaning to. A pricing table is a good place to practise that kind of review, because every mistake is visible.
And for motion: the finished demo lifts a card by 4 pixels when you hover it, but only inside @media (prefers-reduced-motion: no-preference). People who asked their system for less motion get a still page. It's one wrapper around two lines, and it's the polite default for every hover effect you add.
Take this exact pattern and make it yours without looking at the demo code. Change it to four plans, add a long description to one of them, and check it on your phone. Then pick another piece of a website you use every day, a sign-up form, a product card, a page header that stays on top while you scroll, and rebuild it with your own text and colours. Write down the tricks you had to look up. That list is how you learn CSS for real. If you're starting from zero, build your first web page with HTML and CSS first, and if you want the bigger picture of where this fits, what web development is, from scratch connects HTML, CSS and JavaScript into one whole.
A real pricing page connected to real payments, accounts and plans is exactly the kind of thing a course builds step by step. For the blog, this is the concept: once you can see the four tricks, you can rebuild the page yourself.
You can do this, and you have to. If you weren't capable, I wouldn't spend my time on you. Just don't give up, and you'll always have me on your side.
