A plans page that explains itself
Readers reach a plans page to settle two things without writing to anyone: which plan fits them, and what happens if they change their mind. Most of the work is not visual. It is deciding what the plans are called, what differs between them, which limits matter and what the reader needs to know before they join or leave.
This article covers structure, wording and the accessibility of the comparison table. It leaves amounts out on purpose: those are a separate decision, and the structure below holds whatever they turn out to be. The plan names and limits in the examples are fictional.
Answer five questions, in this order
Readers arrive with the same questions, and the page works when it answers them in the order they occur.
- Which plan is for someone like me?
- What exactly do I get?
- What are the limits, and what happens when I reach one?
- Is there a free option, and what ends it?
- How do I leave, and what happens to my work?
A page that answers only the second question in detail leaves the rest to guesswork. Treat each question as a section with a heading, and put a short summary of the first above the table so a reader can decide before comparing.
Name plans for the person, not the rank
A plan name is a label the reader must remember while comparing, and it should carry meaning on its own.
| Naming approach | Example (fictional) | What it tells the reader | Weakness |
|---|---|---|---|
| By size or scale | Solo, Team, Organisation | Which plan matches their situation | Boundaries must be stated, since "team" means different things |
| By role | Starter, Studio, Agency | Who the plan was designed for | Works only if a one-line description backs it up |
| By rank | Basic, Plus, Max | Only that one is bigger | The reader has to open the table to learn anything |
Whichever you choose, give each plan a "for" line that names a situation: "For one person running a single site." The line should be something the reader could say about themselves. Two or three capabilities that distinguish the plan from its neighbours can sit under it. If you cannot write the "for" line without using the plan's own name, the plan has no clear audience yet.
If one plan is the usual choice, label it in words, such as "Recommended for small teams". A highlighted card with no text relies on colour alone, which WCAG 1.4.1 rules out as the only visual means of conveying information.
Decide what belongs in the table
Compare the plans on rows where they differ, and move the rest out. A row that reads the same in every column makes the table longer without helping the decision, so put it in an "Included in every plan" list below. Rows fall into two kinds:
- Limits: how many sites, members, projects or how much storage. State the quantity, the unit and the period if there is one.
- Capabilities: custom domains, exports, support channels, integrations. State what the capability is, in the words the product uses on its own screens.
For every limit, say what happens at the edge. "5 sites" leaves the reader guessing whether site six is blocked, whether the others stop, or whether something is quietly switched off. "5 sites. To add a sixth you need a larger plan; existing sites keep working" answers it. If a limit is soft or reviewed case by case, say so, because a hidden rule found after joining is a bigger problem than a strict one stated up front.
Group rows under short headings (Limits, Features, Support) and keep each label identical to the wording in the product.
Mark up the table so it can be read without seeing it
The W3C tutorial on tables says header cells must be marked up with th and data cells with td, and that explicit scope values help when the direction of a header is unclear. A screen reader announces the row and column headers as the reader moves between cells, so "Studio, Custom domain, Included" can be heard from a cell that only contains a mark. The tutorial also recommends a caption, which functions like a heading and helps a reader find and understand a table. A plans table has two header axes: plan names across the top and features down the side.
Following the tutorial's two-header pattern, the corner cell is an empty td, the plan names are th scope="col", and the row labels are th scope="row". The marks carry text, in a visually hidden span, and the mark itself is hidden from assistive technology:
<table class="plans">
<caption>What each plan includes. Limits are per account.</caption>
<thead>
<tr>
<td></td>
<th scope="col">Starter <span class="note">(free)</span></th>
<th scope="col">Studio <span class="note">(recommended for small teams)</span></th>
<th scope="col">Agency</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Sites</th><td>1</td><td>5</td><td>25</td></tr>
<tr><th scope="row">Team members</th><td>1</td><td>3</td><td>15</td></tr>
<tr><th scope="row">Custom domain</th>
<td><span aria-hidden="true">-</span><span class="sr-only">Not included</span></td>
<td><span aria-hidden="true">✓</span><span class="sr-only">Included</span></td>
<td><span aria-hidden="true">✓</span><span class="sr-only">Included</span></td></tr>
<tr><th scope="row">Support</th><td>Community forum</td><td>Email</td><td>Email and a scheduled call</td></tr>
</tbody>
</table>
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
The "(recommended for small teams)" text is part of the heading cell, so the recommendation is spoken as well as shown. A cell that holds only a check mark or an empty image says nothing to a screen reader, and a reader who cannot tell a tick from a dash by colour or shape loses the answer.
Check the structure with a script
The script below parses a table with the node-html-parser package and checks four things: that a caption exists, that column headers exist, that each body row starts with a row header, and that every data cell has some text a screen reader can say once hidden marks are removed.
import { readFileSync } from 'node:fs';
import { parse } from 'node-html-parser';
const root = parse(readFileSync(process.argv[2], 'utf8'));
const table = root.querySelector('table');
const problems = [];
if (!table.querySelector('caption')) problems.push('no <caption>');
const colHeads = table.querySelectorAll('thead th[scope="col"]');
if (!colHeads.length) problems.push('no <th scope="col"> in the header row');
for (const [r, tr] of table.querySelectorAll('tbody tr').entries()) {
const cells = tr.childNodes.filter((n) => n.tagName === 'TD' || n.tagName === 'TH');
const rowHead = cells.find((c) => c.tagName === 'TH' && c.getAttribute('scope') === 'row');
if (!rowHead) problems.push('row ' + (r + 1) + ': first cell is not <th scope="row">');
cells.forEach((cell, i) => {
if (cell.tagName !== 'TD') return;
const copy = parse(cell.outerHTML);
copy.querySelectorAll('[aria-hidden="true"]').forEach((n) => n.remove());
const spoken = copy.text.trim();
const label = rowHead ? rowHead.text.trim() : 'row ' + (r + 1);
if (!spoken) problems.push(label + ', column ' + (i + 1) + ': nothing a screen reader can say');
});
}
console.log(table.querySelectorAll('tbody td').length + ' data cells checked');
console.log(problems.length ? problems.join('\n') : 'ok');
On the table above it printed:
12 data cells checked
ok
On a version with no caption, plain td cells for headers and tick images with empty alt text, it printed:
8 data cells checked
no <caption>
no <th scope="col"> in the header row
row 1: first cell is not <th scope="row">
row 2: first cell is not <th scope="row">
row 2, column 2: nothing a screen reader can say
row 2, column 3: nothing a screen reader can say
row 2, column 4: nothing a screen reader can say
This catches structure only. It cannot judge wording, so a person still has to read the table with a screen reader or at least with the keyboard.
Contrast and the greyed-out row
A common way to show a feature is missing is to grey the text. WCAG 1.4.3 requires a contrast ratio of at least 4.5:1 for text, and 3:1 for large-scale text. A cell in a table is content, not a disabled control, so the requirement reads as applying to it. These ratios were computed with the WCAG relative luminance formula for grey text on a plain white ground and on a light grey band:
| Text colour | Background | Ratio | Meets 4.5:1? |
|---|---|---|---|
| #999999 | #ffffff | 2.85:1 | No |
| #767676 | #ffffff | 4.54:1 | Yes, narrowly |
| #6b7280 | #f3f4f6 | 4.39:1 | No |
| #4b5563 | #f3f4f6 | 6.87:1 | Yes |
The second and third rows are the useful ones: a grey that passes on white can fail on the tinted band behind a highlighted column. Check each colour against the background it sits on. Note also that the ratio must meet the threshold without rounding; the Understanding page says 4.499:1 does not meet 4.5:1. For more on contrast, see dark interfaces that stay readable.
Narrow screens
WCAG 1.4.10 asks for content to be usable at 320 CSS pixels wide without scrolling in two directions, and it lists data tables among the content that may need two-dimensional layout. The Understanding page adds that a table in its own scrollable container lets the other content reflow. The paragraphs around the table must still reflow, and the cells inside it must still meet the other criteria. Two workable designs follow:
- Keep one table in a horizontally scrollable container, with the plan names and row labels still in view as far as the layout allows. Check that a keyboard user can reach and scroll it.
- Under a breakpoint, replace the table with one section per plan, each listing the same rows with the same wording. The content must match the table exactly.
The free option
Say which of two things it is. A free plan has no end date and has limits; a trial ends on a date and then asks for a decision. Write the answers a hesitant reader looks for:
- What the free option includes and what limit ends it.
- Whether it has a time limit, and the date or length if so.
- Whether payment details are requested before it starts.
- What happens to the reader's work when it ends or when they move to another plan.
Leave these facts in sentences next to the plan itself and not in a separate terms page.
Switching and cancelling
State how to cancel and where: in the account settings, by how many steps, and by what means. State what happens at the end of the current period, whether access continues until then, what happens to data and for how long it can be retrieved. Provide a link to the longer terms, and do not rely on it alone.
A plan change or cancellation is often a financial transaction. WCAG 3.3.4 asks that, for pages that cause financial transactions, at least one of three things is true: submissions are reversible, data is checked and correctable, or there is a step for reviewing, confirming and correcting before finalizing. A confirmation screen that states what will change, and from which date, meets the review-and-confirm option and helps every reader, including those using assistive technology. Subscription rules differ by province and country. This is general information and not legal advice, so read the requirements that apply to your customers.
Checklist
- Answer the five questions in order, with a "for" line under every plan name.
- Compare only rows where plans differ; list the shared features once below the table.
- State each limit with what happens at the edge.
- Use caption, th scope and real text in every cell, and label the recommended plan in words.
- Write the free option, the leaving process and the data outcome in plain sentences beside the plans.
Questions and answers
Should every feature appear in the comparison table?
No. List a row only where the plans differ. Anything every plan includes belongs in a short 'included in every plan' list under the table, which keeps the table about the decision the reader is making.
Is a check mark enough to show a feature is included?
Not on its own. A mark drawn as an icon has no text for a screen reader to speak, and WCAG 1.4.1 says colour must not be the only way to convey information. Pair the mark with text, visible or visually hidden, such as Included and Not included.
How do I make a comparison table work on a phone?
WCAG 1.4.10 gives data tables an exception to the no-two-dimensional-scrolling rule, but the content around the table must still reflow. Either put the table in its own scrollable container or restructure it into one stacked section per plan. Test with a keyboard and at 320 CSS pixels.
Where should cancellation be explained?
On the plans page, in the same place a reader decides to join, and again where the account is managed. State where to cancel, what happens at the end of the period, and what happens to the data. Rules for subscriptions vary by place, so check the requirements where your customers live.
Sources
- Tables Tutorial (W3C WAI) w3.org
- Understanding Success Criterion 1.4.1: Use of Color (W3C WAI) w3.org
- Understanding Success Criterion 1.4.3: Contrast (Minimum) (W3C WAI) w3.org
- Understanding Success Criterion 1.4.10: Reflow (W3C WAI) w3.org
- Understanding Success Criterion 3.3.4: Error Prevention (Legal, Financial, Data) (W3C WAI) w3.org