HTML Table Generator
Accessible table markup from pasted data: a caption, scope on every header, and a scroll wrapper that a keyboard user can actually reach.
Rows 5
Columns 3
Header row first row, as th scope="col"
Header column first column, as th scope="row"
Footer row last row, in tfoot
Numeric columns 2
HTML
<div class="table-wrap" tabindex="0" role="region" aria-label="Pricing plans, 2026">
<table>
<caption>Pricing plans, 2026</caption>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col" class="num">Price</th>
<th scope="col" class="num">Seats</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Free</th>
<td class="num">£0</td>
<td class="num">1</td>
</tr>
<tr>
<th scope="row">Pro</th>
<td class="num">£99</td>
<td class="num">5</td>
</tr>
<tr>
<th scope="row">Team</th>
<td class="num">£299</td>
<td class="num">25</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td class="num">£398</td>
<td class="num">31</td>
</tr>
</tfoot>
</table>
</div>
CSS
table {
border-collapse: collapse;
width: 100%;
}
caption {
caption-side: top;
text-align: left;
padding-block-end: 0.5rem;
font-weight: 600;
}
th,
td {
padding: 0.5rem 0.75rem;
border-block-end: 1px solid #d8dbe3;
text-align: left;
}
th[scope="col"] {
border-block-end-width: 2px;
}
/* Numbers read faster right aligned and in a tabular figure. */
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.center {
text-align: center;
}
.right {
text-align: right;
}
/* Scrollable, and reachable by keyboard: a scroll container without tabindex
cannot be scrolled without a mouse. */
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-wrap:focus-visible {
outline: 3px solid currentColor;
outline-offset: 2px;
}
/* For a caption that has to be invisible: still announced, still first. */
.caption-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
The caption is announced first and it is the one piece of table markup
with no visual substitute. A heading above the table looks the same and
is not associated with it, so a screen reader reaching the table has no
idea what it contains.
scope="col" and scope="row" are what turn a grid of values into cells
with meaning: a cell is then announced with its headers, so "£99"
becomes "Price, Pro, £99". Without them a screen reader reads the
numbers in a row and the listener has to remember the column order.
The wrapper carries tabindex="0" and a label, which is the part
everybody leaves off: a scroll container that is not focusable cannot be
scrolled without a mouse, so a wide table becomes unreadable by
keyboard. role="region" with a label is what makes the focus stop make
sense when it is announced.
2 of 3 columns look numeric and are right aligned with tabular figures,
which is what lets a reader compare digits by position. Text stays left
aligned, because centred text has no reliable edge to scan down.
Do not use a table for layout. If you inherit one, role="presentation"
removes it from the accessibility tree so it is read as ordinary content
rather than announced as a table with one row.
What this cannot do is make a large table readable. Past about seven
columns, a table on a phone is a scrolling puzzle whatever the markup
says, and the honest fix is fewer columns or a different presentation
for small screens.
Output is valid and updates as you type.
Fix the highlighted fields to update the output.
A table is one of the few places where markup does real work for a screen reader. Read linearly, “£99” means nothing. Read as a cell with its headers, it is announced as “Price, Pro, £99”.
Four things make that happen: a <caption>, <thead> with scope="col", scope="row" on
the first cell of each row where rows have names, and no layout tables.
The fifth thing is the one everybody forgets. A wide table inside an overflow-x: auto
container cannot be scrolled without a mouse unless the container is focusable. That is one
attribute, tabindex="0", and without it a keyboard user simply cannot read the right-hand
columns.
How to use
- Paste the rows. A spreadsheet copy gives you tab separated data.
- Write a caption. It is required here, because it is the one piece of table markup with no visual substitute.
- Say whether the first row and the first column are headers.
- Copy the HTML and the CSS.
Example
<div class="table-wrap" tabindex="0" role="region" aria-label="Pricing plans, 2026">
<table>
<caption>Pricing plans, 2026</caption>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col" class="num">Price</th>
<th scope="col" class="num">Seats</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Free</th>
<td class="num">£0</td>
<td class="num">1</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td class="num">£398</td>
<td class="num">31</td>
</tr>
</tfoot>
</table>
</div>
The price and seat columns were detected as numeric, so they are right aligned with
font-variant-numeric: tabular-nums, which is what lets a reader compare digits by
position.
Pitfalls
A caption is not a heading. A heading above the table looks identical and is not associated with it, so a screen reader reaching the table has no idea what it contains. If the design cannot show a caption, the generated CSS has a class that hides it visually and keeps it announced.
scope is what makes a cell mean something. Without it, a screen reader reads a row of
numbers and the listener has to remember the column order. With it, every cell is announced
with its headers.
Not every first column is a header. scope="row" is right when each row has a name and
wrong when the first column is just more data. Turning it on indiscriminately announces a
value as a header.
A scroll container needs tabindex="0". Otherwise it cannot be focused, and a container
that cannot be focused cannot be scrolled from the keyboard. role="region" with a label is
what makes that focus stop make sense when it is announced.
Do not use a table for layout. If you inherit one, role="presentation" takes it out of
the accessibility tree so it is read as ordinary content.
Centre-aligned numbers are unreadable. There is no consistent edge to scan down. Right align them, and use tabular figures so the digits occupy equal widths.
Markup cannot rescue a large table. Past about seven columns, a table on a phone is a scrolling puzzle whatever the HTML says. The honest fixes are fewer columns, a summary row, or a different presentation at small sizes.
tfoot is for totals. It belongs after tbody in the source and browsers render it at
the bottom, which is also where a printed page repeats it.
Compatibility
Everything runs in the browser: nothing is uploaded and nothing is stored.
scope, caption, thead, tbody and tfoot are as old as HTML tables and work
everywhere. font-variant-numeric: tabular-nums needs a font with tabular figures, which
every system UI font has, and is harmlessly ignored otherwise.
The CSS uses logical properties, border-block-end and padding-block-end, which work in
every current browser and behave correctly in a right-to-left layout, where a physical
border-bottom would not need changing but a border-left would.
Comma and semicolon separated input is parsed with quote handling, so a cell containing the
separator survives if it is wrapped in double quotes, and a doubled "" becomes one quote.
Numeric detection accepts a currency symbol, thousands separators and a percentage sign, so
£1,299.00 and 12% are treated as numbers. A column with one non-numeric value in it is
treated as text, which is the conservative way round.
Frequently asked questions
Do I need role="presentation" on this table?
How do I make a table responsive without a wrapper?
::before on each cell. It reads well and it is a lot of CSS, and it stops being a table to
assistive technology when you change the display property, which is the trade.What about sortable columns?
aria-sort on the header and a button inside the th to do the sorting, so the
control is reachable and its state is announced. It is beyond what this generates, and the
markup here is the right starting point for it.Can I merge cells?
colspan and rowspan are valid and they make a table considerably harder to navigate with
a screen reader, because the header association stops being obvious. If the data needs merged
cells, it is usually two tables.Should the caption be above or below?
caption-side: top explicitly, because the default varies less than you would hope.