How to Design a Comparison Table for a Website: Layout, Mobile Patterns, and CSS Tips

by | Sep 11, 2026 | Uncategorized | 0 comments

Almost every website eventually needs one: a pricing grid, a feature matrix, a product spec sheet. And almost every one of them breaks the moment someone opens it on a phone. Columns squash into unreadable slivers, the header row scrolls away so nobody knows which plan they are looking at, and the "most popular" badge ends up floating in the middle of nowhere.

This guide covers comparison table design from a builder’s point of view: the layout decisions that come first, the four mobile patterns that actually work, the CSS you can copy, and the semantic markup that keeps the table usable for screen readers. Everything here is tested against real patterns used by SaaS pricing pages and e-commerce spec sheets.

Why comparison tables fail on small screens

A comparison table is a two-dimensional object squeezed into a one-dimensional viewport. On a 1440px desktop screen, five columns and thirty rows feel comfortable. On a 390px phone, that same table is roughly four times wider than the screen. Designers then reach for the wrong fixes:

  • Shrinking the font to 10px so everything fits. Nobody reads it.
  • Hiding columns with display: none, which removes the whole point of comparing.
  • Turning the table into images, which kills text selection, search, translation and accessibility.
  • Letting the page scroll horizontally, so the entire layout drifts sideways instead of just the table.

The fix is not one magic CSS rule. It is a decision about which comparison the user is actually making, then choosing the pattern that supports it.

pricing table design

Step 1: Decide what is being compared before you design anything

Two very different jobs hide behind the same visual component:

Plan comparison (few columns, many rows)

Typical SaaS pricing: 3 to 5 plans, 15 to 60 feature rows. The user scans vertically down one column, then jumps sideways to check a specific feature. Sticky headers matter enormously here.

Product comparison (many columns, moderate rows)

E-commerce spec sheets: laptops, mattresses, cameras. The user adds and removes items, and the row labels are the constant. A sticky first column plus horizontal scroll is usually the right answer.

Us vs. them comparison (2 to 4 columns, short rows)

Marketing pages that pit your product against competitors. Rows are short, tone is persuasive, and the table often collapses gracefully into two stacked cards.

Rule of thumb: if you cannot describe in one sentence which comparison the visitor is trying to make, the table will be cluttered no matter how good the CSS is.

Step 2: Layout fundamentals that make a table scannable

Before any responsive trick, get the desktop version right. A messy table does not become clean by stacking it.

  • Row labels on the left, options across the top. This is the convention users expect. Do not invert it to look clever.
  • Group rows into labelled sections (Storage, Security, Support). A 40-row flat list is a wall. Six groups of 6 to 8 rows is a map.
  • Keep values consistent. If one cell says "Unlimited" and another says "No cap", the user has to translate. Use one vocabulary per row.
  • Use the same unit and format in a row. 50 GB, 200 GB, 1 TB is fine. 50 GB, 200000 MB, 1 TB is not.
  • Zebra striping or row hover, not both. Subtle horizontal rules usually beat heavy fills.
  • Right-align numbers, left-align text. Numbers compare faster when the digits line up.
  • Repeat the call to action. Put a button under each column header and again at the bottom of long tables.
  • Avoid empty cells. An empty cell is ambiguous. Use a dash, "Not included", or an icon with a text label.

Checkmarks, crosses and text values

Icons compress space but carry risk. A green check and a grey cross look almost identical to users with certain colour deficiencies, and screen readers announce nothing if the icon is a background image. If you use icons:

  • Pair shape with colour (a check and a cross, not two coloured dots).
  • Add visually hidden text: <span class="sr-only">Included</span>.
  • Never use an icon where a number would be more useful. "Check" tells the user less than "10 seats".

Step 3: Semantic markup comes first

Every responsive pattern below depends on starting with a real <table>. Divs with display: table-cell look identical and are useless to assistive technology.

<div class="table-wrap" role="region" aria-labelledby="plans-caption" tabindex="0">
  <table class="compare">
    <caption id="plans-caption">Compare hosting plans: features and monthly price</caption>
    <colgroup>
      <col class="col-label">
      <col>
      <col class="is-featured">
      <col>
    </colgroup>
    <thead>
      <tr>
        <td></td>
        <th scope="col">Starter</th>
        <th scope="col">Business <span class="badge">Recommended</span></th>
        <th scope="col">Agency</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Storage</th>
        <td data-label="Starter">25 GB</td>
        <td data-label="Business">100 GB</td>
        <td data-label="Agency">500 GB</td>
      </tr>
    </tbody>
  </table>
</div>

Four details in that snippet do most of the accessibility work:

  1. <caption> gives the table a name. Screen reader users can list tables on a page and pick one.
  2. scope="col" and scope="row" tell assistive tech which header belongs to which cell.
  3. The wrapper with role="region" and tabindex="0" makes a horizontally scrollable table reachable by keyboard.
  4. data-label attributes are unused on desktop but power the mobile stacking pattern later.

