Type something to search...
How to Build a Responsive Pricing Table with CSS

How to Build a Responsive Pricing Table with CSS

The pricing page is where a visitor decides whether to become a customer, and the pricing table is the centerpiece of that page. It needs to present a handful of plans side by side, make the differences obvious, highlight the plan you'd like people to choose, and do all of that on a 27-inch monitor and a phone held in one hand.

In this tutorial we'll build a three-tier pricing table from scratch. We'll start with semantic HTML, lay the cards out with CSS Grid, use subgrid to line up rows across cards, add a featured plan, build a monthly/yearly toggle without JavaScript, and use container queries so the component adapts to the space it's placed in rather than just the viewport width.

What Makes a Good Pricing Table?

Before writing code, it helps to know what we're aiming for:

  • Scannable : Plan names, prices, and calls to action should line up horizontally so the eye can compare them in one sweep.
  • One clear recommendation : Most tables highlight a single plan. Highlighting two defeats the purpose.
  • Consistent structure : Every card has the same sections in the same order, even if a plan has fewer features.
  • Readable on mobile : On small screens the cards stack, and the featured plan should still stand out.
  • Accessible : Prices should be real text, feature lists should be real lists, and the "included" and "not included" markers must make sense without color.

The HTML Structure

Each plan is an <article> inside a list-like wrapper. Using a heading for the plan name gives screen reader users a quick way to jump between plans.

<section class="pricing" aria-labelledby="pricing-title">
  <h2 id="pricing-title" class="pricing__title">Simple, transparent pricing</h2>

  <div class="pricing__grid">
    <article class="plan">
      <h3 class="plan__name">Starter</h3>
      <p class="plan__desc">For individuals trying things out.</p>
      <p class="plan__price">
        <span class="plan__amount">$9</span
        ><span class="plan__period">/month</span>
      </p>
      <ul class="plan__features">
        <li>1 project</li>
        <li>5 GB storage</li>
        <li>Email support</li>
        <li class="is-excluded">Custom domains</li>
      </ul>
      <a class="plan__cta" href="/signup?plan=starter">Start free trial</a>
    </article>

    <article class="plan plan--featured">
      <p class="plan__badge">Most popular</p>
      <h3 class="plan__name">Pro</h3>
      <p class="plan__desc">For growing teams that ship often.</p>
      <p class="plan__price">
        <span class="plan__amount">$29</span
        ><span class="plan__period">/month</span>
      </p>
      <ul class="plan__features">
        <li>10 projects</li>
        <li>100 GB storage</li>
        <li>Priority support</li>
        <li>Custom domains</li>
      </ul>
      <a class="plan__cta" href="/signup?plan=pro">Start free trial</a>
    </article>

    <article class="plan">
      <h3 class="plan__name">Business</h3>
      <p class="plan__desc">For organizations with advanced needs.</p>
      <p class="plan__price">
        <span class="plan__amount">$79</span
        ><span class="plan__period">/month</span>
      </p>
      <ul class="plan__features">
        <li>Unlimited projects</li>
        <li>1 TB storage</li>
        <li>Dedicated manager</li>
        <li>SSO and audit logs</li>
      </ul>
      <a class="plan__cta" href="/contact-sales">Contact sales</a>
    </article>
  </div>
</section>

Note that the calls to action are links, because they navigate to another page. Use <button> only when the action happens on the current page.

Laying Out the Cards with Grid

We want the cards side by side on wide screens and stacked on narrow ones, without writing a media query for every breakpoint. The repeat(auto-fit, minmax()) pattern does exactly that:

.pricing {
  --gap: 1.5rem;
  --radius: 16px;
  --border: #e2e8f0;
  --accent: #4f46e5;
  --text: #0f172a;
  --muted: #64748b;

  max-width: 72rem;
  margin-inline: auto;
  padding: 4rem 1rem;
  color: var(--text);
  font-family: system-ui, sans-serif;
}

.pricing__title {
  text-align: center;
  font-size: clamp(1.75rem, 1.2rem + 2vw, 2.75rem);
  margin: 0 0 3rem;
}

.pricing__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
  gap: var(--gap);
  align-items: start;
}

Here's what that column definition says: fit as many columns as possible, each at least 18rem wide and sharing leftover space equally. The inner min(100%, 18rem) prevents overflow on very narrow screens where even 18rem is wider than the container.

Styling the Plan Cards

.plan {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 2rem;
  background: #fff;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.plan__name {
  margin: 0;
  font-size: 1.25rem;
}

