Feature comparison table
A plan comparison with a sticky header row and a highlighted column.
<div class="wrap">
<table class="compare">
<caption>What is included in each plan</caption>
<colgroup>
<col><col><col class="best"><col>
</colgroup>
<thead>
<tr>
<td></td>
<th scope="col">Starter<span>Free</span></th>
<th scope="col">Studio<span>£24/mo</span><b class="flag">Recommended</b></th>
<th scope="col">Agency<span>£79/mo</span></th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Projects</th><td>1</td><td>10</td><td>Unlimited</td></tr>
<tr><th scope="row">Requests a month</th><td>10,000</td><td>500,000</td><td>5,000,000</td></tr>
<tr><th scope="row">Custom domains</th>
<td><span class="no" role="img" aria-label="Not included">✕</span></td>
<td><span class="yes" role="img" aria-label="Included">✓</span></td>
<td><span class="yes" role="img" aria-label="Included">✓</span></td></tr>
<tr><th scope="row">Team members</th><td>1</td><td>5</td><td>Unlimited</td></tr>
<tr><th scope="row">Support</th><td>Community</td><td>Email, same day</td><td>Priority</td></tr>
<tr><th scope="row">Single sign-on</th>
<td><span class="no" role="img" aria-label="Not included">✕</span></td>
<td><span class="no" role="img" aria-label="Not included">✕</span></td>
<td><span class="yes" role="img" aria-label="Included">✓</span></td></tr>
<tr><th scope="row">Invoiced billing</th>
<td><span class="no" role="img" aria-label="Not included">✕</span></td>
<td><span class="no" role="img" aria-label="Not included">✕</span></td>
<td><span class="yes" role="img" aria-label="Included">✓</span></td></tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td><a class="btn ghost" href="#">Start free</a></td>
<td><a class="btn" href="#">Choose Studio</a></td>
<td><a class="btn ghost" href="#">Choose Agency</a></td>
</tr>
</tfoot>
</table>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 20px; background: #fff; }
.wrap { overflow-x: auto; }
.compare {
width: 100%;
min-width: 620px;
border-collapse: separate; /* sticky needs separate, not collapse */
border-spacing: 0;
font-size: .92rem;
}
caption { text-align: left; font-size: .84rem; color: #8593ab; padding-bottom: 12px; }
.compare th, .compare td {
padding: 13px 18px;
text-align: center;
border-bottom: 1px solid #eef1f6;
}
.compare tbody th, .compare thead td { text-align: left; }
/* ---- header ---- */
.compare thead th {
position: sticky;
top: 0;
z-index: 2;
background: #fff;
border-bottom: 2px solid #e2e7ef;
font-size: 1rem;
color: #16202e;
vertical-align: top;
padding-top: 18px;
}
.compare thead th span {
display: block;
font-size: .82rem;
font-weight: 400;
color: #8593ab;
margin-top: 2px;
}
.flag {
display: inline-block;
margin-top: 8px;
font-size: .68rem;
font-weight: 500;
padding: 3px 9px;
border-radius: 999px;
background: #3b4fe4;
color: #fff;
}
/* ---- the highlighted column, drawn with colgroup ---- */
col.best { background: #f7f8fe; }
.compare tbody th { font-weight: 500; color: #16202e; }
.compare tbody td { color: #5b6b83; }
.compare tbody tr:hover td, .compare tbody tr:hover th { background: rgba(246, 248, 251, .6); }
.yes { color: #1f8a5b; font-size: 1.05rem; }
.no { color: #c0c9d6; font-size: 1.05rem; }
/* ---- footer ---- */
tfoot td { border-bottom: 0; padding-top: 18px; }
.btn {
display: inline-block;
font-size: .88rem;
font-weight: 500;
text-decoration: none;
padding: 9px 18px;
border-radius: 8px;
background: #3b4fe4;
color: #fff;
border: 1px solid #3b4fe4;
white-space: nowrap;
}
.btn:hover { background: #2a3abf; border-color: #2a3abf; }
.btn.ghost { background: #fff; color: #16202e; border-color: #d9e0ea; }
.btn.ghost:hover { background: #f6f8fb; }How it works
colgroup does the column highlight. One <col class="best"> gives the whole column a background, with no class on any individual cell. It is one of the few things <col> can style — background, border, width and visibility — and this is exactly the case it exists for.
border-collapse: separate is required for sticky headers. With collapse, borders belong to the table rather than the cells, and sticky cells lose them when they detach. Using separate with border-spacing: 0 looks identical and keeps the borders attached.
The top-left cell is a td, not a th. It is empty and labels nothing. Making it a header tells a screen reader it describes either the row or the column, which it does not.
Ticks and crosses need labels. A bare ✓ is announced inconsistently — sometimes as "check mark", sometimes as nothing at all. role="img" with an aria-label makes it announce "Included" reliably, which is the actual information.
min-width plus an overflow wrapper. Below 620 pixels the table scrolls sideways rather than compressing to unreadable columns. For a comparison table this is the right call — the whole point is seeing columns side by side, so stacking them defeats it.
Accessibility notes
scope="col" on the plan names and scope="row" on the feature names is what lets a screen reader announce "Custom domains, Studio, Included" when the user lands on a cell. Without scope, they hear "Included" with no context at all.
The scrolling wrapper should take tabindex="0" and an aria-label in production, so keyboard users can scroll it without a mouse. It is omitted here to keep the markup readable.
Never signal availability with colour alone. The tick and the cross differ in shape as well as colour, and both carry a text label underneath.
Making it yours
For more than four plans, a comparison table stops working and a filtered feature list serves better. Three or four is the range where side-by-side comparison actually helps someone decide.
Group long feature lists with <tbody> sections and a spanning header row per group: <tr><th colspan="4" scope="colgroup">Security</th></tr>.
The pricing table template is the card-based alternative, which works better on mobile and worse for detailed comparison. Many sites use both: cards at the top, this table further down.
Related templates
Responsive data table
A real table that stays readable at 390px, using a label-per-cell stack.
HTMLCSSSortable table
Click a column to sort, with aria-sort announced and no library.
HTMLCSSJavaScriptHTML email boilerplate
A table-based email shell that survives Outlook and Gmail.
HTMLPricing table
Three tiers with a highlighted plan and a monthly-to-yearly toggle.
HTMLCSSJavaScriptCheck 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.