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.

Enable JavaScript to customise; default output below.

Copying a range out of a spreadsheet gives you tab separated data, which is the default.

Announced first by a screen reader, and the one piece of table markup with no visual substitute. The CSS includes a class for hiding it visually if the design demands that.

Separated by
Alignment

Auto right-aligns the columns that look numeric and adds tabular figures, which is what lets a reader compare digits by position.

Live preview table.html
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.

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

  1. Paste the rows. A spreadsheet copy gives you tab separated data.
  2. Write a caption. It is required here, because it is the one piece of table markup with no visual substitute.
  3. Say whether the first row and the first column are headers.
  4. 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?
No: this is a data table, and the roles it gets by default are the ones you want. That attribute is for a table used for layout, where the announcement is noise.
How do I make a table responsive without a wrapper?
You can turn each row into a card with CSS at small sizes, repeating the header as a ::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?
That needs 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?
Above, which is where it is announced from and where a reader looks for it. The generated CSS sets caption-side: top explicitly, because the default varies less than you would hope.
Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.