Do not use the deprecated attributes border, cellpadding or cellspacing. All of that belongs in CSS now.

pricing table design

Step 4: Four mobile patterns, and when to use each

Pattern Best for Trade-off
Horizontal scroll + sticky first column Product specs, 4+ columns, side-by-side comparison is essential Users may not notice the scroll unless you signal it
Column stacking (cards) Pricing plans, 3 or fewer columns, decision is mostly vertical Direct comparison is lost; page gets very long
Column selector (2-up toggle) Long feature matrices where users compare two options at a time Requires JavaScript and clear controls
Collapsible feature groups 30+ rows with clear categories Hidden content is skipped by many users

Pattern 1: Horizontal scroll with a sticky label column

The most reliable option, because the table stays a table. The row labels pin to the left edge while the value columns slide underneath. It is argued more carefully on magnific.com.

.table-wrap {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
}

.compare {
  border-collapse: separate; /* sticky needs separate borders */
  border-spacing: 0;
  min-width: 46rem;
  width: 100%;
}

.compare th,
.compare td {
  padding: 0.85rem 1rem;
  border-bottom: 1px solid #e5e7eb;
  scroll-snap-align: start;
}

/* pin the row label column */
.compare th[scope="row"] {
  position: sticky;
  left: 0;
  z-index: 2;
  background: #fff;
  text-align: left;
  box-shadow: 1px 0 0 #e5e7eb;
}

Two things people forget:

  • Sticky cells need a background colour. Without it, the scrolling content shows through.
  • Signal the scroll. Cut the last visible column in half at the viewport edge, or add a soft gradient shadow on the right so it is obvious there is more.
.table-wrap {
  background:
    linear-gradient(to right, #fff 30%, rgba(255,255,255,0)) left / 3rem 100% no-repeat,
    linear-gradient(to left, #fff 30%, rgba(255,255,255,0)) right / 3rem 100% no-repeat;
  background-attachment: local, local;
}

Pattern 2: Sticky header row for long tables

On a 40-row pricing matrix, the plan names disappear after two thumb swipes. Pin them.

.compare thead th {
  position: sticky;
  top: 0;
  z-index: 3;
  background: #fff;
}

/* if you have a fixed site header, offset it */
.compare thead th { top: var(--site-header-height, 0px); }

/* the corner cell must sit above both */
.compare thead th:first-child,
.compare thead td:first-child {
  left: 0;
  z-index: 4;
}

Sticky killer to watch for: position: sticky silently stops working if any ancestor has overflow: hidden, overflow: clip or overflow: auto on the relevant axis. If your sticky header will not stick, walk up the DOM and find the offending overflow rule first.

On mobile, a full sticky header eats vertical space. A common compromise is to shrink it after scroll: show only the plan name and price, drop the buttons and descriptions.

@media (max-width: 40rem) {
  .compare thead .plan-desc,
  .compare thead .plan-cta { display: none; }
  .compare thead th { padding-block: 0.5rem; font-size: 0.875rem; }
}

Pattern 3: Stacking columns into cards

This is the pattern most people mean when they say "responsive table". Each column becomes a card, and each cell shows its own label.

@media (max-width: 40rem) {
  .compare, .compare tbody, .compare tr, .compare td { display: block; width: 100%; }

  .compare thead {
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .compare tr {
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    margin-bottom: 1rem;
    padding: 0.5rem 0.75rem;
  }

  .compare th[scope="row"] {
    display: block;
    font-size: 1rem;
    padding: 0.5rem 0;
  }

  .compare td {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.4rem 0;
    border-bottom: 1px dashed #eee;
  }

  .compare td::before {
    content: attr(data-label);
    font-weight: 600;
    color: #4b5563;
  }
}

Important warning: setting display: block on table elements removes the table semantics in most browsers, so screen readers no longer announce header and cell relationships. If you use this pattern, either:

  • keep the data-label text visible so the information is in the DOM for everyone, or
  • restore roles explicitly (role="table", role="row", role="cell"), or
  • prefer Pattern 1, which keeps the table intact.

For most SaaS pricing pages with three plans, stacking works well, because on mobile people rarely compare cell by cell. They scroll, find the plan that matches their situation, and tap.

Pattern 4: The two-column selector

Used by phone retailers and mattress brands: on mobile, the user picks two items from dropdowns and only those two columns render. It is the best user experience for long spec sheets but it needs JavaScript and careful state handling. Keep the full table in the DOM and toggle a class rather than rebuilding the markup, so search engines and non-JS users still get everything.

Bonus: use container queries instead of media queries

Since a comparison table can appear in a full-width section or a narrow sidebar, the viewport width is the wrong thing to test. Container queries are supported across all current browsers and are the better tool in 2026.

.table-wrap {
  container-type: inline-size;
  container-name: comparetable;
}

@container comparetable (max-width: 40rem) {
  /* stacking rules go here */
}

Step 5: Highlighting the recommended plan without shouting

Highlighting one column raises conversions when it is honest and readable. It backfires when the highlight fights the content. A comparable breakdown sits on webflow.com.

  • Use a full-height column treatment, not just a coloured header. The eye needs to follow the column down.
  • Keep contrast in check. A tinted background must still pass 4.5:1 contrast with the text on top of it.
  • The badge needs real text ("Most popular", "Best value"), not just a colour.
  • Do not make the featured column taller on desktop if it breaks row alignment. Misaligned rows destroy comparability.
  • On mobile, move the highlighted plan first in the stacking order rather than leaving it in the middle.
/* highlight an entire column using :nth-child */
.compare tbody td:nth-child(3),
.compare thead th:nth-child(3) {
  background: #f2f7ff;
  box-shadow: inset 2px 0 0 #1d4ed8, inset -2px 0 0 #1d4ed8;
}

.compare thead th:nth-child(3) {
  border-top-left-radius: 12px;
  border-top-right-radius: 12px;
  box-shadow: inset 2px 2px 0 #1d4ed8, inset -2px 0 0 #1d4ed8;
}

.badge {
  display: inline-block;
  background: #1d4ed8;
  color: #fff;
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 0.2rem 0.55rem;
  border-radius: 999px;
}

/* reorder on mobile so the featured plan comes first */
@media (max-width: 40rem) {
  .compare tbody { display: flex; flex-direction: column; }
  .compare tbody tr.is-featured { order: -1; }
}

If you support column highlighting on hover, :has() makes it trivial without JavaScript:

.compare:has(td:nth-child(2):hover) td:nth-child(2) { background: #fafafa; }

Step 6: Real-world examples worth studying

SaaS pricing pages

  • Short summary cards first, full matrix below. Most well-converting pricing pages show 3 or 4 plan cards at the top with the five headline differences, then a full feature table further down for people who need detail. This gives 80% of visitors an answer without scrolling a 50-row grid.
  • Sticky mini header on scroll. Once the plan cards scroll off, a compact bar with plan name, price and CTA pins to the top. It is the single highest-value addition to a long feature matrix.
  • Feature groups with anchor links. "Jump to: Security, Integrations, Support" above the table cuts perceived length dramatically.
  • Monthly and annual toggle. Update the price cells only, never reload the table.

E-commerce spec sheets

  • Sticky product images plus names at the top of the comparison, so the user always knows which column is which item.
  • "Hide identical rows" toggle. When four laptops share the same port layout, that row adds nothing. Letting shoppers hide matching rows shortens a 60-row table to 15 meaningful differences.
  • Remove buttons per column. Comparison is iterative: users add four items and cut down to two.
  • Add to cart in the sticky header, so the decision can be acted on from anywhere in the table.

Us vs. competitor tables

  • Keep it to three or four rows that genuinely matter. A 20-row table where you win every row reads as marketing noise.
  • Do not leave competitor columns entirely empty. It looks dishonest and readers notice.
  • Date your claims ("Pricing compared August 2026") so the table stays credible.
pricing table design

Accessibility checklist

  1. Real <table> markup with <thead>, <tbody> and <th>.
  2. scope on every header cell; headers and id for complex tables with merged cells.
  3. A <caption> that describes the table, not just "Table 1".
  4. Scroll containers focusable with tabindex="0" and labelled with role="region" plus aria-labelledby.
  5. Icon-only cells always paired with visually hidden text.
  6. Text contrast of at least 4.5:1, including inside highlighted columns.
  7. Touch targets of 44 x 44 CSS pixels minimum for buttons and toggles inside cells.
  8. No information conveyed by colour alone.
  9. Collapsible groups built with <details>/<summary> or proper aria-expanded buttons.
  10. Test with keyboard only: you should reach every CTA and be able to scroll the table with arrow keys.

Performance and SEO notes

  • Keep the table in HTML. Client-rendered tables that appear after hydration can be missed or delayed in indexing, and they hurt Interaction to Next Paint if the JavaScript is heavy.
  • Avoid layout shift. Reserve column widths with <colgroup> or table-layout: fixed so the table does not reflow after fonts load.
  • Do not put the whole table in an image. You lose text indexing and every accessibility benefit.
  • Use descriptive text near the table. A short paragraph explaining what the table compares gives search engines context that a grid of numbers cannot.
  • Consider Product structured data for e-commerce comparisons, applied to each product, not to the table itself.
  • Lazy-load anything heavy inside cells (logos, thumbnails) with loading="lazy" and explicit dimensions.

Quick reference: CSS properties that do the heavy lifting

Property What it solves Gotcha
position: sticky Pinned header row and label column Broken by ancestor overflow: hidden; needs a background
table-layout: fixed Predictable, equal column widths Long unbroken strings overflow; add overflow-wrap: anywhere
border-collapse: separate Required for sticky cells to keep borders Use box-shadow instead of borders on pinned cells
container-type: inline-size Responsive behaviour based on the table’s own width Establishes containment, so absolutely positioned children shift
scroll-snap-type: x Columns snap neatly on swipe Use proximity, not mandatory, or scrolling feels sticky
content: attr(data-label) Column labels in stacked card mode Generated content is not reliably read by all screen readers
pricing table design

Testing checklist before you ship

  1. Open the page at 320px width. Does the page scroll sideways, or only the table?
  2. Scroll to the middle of the table. Can you still tell which plan each column is?
  3. Zoom the browser to 200%. Does anything overlap or get clipped?
  4. Tab through with a keyboard. Can you reach and activate every button?
  5. Turn off CSS entirely. Does the raw table still make sense top to bottom?
  6. Print the page. Many B2B buyers print or export pricing to share internally.
  7. Check the table with a screen reader (VoiceOver rotor or NVDA table navigation).
  8. Simulate slow 4G. Is the table visible before the JavaScript loads?

Common mistakes we see on client sites

  • Six plans in one table. Above four columns, comprehension drops sharply. Split into two tables or introduce a filter.
  • Jargon in row labels. "Multi-tenant isolation" means nothing to most visitors. Write it the way a customer would say it, and add a tooltip for detail.
  • Tooltips only on hover. There is no hover on touch devices. Trigger tooltips on click or focus too.
  • No price in the sticky header. Price is the number people re-check most.
  • All rows visible, no grouping. Sixty ungrouped rows is a bounce.
  • Different row order between mobile and desktop. Confusing for anyone switching devices.
  • Fake urgency in the featured column. If everything is "Best value", nothing is.

FAQ

How do I create a comparison table for a website?

Start with a semantic HTML <table>: put the options in <th scope="col"> across the header row and the attributes in <th scope="row"> down the first column. Add a <caption>, group related rows into sections, style with CSS instead of deprecated table attributes, then add a scrollable wrapper with a sticky label column so it works on mobile. If you use a CMS, prefer a block or plugin that outputs real table markup rather than one that renders images or nested divs.

What is a good comparison table example?

A SaaS pricing page with three or four plans, a highlighted recommended column, grouped feature sections, a sticky header carrying the plan name, price and CTA, and consistent values in every row. On e-commerce, a good example is a spec comparison with sticky product names, a "hide identical rows" toggle and a remove button per column.

How many columns should a comparison table have?

Three to four option columns is the sweet spot on desktop. Beyond five, users lose track and horizontal scrolling becomes constant. On mobile, show two options at a time or stack columns as cards.

Should I stack the table or let it scroll horizontally on mobile?

Scroll horizontally when the side-by-side comparison is the point, for example product specs. Stack into cards when each option can be evaluated on its own, for example three pricing plans. Stacking is longer but easier to read; scrolling preserves comparison but must be signalled clearly with a cut-off column or a gradient shadow.

Why is my sticky table header not working?

Nine times out of ten an ancestor element has overflow: hidden, overflow: clip or overflow: auto, which cancels sticky positioning. Other causes: no top value set, the parent being shorter than the sticky element, or border-collapse: collapse stripping borders from the sticky cells. Also give sticky cells an explicit background colour and a z-index.

Are comparison tables good for SEO?

Yes, when they are real HTML. Tables give search engines structured, crawlable text that answers comparison-style queries, and they keep visitors on the page longer. They hurt when they are rendered as images, injected only by JavaScript, or so heavy they slow down the page.

What is the difference between a comparison table and a pricing table?

A pricing table presents plans as separate cards with their own feature lists and is optimised for choosing quickly. A comparison table aligns every option against the same set of rows so differences are visible at a glance. Many pages use both: pricing cards at the top, full comparison matrix below.

Wrapping up

Good comparison table design is mostly restraint plus a handful of dependable CSS patterns. Decide which comparison the visitor is making, keep the markup semantic, pick one mobile pattern instead of half-implementing three, pin the context that people lose while scrolling, and highlight the recommended option honestly. Do that, and the table stops being the part of the page that breaks on phones and starts being the part that closes the sale. It is argued more carefully on nngroup.com.

Need help rebuilding a pricing or product comparison that is not converting on mobile? The team at FatCow Web Design builds accessible, fast-loading comparison components that work on every screen size. Get in touch and we will audit your existing table for free.

Search Keywords

Recent Posts

Subscribe Now!