.plan__desc {
  margin: 0;
  color: var(--muted);
}

.plan__price {
  display: flex;
  align-items: baseline;
  gap: 0.25rem;
  margin: 0.5rem 0;
}

.plan__amount {
  font-size: 3rem;
  font-weight: 800;
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

.plan__period {
  color: var(--muted);
}

align-items: baseline lines up the small "/month" text with the bottom of the large number instead of its vertical center, which is how prices are typically set. font-variant-numeric: tabular-nums gives every digit the same width, so prices that change (as they will with a billing toggle) don't cause the layout to shift.

Feature lists with accessible markers

.plan__features {
  display: grid;
  gap: 0.75rem;
  margin: 0;
  padding: 1.5rem 0 0;
  border-top: 1px solid var(--border);
  list-style: none;
}

.plan__features li {
  display: flex;
  gap: 0.6rem;
  align-items: start;
}

.plan__features li::before {
  content: "\2713" / "";
  flex-shrink: 0;
  color: #16a34a;
  font-weight: 700;
}

.plan__features li.is-excluded {
  color: var(--muted);
  text-decoration: line-through;
}

.plan__features li.is-excluded::before {
  content: "\2715" / "";
  color: #94a3b8;
}

The content: "..." / "" syntax provides alternative text for generated content. Here the alternative is empty, so screen readers skip the decorative check marks. That's supported in current browsers; older ones ignore the whole declaration, so you may want a plain content: "\2713"; line above it as a fallback.

Because the marker is decorative and the strikethrough is only visual, add hidden text to excluded items so the meaning isn't lost:

<li class="is-excluded">
  <span class="visually-hidden">Not included:</span> Custom domains
</li>

The call-to-action button

.plan__cta {
  margin-top: auto;
  display: block;
  padding: 0.85rem 1rem;
  text-align: center;
  font-weight: 600;
  text-decoration: none;
  color: var(--accent);
  border: 2px solid var(--accent);
  border-radius: 10px;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.plan__cta:hover {
  background: var(--accent);
  color: #fff;
}

.plan__cta:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

margin-top: auto inside a flex column pushes the button to the bottom of the card. If the grid used align-items: stretch so that all cards were the same height, the buttons would line up at the bottom even when feature lists have different lengths.

Aligning Rows Across Cards with Subgrid

Here's a problem you'll hit quickly: if one plan's description wraps to two lines, its price sits lower than the prices in the other cards. The table stops being scannable.

Subgrid fixes this. Each card becomes a grid that borrows its row tracks from the parent grid, so the name, description, price, features, and button rows line up across every card.

.pricing__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
  gap: var(--gap);
}

.plan {
  display: grid;
  grid-row: span 5;
  grid-template-rows: subgrid;
  row-gap: 1rem;
}

Each card spans five rows of the parent (name, description, price, features, button), and grid-template-rows: subgrid makes the card use those shared rows. Now, if the Business description is the longest, that row grows in every card, and all the prices stay aligned.

There's a catch: the featured card has a badge, and the others don't. Every card needs the same number of grid items in the same order for subgrid to line up, and an extra badge would push the Pro card's content down one row. You have two options. The first is to give every card a badge slot and hide the empty ones, then span six rows instead of five:

.plan__badge:empty {
  visibility: hidden;
}
<p class="plan__badge"></p>

The second, which we'll use in the next section, is to position the badge absolutely. Absolutely positioned children aren't grid items, so the card keeps its five rows.

Subgrid is supported in all current major browsers. In an older browser, the cards fall back to independent grids, which still look fine, just without cross-card alignment. You can make that explicit with a feature query:

@supports not (grid-template-rows: subgrid) {
  .plan {
    display: flex;
    flex-direction: column;
  }
}

Highlighting the Featured Plan

The featured plan should stand out through contrast, not just a different border color.

.plan--featured {
  border: 2px solid var(--accent);
  box-shadow: 0 20px 40px -12px rgb(79 70 229 / 0.35);
}

.plan--featured .plan__cta {
  background: var(--accent);
  color: #fff;
}

.plan--featured .plan__cta:hover {
  background: #4338ca;
  border-color: #4338ca;
}

.plan__badge {
  position: absolute;
  top: 0;
  left: 50%;
  translate: -50% -50%;
  margin: 0;
  padding: 0.3rem 0.9rem;
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #fff;
  background: var(--accent);
  border-radius: 999px;
  white-space: nowrap;
}

With position: absolute, the badge no longer takes part in the subgrid, so the featured card spans the same five rows as the others. The translate: -50% -50% centers the pill on the card's top border.

Some designs also scale the featured card up slightly. If you do, only apply it on wide layouts, and use the scale property rather than changing dimensions so the grid doesn't reflow:

@media (width >= 64rem) {
  .plan--featured {
    scale: 1.04;
    z-index: 1;
  }
}

A Monthly/Yearly Toggle Without JavaScript

Most pricing pages let visitors switch between monthly and annual billing. You can build that toggle with a checkbox and :has().

<div class="billing-toggle">
  <span>Monthly</span>
  <label class="switch">
    <input type="checkbox" id="billing-yearly" />
    <span class="visually-hidden">Bill yearly</span>
  </label>
  <span>Yearly <strong>save 20%</strong></span>
</div>

Then include both prices in each card:

<p class="plan__price">
  <span class="plan__amount price--monthly">$29</span>
  <span class="plan__amount price--yearly">$23</span>
  <span class="plan__period">/month</span>
</p>
.price--yearly {
  display: none;
}

.pricing:has(#billing-yearly:checked) .price--monthly {
  display: none;
}

.pricing:has(#billing-yearly:checked) .price--yearly {
  display: inline;
}

Style the switch itself with an appearance reset:

.switch input {
  appearance: none;
  width: 3rem;
  height: 1.6rem;
  margin: 0;
  border-radius: 999px;
  background: #cbd5e1;
  position: relative;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.switch input::before {
  content: "";
  position: absolute;
  top: 0.2rem;
  left: 0.2rem;
  width: 1.2rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #fff;
  transition: translate 0.2s ease;
}

.switch input:checked {
  background: var(--accent);
}

.switch input:checked::before {
  translate: 1.4rem 0;
}

.switch input:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

Put the .billing-toggle inside .pricing so the :has() selector can see the checkbox. For an extra accessibility touch, add role="switch" to the checkbox, which makes screen readers announce it as on or off.

Keep in mind that displaying both prices in the HTML means search engines and users with CSS disabled will see both. That's usually acceptable, and arguably more transparent.

Using Container Queries for True Reusability

The auto-fit grid handles the page-level layout, but the card's internal layout can also adapt. A pricing card placed in a narrow sidebar, for example, might want a smaller price.

.pricing__grid {
  container-type: inline-size;
}

@container (width < 40rem) {
  .plan {
    padding: 1.5rem;
  }

  .plan__amount {
    font-size: 2.25rem;
  }

  .plan--featured {
    order: -1;
  }
}

With order: -1, the featured plan moves to the top when the cards stack in a narrow container, so mobile visitors see your recommended plan first. Changing visual order doesn't change the DOM order, so keyboard and screen reader users still move through plans in source order. That's a reasonable trade-off for a single promoted card, but don't reorder everything.

Size container queries are supported in all current major browsers. If you need a fallback, the default styles already work; the container query only refines them.

Dark Mode

Because every color lives in a custom property on .pricing, dark mode is a short override:

@media (prefers-color-scheme: dark) {
  .pricing {
    --border: #334155;
    --text: #f1f5f9;
    --muted: #94a3b8;
    --accent: #818cf8;
  }

  .plan {
    background: #111827;
  }
}

Common Pitfalls

  1. Prices as images : Screen readers can't read them, translation tools can't convert them, and they blur on high-density screens. Always use text.
  2. Mismatched card structure : Subgrid alignment depends on every card having the same children in the same order. An optional element in one card throws off the rows.
  3. Relying on color alone : A green check and a gray X mean nothing to someone who can't distinguish those colors. Add strikethrough, text, or both.
  4. Too many plans : Beyond four columns, cards get cramped on laptops. Consider a comparison table below the cards for detailed feature breakdowns.
  5. Buttons that all look the same : If every call to action is a solid button, none stands out. Use a solid button for the featured plan and outline buttons for the others.

Conclusion

A responsive pricing table is a great exercise in modern CSS layout. repeat(auto-fit, minmax()) handles the column count, subgrid lines up prices and buttons across cards, :has() powers a JavaScript-free billing toggle, and container queries let the component adapt wherever it's placed.

Get the fundamentals right, with semantic HTML, clear hierarchy, one highlighted plan, and markers that don't rely on color alone, and the pricing table does its job: helping visitors choose the right plan quickly and confidently.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading