HTML Tables: Building Accessible Tables from Spreadsheet Data
Learn when to use an HTML table, how to mark it up so screen readers can use it, and how to convert CSV or spreadsheet data safely, with tested JavaScript and Python.

A spreadsheet is the most natural place to keep tabular data, and the web page is where most people need to read it. Moving the numbers from one to the other sounds like a five-minute job: copy the cells, wrap them in <table>, <tr> and <td>, done. Then a colleague who uses a screen reader tells you that your pricing table is just a long stream of unlabeled numbers, a long product name breaks the layout on a phone, and a cell containing < swallows half of the next row. This guide is for developers, technical writers and anyone who publishes data on the web. It explains when a table is the right tool, how the HTML table model really works, how to make a table that screen readers, keyboards and small screens can all use, and how to convert spreadsheet, CSV and TSV data safely, with tested code in JavaScript and Python.
What an HTML table is, and when to use one
An HTML table is a structure for data that has meaning in two dimensions. Each value in the grid is understood by the row it sits in and the column it sits under. The question to ask before writing any <table> is simple: if I removed the visual grid, would readers still need to look up a cell by both its row label and its column label? A schedule, a price list, a comparison of features, a set of measurements by month and region: yes. A list of links, a row of three feature cards, a form with labels beside inputs: no, those are lists, grids made with CSS, or form layouts.
The distinction matters because the browser exposes tables to assistive technology as a special structure. A screen reader user can move through a real table cell by cell, and the software announces the relevant headers as they go. That feature is only available when you mark up real data as a real table, and it becomes noise when you use a table to position things on the page.
Tabular data versus layout
In the 1990s there were no reliable layout tools in CSS, so developers used invisible tables to build page structure: a table for the header, a nested table for the sidebar, spacer images in empty cells. The practice has a name, table-based layout, and it has been obsolete for a long time. CSS Flexbox and Grid do the job better, adapt to screen sizes, and do not confuse assistive technology. The HTML specification and the W3C Web Accessibility Initiative tutorials on tables both state the principle: use tables for data, use CSS for layout.
There is one place where layout tables are still common, which is HTML email. We cover that near the end of this guide, because email clients are a special environment with special rules.
A quick test you can apply to any table in a code review:
| Question | If yes | If no |
|---|---|---|
| Does each cell make sense only with a row label and a column label? | Data table, keep it | Probably not a table |
| Would a sighted user sort or scan it by column? | Data table | Consider a list or cards |
| Is it there only to place elements side by side? | Layout, replace with CSS | Keep it |
| Is the content a single column of text? | Use a list | Keep it |
If a table passes the test, the next job is to build it from the right elements.
A short history of the table element
Tables appeared early in the life of HTML. They were not part of the first version of the language. They were developed through the draft HTML+ and HTML 3.0 proposals and were standardized for wide use in HTML 3.2 in 1997. Browsers rendered them immediately, and designers soon discovered that a table could position anything, which led to the layout abuse described above.
HTML 4 in 1997 and 1999 introduced most of the structural elements still in use: caption, thead, tbody, tfoot, colgroup and col, along with the attributes scope, headers and axis that let authors describe relationships between cells for assistive technology. Those additions came directly from accessibility concerns. HTML 4 also had a summary attribute on table, which HTML5 made obsolete, because a visible <caption> or surrounding text serves everyone, not only screen reader users.
HTML5 and the living standard maintained by WHATWG cleaned things up. Presentational attributes such as border, cellpadding, cellspacing, align and bgcolor are obsolete and belong in CSS. The tfoot element may now come after tbody in source order, where HTML 4 required it before. If you want a broader view of the history and the element list, the Wikipedia article on HTML tables is a good overview, and the MDN reference for the table element documents current behavior and browser support.
The table element model
A table is built from a small set of elements with strict nesting rules. Learning them once saves a lot of debugging later.
| Element | Purpose | Notes |
|---|---|---|
table |
The container | Everything below lives inside it |
caption |
The visible title of the table | Must be the first child of table |
colgroup, col |
Describe columns as a group or individually | For column-wide styling and span |
thead |
The header rows | Holds one or more tr of th cells |
tbody |
A body section | A table may have several, to group rows |
tfoot |
Summary or footer rows | Totals, notes |
tr |
One row | Contains th and td |
th |
A header cell | Labels a row, a column, or a group |
td |
A data cell | Holds a value |
A minimal but complete table looks like this:
<table>
<caption>Quarterly revenue by region (thousands of dollars)</caption>
<colgroup>
<col>
<col span="2">
</colgroup>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>120</td>
<td>135</td>
</tr>
<tr>
<th scope="row">South</th>
<td>98</td>
<td>101</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>218</td>
<td>236</td>
</tr>
</tfoot>
</table>
Notice what is and is not in this snippet. There are no border or width attributes, because styling lives in CSS. The title is a real caption, not a heading placed above the table. The first cell of each body row is a th, not a td, because it labels the row. The next sections explain why each choice matters.
Caption: the table's name
The <caption> element gives the table an accessible name. Screen readers announce it when the user reaches the table, and some let users list all tables on a page by caption. Sighted users get the same benefit: a clear title that sits attached to the data, even if the table is copied or the page is rearranged.
Write captions that say what the table contains, not that it is a table. "Quarterly revenue by region (thousands of dollars)" tells users what to expect and which unit applies. "Table 1" tells them nothing. If you need a longer description, put it in a paragraph before or after the table. You can point to it with aria-describedby on the table.
You may use a heading above the table instead, and connect it with aria-labelledby, but a caption is simpler, needs no ids, and keeps the title inside the table element where tools expect it. Style it freely with CSS: caption-side, alignment and font size all work.
thead, tbody and tfoot: sections with meaning
The three section elements group rows by role. thead holds the header row or rows. tbody holds the data. tfoot holds totals or notes. Beyond meaning, they have practical effects. When a long table is printed across several pages, browsers can repeat the thead and tfoot on each page. CSS can target each section, for example to make the header sticky. Scripts that sort rows can restrict themselves to tbody and leave the header and footer alone.
A table can have several tbody elements, and that is a good way to group related rows, such as one section per department. Each tbody can then be styled with its own top border, and a row-group header can label it.
colgroup and col: columns as objects
Rows are the main unit in HTML tables, so columns are described separately. colgroup contains col elements, one per column or one with a span attribute for several. They carry almost no content. Their value is as styling hooks:
col.numeric { text-align: right; } /* limited: see below */
col.highlight { background: #fff7d6; }
col.narrow { width: 6rem; }
The CSS properties that apply to col are limited. Background, border, width and visibility work, but text alignment and fonts do not inherit from a column to its cells in most cases. Use classes on cells or :nth-child selectors for those. That limitation is the most common surprise when people first reach for col.
th and td, and what the browser infers
Header cells (th) differ from data cells (td) in more than boldface. They establish the labels that assistive technology reads when a user moves around the table. A th that is the first cell of a row, in a table with scope="row", becomes the row header for that row. A th in thead with scope="col" becomes the header for the whole column.
Browsers can often guess which cells are headers from their position, especially in simple tables, but the specification says to make the relationship explicit when it is not obvious, and a scope attribute costs nothing. The next section covers it in depth.
How tables become accessible
Accessibility is the main reason to care about table markup. A well-built table works for people who see it, people who hear it through a screen reader, people who zoom in a lot, and people who navigate with a keyboard. A badly built one fails the same people in different ways.
How a screen reader announces a cell
Most screen readers, including NVDA, JAWS and VoiceOver, offer a table navigation mode. When the user focuses a data table, the software announces its name (the caption) and often its size, for example the number of rows and columns. Then, as the user presses table navigation keys to move to the next cell, the reader speaks the content of the cell together with the header cells that apply to it.
For the table above, moving into the cell with the value 135 might be announced as something like "Q2, North, 135". The exact wording and verbosity vary by product and settings, and I will not claim a precise phrase for each, but the principle is consistent: the cell comes with its column header and row header, so the user never has to keep the grid in their head.
Now imagine the same data as a table with no th cells, built entirely from td. The reader has no headers to announce, and the user hears "135" with no context. They would need to remember which column they are in and count back to the header row on every move. For a table with eight columns, that is a serious burden.
This is why the first two rules are:
- Mark header cells with
th, nottdwith bold styling. - Give the table a caption.
The scope attribute
scope on a th tells the browser and assistive technology which cells the header applies to. The values are:
| Value | Meaning |
|---|---|
col |
Header for the cells below it in the column |
row |
Header for the cells to its right in the row |
colgroup |
Header for a group of columns defined by colgroup |
rowgroup |
Header for a group of rows in the same tbody |
Use scope="col" on header cells in thead and scope="row" on the first cell of each data row if that cell names the row. The W3C tutorial describes this as the right approach for simple tables, which means tables with one level of column headers and optionally one level of row headers.
Many authors skip scope because browsers infer it. The inference works for simple tables, but it is not guaranteed in all cases, and tools for checking accessibility report missing scopes. Add them. In a generated table, as you will see in the code below, adding them is a single template string.
Complex tables: headers and id
Some tables have headers that sit at more than one level, or irregular shapes where scope cannot express which header applies. For these, the headers attribute on a td lists the ids of every th that describes it, space separated.
<table>
<caption>Opening hours by site and day type</caption>
<thead>
<tr>
<td rowspan="2"></td>
<th id="wk" colspan="2" scope="colgroup">Weekdays</th>
<th id="we" colspan="2" scope="colgroup">Weekend</th>
</tr>
<tr>
<th id="wk-open" scope="col">Opens</th>
<th id="wk-close" scope="col">Closes</th>
<th id="we-open" scope="col">Opens</th>
<th id="we-close" scope="col">Closes</th>
</tr>
</thead>
<tbody>
<tr>
<th id="lib" scope="row">Library</th>
<td headers="lib wk wk-open">08:00</td>
<td headers="lib wk wk-close">20:00</td>
<td headers="lib we we-open">10:00</td>
<td headers="lib we we-close">16:00</td>
</tr>
</tbody>
</table>
Here the cell showing 10:00 is described by three headers: the row (Library), the group (Weekend) and the column (Opens). The reader can announce all three. The cost is more markup, and it is easy to get wrong, because a typo in an id silently breaks the link. The W3C recommendation is to avoid such tables when you can by splitting them into two simple tables. If you must keep one, test it with a screen reader and use a validator that checks that every id in headers exists.
Avoid layout tables, and mark them if you cannot
If you inherit a page that uses tables for layout and cannot rewrite it, add role="presentation" to the layout table. That removes its table semantics from the accessibility tree, so a screen reader reads the content in order instead of announcing rows and columns. This is a bandage, not a cure. The better fix is CSS. HTML email is the main legitimate exception, as discussed later.
Merged cells: rowspan and colspan
colspan makes a cell wider and rowspan makes it taller. They are tempting for visual grouping, but they have costs:
- They break the simple grid, so
scopealone may no longer describe the relationships. You may needheadersandid. - Sorting, filtering and copy and paste into spreadsheets all become harder or impossible.
- Screen reader table navigation can behave less predictably around merged cells, depending on the product.
- Responsive restructuring with CSS is much harder when cells span.
A safe pattern is to avoid merging data cells at all and to use merging only for group headers above the column headers, in thead, with scope="colgroup". When you feel the urge to merge cells in the body, consider repeating the value instead, or splitting into several tables, or using a tbody per group with a row header that has scope="rowgroup". Repetition is not elegant, but it is accessible, sortable and copyable.
Empty cells
Empty cells are ambiguous. For a sighted user, an empty cell might mean zero, unknown, not applicable or missing. For a screen reader, an empty cell is often announced as "blank" or skipped, depending on the product. Make the meaning explicit. Use a visible dash or "n/a" if that is accurate, or a visually hidden "No data" text for cells that look empty on purpose. Pick one convention for the table and say what it means in the caption or a note. The code later in this guide emits visually hidden text for blank cells to illustrate the approach.
Other accessibility requirements
- Contrast. Header backgrounds and zebra stripes must keep text at the contrast ratios WCAG requires. Light grey text on a light stripe is a common failure.
- Not by color alone. If a color means "over budget", add text or an icon as well.
- Zoom and reflow. Users who magnify text need the table to remain usable at high zoom. Wrapping it in a scrollable container, covered below, is the standard solution.
- Keyboard access. Static tables need no focus handling. Tables that scroll need to be reachable from the keyboard. Interactive tables with sorting need real buttons in the header cells.
- Language. Set the
langattribute on the page so numbers and text are pronounced correctly.
Before and after: fixing an inaccessible table
Here is a table of the kind that appears in many codebases. It looks fine. It fails in several ways for people who rely on assistive technology.
<table border="1" cellpadding="4">
<tr>
<td><b>Plan</b></td>
<td><b>Monthly</b></td>
<td><b>Seats</b></td>
</tr>
<tr>
<td>Starter</td>
<td>$9</td>
<td>1</td>
</tr>
<tr>
<td>Team</td>
<td>$29</td>
<td></td>
</tr>
</table>
The problems, in order of severity:
- No header cells. Bold
tdcells look like headers but carry no semantics. A screen reader announces three data cells in a row and no labels. - No caption. The table has no name, so the user cannot tell what it is without reading everything around it.
- No head and body sections. Printing and sticky-header CSS have nothing to target.
- Presentational attributes.
borderandcellpaddingare obsolete, and they cannot adapt to a theme or dark mode. - An ambiguous empty cell. Does the Team plan have no seat limit, or is the data missing?
The fixed version:
<table class="plans">
<caption>Plans, monthly price and included seats</caption>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col" class="num">Monthly price</th>
<th scope="col" class="num">Seats</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Starter</th>
<td class="num">$9</td>
<td class="num">1</td>
</tr>
<tr>
<th scope="row">Team</th>
<td class="num">$29</td>
<td class="num">Unlimited</td>
</tr>
</tbody>
</table>
.plans { border-collapse: collapse; }
.plans th, .plans td { padding: 0.5rem 0.75rem; border: 1px solid #c8ccd0; }
.plans thead th { background: #f1f3f5; text-align: left; }
.plans .num { text-align: right; font-variant-numeric: tabular-nums; }
The fix did not need clever techniques. It used the elements HTML already provides. Moving the styling into CSS also means the table looks the same in every browser and can be changed from one place. We chose "Unlimited" for the empty cell, which is an editorial decision: you must know what the data means to fix it, and no converter can decide that for you.
Real-world use cases
Tables appear in more places than most people notice. A few of the common ones, with what each needs:
Documentation and README files. Parameter tables, option lists and compatibility matrices. These benefit from caption and row headers, and are often written in Markdown, which produces simple HTML tables with header cells but no captions or scopes. If accessibility matters, post-process the output or write HTML directly.
Pricing and feature comparison pages. Many columns, many rows, and strong pressure to merge cells and add icons. Keep the structure flat, give every icon an accessible text alternative, and keep a visible plan name as a column header.
Reports and dashboards. Financial and operational data with numbers. Right-aligned figures, consistent decimal places, units in the header or caption, and totals in tfoot.
Data export and import previews. After a user uploads a CSV, the app shows a preview in a table. Escaping and handling of ragged rows matter most here, because the content is untrusted.
Schedules and timetables. Two-dimensional by nature, and often the most complex tables because of grouped headers. This is the case where headers and id earn their place.
Email newsletters. Layout tables for structure, and sometimes real data tables inside them.
Academic and scientific content. Captions and notes are essential, and footnotes belong in tfoot or right after the table.
Converting spreadsheet, CSV and TSV data
The bulk of real-world table work is conversion: data starts in Excel, Google Sheets, a database export or a CSV file, and has to end up as HTML. The conversion looks mechanical, but several details decide whether the result is correct.
What the source formats look like
CSV (comma-separated values) is a text format with one record per line and fields separated by commas. RFC 4180 documents the common conventions: fields containing commas, double quotes or line breaks are enclosed in double quotes, and a double quote inside a quoted field is written as two double quotes. The RFC is informational, and real files vary: some use semicolons as separators (common where the comma is the decimal mark), some use different quote rules, and some start with a byte order mark.
TSV (tab-separated values) uses tab characters as separators. It is what most spreadsheet programs place on the clipboard when you copy a range of cells, which makes it a convenient way to move data: select cells, copy, paste into a tool. Tabs rarely appear inside values, so TSV needs less quoting, but cells that contain line breaks are still wrapped in quotes when copied from a spreadsheet.
Spreadsheet files such as .xlsx are zipped XML and are not something to parse by hand. Export to CSV or copy the cells instead.
Why you should not split on commas
The first version of every CSV converter splits each line on commas, and the first real file breaks it. Consider this line:
North,"Widget, large",1200
Splitting on commas yields four fields, not three. A correct parser reads character by character and tracks whether it is inside quotes. Our sample input below contains three nasty cases on purpose: a comma inside quotes, a doubled quote inside quotes, and a field that is entirely empty. Use a real parser, such as Python's csv module, or the small state machine in the JavaScript example.
Headers
The first row of most CSV files holds column names, but the file does not say so. Your converter has to be told, or has to assume. Header detection by guessing is unreliable, because a first row of text can be data. Make it an explicit option: "first row is a header". If it is not, you must supply headers yourself or generate generic ones, because a table without any header cells is the inaccessible table from the previous section.
Also decide what to do about row headers. If the first column holds names, like regions or products, mark those cells as th scope="row". Converters that emit only td for the body leave this step to you.
Escaping special characters
Cell content is text, not markup. Before inserting it into HTML, replace the characters that HTML treats specially:
| Character | Replacement | Why |
|---|---|---|
& |
& |
Begins a character reference |
< |
< |
Begins a tag |
> |
> |
Ends a tag, escape for safety and clarity |
" |
" |
Ends a double-quoted attribute value |
' |
' |
Ends a single-quoted attribute value |
Order matters when you write this by hand: replace & first, otherwise you will double-escape the ampersands that your own replacements introduce. In practice, use the escaping function your language provides, or a library, rather than a chain of replaces. Python has html.escape. JavaScript has no built-in string escaper, but you can avoid the issue by creating elements with document.createElement and assigning textContent.
Forgetting to escape produces two kinds of bug. The mild kind is a visible one: a company named "AT&T" renders strangely, or <Pro> disappears because the browser treats it as an unknown tag. The severe kind is a security hole, which we cover under security below.
Numbers and alignment
Numbers in a column are easier to compare when they are right-aligned, share the same number of decimals, and use tabular figures so the digits line up. A converter can detect a numeric column by checking that every non-empty cell looks like a number, then add a class for CSS to align. Do this per column, not per cell, so that a single "N/A" in a column of numbers does not change the alignment of its neighbors.
Be careful with what you consider a number. Postal codes, phone numbers, ZIP codes with leading zeros and identifiers such as 00123 look numeric but are labels, and right-aligning or reformatting them is wrong. Also do not reformat values: keep 980.00 as it was, since trailing zeros in a price column are meaningful. Spreadsheet exports can already have damaged such values, for example by stripping leading zeros, and you cannot repair that afterwards.
Locale is another trap. In some locales 1.234,56 means one thousand two hundred thirty-four and fifty-six hundredths. A converter that only copies text does not care, but one that sorts or aligns on the decimal point must know the convention.
Empty cells and ragged rows
CSV rows do not have to be the same length. A line with fewer fields than the header is common when trailing columns are empty. A line with more fields than the header is a sign that something went wrong, often an unquoted comma. A robust converter pads short rows to the header width, so the table stays rectangular, and reports or truncates long rows rather than letting them silently push cells out of their columns.
Large tables
A table of ten thousand rows is a valid, if heavy, document. The performance section later in this guide goes into the details. For conversion the main points are to stream or build output in an array and join once, avoid string concatenation inside nested loops on huge inputs, and decide early whether the browser should receive the whole table or only a page of it.
Tested code: CSV to an accessible table
The following programs convert CSV text to an accessible HTML table, with a caption, header cells with scope, a row header column, per-column numeric alignment, visually hidden text for empty cells, and escaping. I ran both programs on the same input and compared the output byte for byte: it was identical.
The input file, sample.csv, contains a quoted comma, a doubled quote, an ampersand, angle brackets, a script tag as a deliberate attack string, and empty cells:
Region,Product,Units,Revenue,Note
North,"Widget, large",1200,"14,400.50",Top seller
South,Gadget <Pro>,85,980.00,
East,"Gizmo ""Mini""",430,5160.25,"<script>alert(1)</script>"
West,Doohickey & Co,,0,Out of stock
JavaScript (Node.js)
const fs = require("fs");
function parseCsv(text) {
const rows = []; let row = [], cell = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
else if (c === '"') q = false;
else cell += c;
} else if (c === '"') q = true;
else if (c === ",") { row.push(cell); cell = ""; }
else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(cell); rows.push(row); row = []; cell = "";
} else cell += c;
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows;
}
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
const isNum = (s) => /^-?[\d,]*\.?\d+$/.test(s.trim()) && s.trim() !== "";
function csvToTable(text, caption) {
const [head, ...body] = parseCsv(text);
const numeric = head.map((_, c) => body.length > 0 &&
body.every((r) => (r[c] ?? "") === "" || isNum(r[c])));
const cls = (c) => (numeric[c] ? ' class="num"' : "");
const out = ['<table>', ` <caption>${esc(caption)}</caption>`, " <thead>", " <tr>"];
head.forEach((h, c) => out.push(` <th scope="col"${cls(c)}>${esc(h)}</th>`));
out.push(" </tr>", " </thead>", " <tbody>");
for (const r of body) {
out.push(" <tr>");
head.forEach((_, c) => {
const v = r[c] ?? "";
const cell = v === "" ? '<span class="sr-only">No data</span>' : esc(v);
out.push(c === 0 ? ` <th scope="row">${cell}</th>` : ` <td${cls(c)}>${cell}</td>`);
});
out.push(" </tr>");
}
out.push(" </tbody>", "</table>");
return out.join("\n");
}
console.log(csvToTable(fs.readFileSync(process.argv[2], "utf8"), "Sales by region"));
Python
import csv, html, re, sys
NUM = re.compile(r"^-?[\d,]*\.?\d+$")
def is_num(s):
s = s.strip()
return s != "" and bool(NUM.match(s))
def csv_to_table(text, caption):
rows = list(csv.reader(text.splitlines()))
head, body = rows[0], rows[1:]
numeric = [bool(body) and all(len(r) <= c or r[c] == "" or is_num(r[c]) for r in body)
for c in range(len(head))]
cls = lambda c: ' class="num"' if numeric[c] else ""
e = lambda s: html.escape(s, quote=True)
out = ["<table>", f" <caption>{e(caption)}</caption>", " <thead>", " <tr>"]
for c, h in enumerate(head):
out.append(f' <th scope="col"{cls(c)}>{e(h)}</th>')
out += [" </tr>", " </thead>", " <tbody>"]
for r in body:
out.append(" <tr>")
for c in range(len(head)):
v = r[c] if c < len(r) else ""
cell = e(v) if v else '<span class="sr-only">No data</span>'
out.append(f' <th scope="row">{cell}</th>' if c == 0
else f" <td{cls(c)}>{cell}</td>")
out.append(" </tr>")
out += [" </tbody>", "</table>"]
return "\n".join(out)
print(csv_to_table(open(sys.argv[1], encoding="utf-8").read(), "Sales by region"))
Run either with node csv2table.js sample.csv or python3 csv2table.py sample.csv. I used Node.js 22 and Python 3.13, and both printed exactly this:
<table>
<caption>Sales by region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Product</th>
<th scope="col" class="num">Units</th>
<th scope="col" class="num">Revenue</th>
<th scope="col">Note</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>Widget, large</td>
<td class="num">1200</td>
<td class="num">14,400.50</td>
<td>Top seller</td>
</tr>
<tr>
<th scope="row">South</th>
<td>Gadget <Pro></td>
<td class="num">85</td>
<td class="num">980.00</td>
<td><span class="sr-only">No data</span></td>
</tr>
<tr>
<th scope="row">East</th>
<td>Gizmo "Mini"</td>
<td class="num">430</td>
<td class="num">5160.25</td>
<td><script>alert(1)</script></td>
</tr>
<tr>
<th scope="row">West</th>
<td>Doohickey & Co</td>
<td class="num"><span class="sr-only">No data</span></td>
<td class="num">0</td>
<td>Out of stock</td>
</tr>
</tbody>
</table>
Walk through what the output shows. The quoted comma in "Widget, large" stayed inside one cell. The doubled quotes in Gizmo "Mini" became a single pair, which was then escaped to ". The script tag is inert text, because < and > became entities. The empty Units cell in the West row received hidden text instead of nothing. The Units and Revenue columns were detected as numeric and received the num class, while Note was not, because it contains text. The Region column became the row-header column.
Some limitations are deliberate and worth knowing. The numeric check accepts thousands separators but does not understand locales where the comma is the decimal mark. The Python version splits on lines before parsing, so it cannot handle line breaks inside quoted fields; for real files, pass the open file object to csv.reader with newline="". The JavaScript parser handles quoted line breaks, but it ignores a leading byte order mark and does not support other delimiters. For production, use a well-tested CSV library and keep the escaping and markup generation shown here.
The code assumes a class named sr-only that hides text visually but keeps it available to screen readers. A common definition is:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
Do not use display: none or visibility: hidden for this purpose: both remove the text from the accessibility tree, which defeats it.
Step-by-step: build the table with DevUtilX
If you do not want to write code, use the DevUtilX Table to HTML converter. It gives you an editable sheet in the browser and produces table markup, with all processing done locally. Nothing you type is uploaded, which matters when the table holds customer or financial data.
- Open the Table to HTML tool.
- Fill the sheet. Click a cell and type, and use the row and column controls to add, cut, paste or delete rows and columns until the grid has the shape you need.
- Tick the option to use the first row as the table head. The first row is then written as
theadwith header cells, and the remaining rows go intotbody. - Use the fullscreen mode if the table is wide and you want more room to edit.
- Click the button to convert. The HTML appears in the output panel.
- Copy the result or download it.
The tool escapes special characters in cell text, so ampersands and angle brackets in your data come out as entities. It generates the structural skeleton: table, thead, tbody, tr, th and td, with clean indentation. It does not know what your table means, so two steps are yours to do after conversion, and they matter for accessibility:
- Add a caption. Insert a
<caption>as the first child of thetable, describing the contents and units. - Add scope attributes. Put
scope="col"on each header cell, and, if the first column labels the rows, change thosetdcells toth scope="row".
Two minutes of editing turns the generated skeleton into the accessible table from the earlier sections. If the page is hand-written HTML, our guide to HTML formatting and semantic markup covers how to keep the surrounding document consistent. Once the table is in place, you may want to shrink the page for production; the article on HTML minification pros and cons explains what is safe to remove.
Responsive tables
Tables are wide by nature, and phones are narrow. A table with six columns will not shrink to fit a 360-pixel screen without crushing its content, and the default behavior, where the table forces the whole page to scroll sideways, is the worst outcome. You have three realistic options, and the right one depends on how many columns you have and how people read the data.
Option 1: a scroll container
Wrap the table in an element that scrolls horizontally, and let the table keep its natural width. The user scrolls the table, not the page. This keeps the table a real table, with all its semantics, and works for any shape of data.
<div class="table-scroll" role="region" aria-labelledby="sales-cap" tabindex="0">
<table>
<caption id="sales-cap">Sales by region</caption>
...
</table>
</div>
.table-scroll {
overflow-x: auto;
max-width: 100%;
}
.table-scroll:focus-visible {
outline: 3px solid #1a5fb4;
outline-offset: 2px;
}
Each attribute has a job. overflow-x: auto adds a scrollbar only when needed. tabindex="0" makes the container focusable, which is required so keyboard users can scroll it with the arrow keys; mouse and touch users can scroll without it, but a keyboard user cannot move into a scroll area that is not focusable. Some browsers now make scrollable areas focusable by default, but older ones do not, so the attribute remains the safe choice. A focusable element with no name is confusing, so role="region" plus aria-labelledby pointing at the caption gives the container a name that screen readers announce. Do not add these three attributes to every wrapper blindly: if the table never overflows, the extra tab stop is clutter. Use them where the content really can scroll.
Give the focus state a visible outline, as shown, so a keyboard user can see where they are.
A scroll container has two weaknesses. Users may not notice that more columns are hidden to the right, so a shadow or a partial last column can hint at it. And the row header scrolls away with the rest, so the user loses the label. A sticky first column fixes that, as shown in the styling section.
Option 2: stacking rows into cards
For tables with few columns, you can turn each row into a small block on narrow screens, with the header label shown beside each value. This is done with CSS and a data-label attribute on each cell:
@media (max-width: 40rem) {
table, thead, tbody, tr, th, td { display: block; }
thead { position: absolute; left: -9999px; } /* hide visually, keep for AT */
td::before {
content: attr(data-label);
font-weight: 600;
display: inline-block;
min-width: 8rem;
}
}
It reads well on a phone, and it comes with a serious warning. Setting display: block on table elements can remove their table semantics in some browser and screen reader combinations, which turns your accessible table into a pile of unrelated blocks. Browsers have improved here over time, and behavior differs between products, so test with the screen readers your audience uses. You can restore the semantics with explicit ARIA roles (role="table", role="row", role="columnheader", role="cell") on the elements, at the cost of more markup that must stay in sync. Generated content from ::before is also announced inconsistently, and users cannot select or search it the way they can real text.
Because of these risks, stacking is best kept for simple tables, and the scroll container is the safer default.
Option 3: change what is shown
Sometimes the answer is to show less on a small screen. Hide secondary columns with CSS below a breakpoint, and offer a link to the full data. This works when some columns are optional and the key columns fit. Hidden columns must really be optional: display: none also hides them from screen readers, which is consistent for users on that device, but keep the row and column headers visible so the table still makes sense.
CSS pitfalls that bite responsive tables
max-width: 100%on a table does not shrink the content. A table is at least as wide as its widest unbreakable content. A long word, URL or code string forces the table wider. Useoverflow-wrap: anywhereorword-breakon the cells that contain such content.table-layout: fixedwith percentages gives predictable column widths, but cells with long content will be clipped or overflow, so pair it with wrapping rules.- Percentage heights on rows are unreliable. Avoid them.
white-space: nowrapon all cells is a quick way to get a clean scrolling table, but it makes narrow screens scroll far. Apply it only to short columns such as dates and numbers.display: flexontrortdbreaks the table layout algorithm. If you need a grid layout, you probably do not have a table.
Styling data tables well
Good styling makes a table easier to read without hiding its structure. A few rules do most of the work.
Borders and spacing
Start with border-collapse: collapse so adjacent cells share borders instead of showing double lines. Add padding to every cell, since cramped cells are hard to read. Use thin horizontal rules, not a full grid of boxes, where possible: horizontal lines guide the eye along a row without making the table feel heavy.
table { border-collapse: collapse; width: 100%; }
caption { text-align: left; font-weight: 600; padding-bottom: 0.5rem; }
th, td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #d0d7de; }
thead th { border-bottom: 2px solid #8c959f; text-align: left; }
Zebra striping
Alternating row backgrounds help readers track a row across many columns. :nth-child does it with no markup:
tbody tr:nth-child(even) { background: #f6f8fa; }
Check contrast against the stripe color, as noted earlier. Striping helps most for wide rows. For short tables, it adds visual noise without value. Also consider a row hover highlight, but never make hover the only way to see which row you are in, since touch and keyboard users do not hover.
Numbers: alignment and tabular figures
Right-align numeric columns, and ask the font to use fixed-width digits so that places line up:
.num { text-align: right; font-variant-numeric: tabular-nums; }
font-variant-numeric: tabular-nums switches to tabular figures when the font provides them. Many fonts do, and those that do not simply ignore the declaration. The header of a numeric column should be right-aligned as well, so the label sits above its numbers. Align decimals by using the same number of decimal places in every cell of the column. Left-aligned numbers of different lengths are very hard to compare.
Sticky headers
For a table taller than the screen, keep the header visible while scrolling:
thead th {
position: sticky;
top: 0;
background: #fff; /* needed, or rows show through */
z-index: 1;
}
This works in modern browsers when the table is inside a scrolling ancestor, or when the page itself scrolls. Note that position: sticky sticks relative to the nearest ancestor with overflow, so a scroll container wrapped around the table makes the header stick to that container, not to the page. A known annoyance is that borders on sticky cells behave poorly under border-collapse: collapse: the border can scroll away while the cell stays. Using border-collapse: separate with border-spacing: 0, or drawing the border with box-shadow, avoids this. The same technique makes a sticky first column: position: sticky; left: 0; on the row header cells, with an opaque background.
Column widths
Browsers size columns automatically by default, based on content. table-layout: fixed with a set width instead uses the first row to define column widths, which is faster to render and more predictable, at the cost of less flexible content handling. Use col elements to set widths in a fixed layout without touching every cell.
Sortable and filterable tables
HTML has no built-in sorting, so interactive behavior is up to you. You do not need a framework to understand the pattern.
Sorting: the concept
A sortable table needs three things. First, a control in each sortable header: a <button> inside the th, so keyboard and screen reader users can activate it. Second, a state announcement, which is the aria-sort attribute on the th with the value ascending, descending or none, so assistive technology can report the current sort. Third, a comparison function that fits the column's data type.
The last point is where bugs live. Sorting numbers as strings puts 100 before 20. Sorting formatted values like 14,400.50 or $9 needs the raw number, so store it in a data-value attribute and compare that. Dates should be compared as ISO 8601 strings or timestamps. Text sorts best with localeCompare, which respects the user's language rules. A stable sort preserves the previous order for equal values, which modern JavaScript engines guarantee for Array.prototype.sort.
Sorting should move whole rows in tbody and leave thead and tfoot alone. If a table has merged cells, sorting breaks it, which is another reason to avoid rowspan in data rows.
Filtering: the concept
A filter is a text input that hides rows that do not match. Hide rows with the hidden attribute, not by deleting them, so the data stays in place. Announce the result count in a polite live region, such as "12 of 240 rows shown", because screen reader users get no other feedback that the table changed. Keep the filter input labelled with a real <label>.
When to use a library
For tables beyond a few hundred rows, or with paging, column resizing, grouping and export, use a maintained data grid library rather than building one. These widgets are hard to make accessible. Complex grids use the ARIA grid pattern, which has its own keyboard model and is much more work than a plain table. If users only need to read the data, a plain table with optional sorting is almost always the better choice.
Performance with thousands of rows
Browsers handle large tables, but they pay for them in layout time and memory. I measured the size of generated markup for a table of 10,000 rows and five columns, where each row had a row header, a name, two numeric cells and a short text cell. The generator is a Node.js script that builds strings in an array and joins them once.
| Variant | Raw size (bytes) | Gzip size (bytes) |
|---|---|---|
Numeric cells with class="num" |
1,137,069 | 85,224 |
| Plain numeric cells, no class | 897,069 | 87,103 |
Two lessons come out of that sample. The markup for each row is about 90 to 110 bytes, so 10,000 rows is roughly one megabyte of HTML, which is large but compresses well, to about 85 KB here. And the extra class attributes cost 240,000 raw bytes yet came out slightly smaller after gzip, because repeated attributes compress almost for free. Do not remove useful markup to save bytes; compress the response instead. These figures come from this synthetic data and will differ for yours. Our companion articles on JSON payload size and HTML minification go into the same logic: compression first, then trimming.
The transfer cost is the smaller problem. The browser then has to build one DOM node per element and compute layout, and a table is among the more expensive layouts because column widths depend on the content of every row. What helps:
- Use
table-layout: fixed. The browser can size columns from the first row and avoid measuring every cell. - Paginate on the server. Show 50 or 100 rows per page. This is the biggest win and also the most accessible option, since users can move around the data with ordinary links.
- Virtualize only when you must. Rendering just the visible rows with scripts keeps the DOM small, but it breaks browser find-in-page, confuses screen readers about the table size unless you set
aria-rowcountandaria-rowindex, and complicates keyboard navigation. Pagination is simpler. - Skip work for off-screen rows. The CSS property
content-visibility: autolets the browser skip rendering of off-screen content in browsers that support it. Check support and test with tables, because row-level use needs care for layout and for sticky headers. - Build once. In scripts, assemble the markup in an array or a document fragment and insert it in a single operation, not row by row, so layout runs once.
- Keep cells light. Avoid putting heavy components, images or event listeners in every cell. Use event delegation on the
tbodyinstead.
Measure your own case in the browser's performance panel before choosing a strategy. A table with 500 rows rarely needs any of this.
Security: escape every cell
Table data usually comes from somewhere else: a CSV that a user uploaded, a database column, an API. Treating it as markup is the classic route to cross-site scripting (XSS). If a cell contains <img src=x onerror=alert(1)> and you insert it raw, the browser will run the handler for every visitor who sees the page.
The rules are short.
- Escape text for the HTML context. The five characters in the table earlier cover element content and quoted attributes. The sample run showed a script tag arriving as harmless text.
- Prefer APIs that do not parse markup. In the browser, build cells with
document.createElement("td")and settextContent. AvoidinnerHTMLfor data. A table-building function that usestextContentis safe by construction, with no escaping step to forget. - Escape for the right context. Escaping that is correct for element content is not enough for a URL in an
href, a value inside inline JavaScript or CSS. If a cell becomes a link, validate the scheme and allow onlyhttp,httpsandmailto, since ajavascript:URL would run code when clicked. - Add a Content Security Policy as a second layer. It limits what injected markup can do even if escaping fails somewhere.
- Sanitize if you must allow HTML. If cells may contain formatting such as bold or links, use a maintained sanitizer with an allowlist rather than a hand-written regular expression.
There is a mirror-image problem when data flows the other way. When users export a table to CSV and open it in a spreadsheet, a cell that begins with =, +, - or @ can be interpreted as a formula. This is known as CSV injection or formula injection. The usual mitigation is to prefix such cells with a single quote or a space on export, and to warn users before opening files from unknown sources. It does not affect the HTML table itself, but it matters if your page has an export button.
Email-safe tables
HTML email is the one place where layout tables are still the practical standard. Email clients render markup with engines of very different ability. Some desktop versions of Outlook render messages using the layout engine of a word processor, which lacks support for much of modern CSS, including Flexbox and Grid. Tables, however, are supported everywhere, and so email authors build layout from nested tables.
If you write email, a few rules keep things working:
- Inline your CSS. Many clients strip or ignore
<style>blocks, so styles go instyleattributes, usually applied by a build step. - Use
role="presentation"on layout tables so screen readers do not announce them as data tables. Real data tables inside the email still needth,scopeand a caption. - Set widths and padding explicitly, and use
border="0" cellpadding="0" cellspacing="0"on layout tables. These obsolete attributes are still the most reliable way to reset spacing in email clients, one of the few places where they remain useful. - Keep the width modest, commonly around 600 pixels for the main container, and let it shrink on small screens where media queries are supported.
- Do not rely on sticky positioning, custom fonts or
:nth-childstriping. Give striped rows an inline background color instead. - Test in real clients, since the only reliable answer is to look.
A converter that produces clean semantic markup is a good starting point for the data inside an email, but you will need to add the inline styles afterward.
Common mistakes and pitfalls
These are the errors that appear most often in reviews, with the reason each one hurts.
- Using
tdwith bold text instead ofth. It looks like a header and means nothing to assistive technology. - Skipping the caption. The table has no name, and users must infer its subject.
- Using a table for layout. It makes content order depend on rows and columns, and adds noise for screen readers. Use CSS Grid or Flexbox.
- Splitting CSV on commas. It breaks the first time a field contains a comma or a line break.
- Forgetting to escape cell content. Visible garbage at best, XSS at worst.
- Merging cells in the body. It breaks sorting, copy and paste, and simple
scoperelationships. - Using
display: blockon table elements without testing. It can silently remove table semantics. - Hiding the header with
display: none. The header disappears for assistive technology as well. Use the visually hidden pattern when you need to hide it visually but keep it. - Right-aligning identifiers. Postal codes and account numbers are labels, not quantities.
- Hiding scrollable tables from keyboard users. A scroll container without
tabindexcannot be scrolled by keyboard in browsers that do not make it focusable. - Using an empty
th. An empty header cell has no name. If a corner cell is truly empty, use a visually hidden label such as "Region" or atd, depending on whether it labels anything. - Setting fixed pixel widths on the table. It overflows small screens. Let it size to its container.
- Putting a table inside a heading or a paragraph. Tables are flow content and cannot nest inside
p; the browser will close the paragraph early and rearrange the document. - Whitespace and invalid nesting. Placing text or a
divdirectly insidetrortableis invalid; browsers move it out of the table in ways that surprise you. Content must live in cells.
Troubleshooting
When a table misbehaves, work from the symptom.
Symptom: the screen reader reads numbers with no labels
Cause. The table has no th cells, or the headers are td cells styled to look bold.
Fix. Change header cells to th with scope, add a caption, and test again with a screen reader.
Symptom: content appears above the table instead of inside it
Cause. Text or elements were placed directly inside table, tbody or tr. The parser relocates them in front of the table, a process known as foster parenting.
Fix. Put all content inside td or th cells. Validate the markup, and look for stray closing tags.
Symptom: columns misalign after converting a CSV
Cause. Some rows had more or fewer fields than the header, usually because of an unquoted comma or a missing trailing field.
Fix. Check the offending rows in the source. Quote fields that contain commas, pad short rows, and make your converter report ragged rows instead of hiding them.
Symptom: part of a cell vanished or the page structure broke
Cause. Unescaped < or & in the cell text made the browser read markup.
Fix. Escape all cell text. Verify with a cell containing <b>x</b> and Tom & Jerry.
Symptom: the page scrolls sideways on phones
Cause. The table is wider than the viewport, because of unbreakable content or fixed widths.
Fix. Wrap the table in a scroll container, allow long strings to wrap with overflow-wrap, and drop fixed pixel widths.
Symptom: the sticky header does not stick
Cause. position: sticky was applied to thead or tr in a browser that needs it on the cells, or an ancestor has overflow: hidden, or the sticky element has no background and sits under later rows.
Fix. Apply position: sticky; top: 0 to the th cells, remove unwanted overflow on ancestors, and set an opaque background.
Symptom: numbers do not line up
Cause. The column is left-aligned, uses proportional digits, or has different decimal counts.
Fix. Right-align with a class, add font-variant-numeric: tabular-nums, and format all values with the same number of decimals.
Symptom: leading zeros disappeared from IDs
Cause. The spreadsheet converted the column to numbers before export.
Fix. Format the source column as text before exporting, or restore the values from the original system. A converter cannot recover digits that the export has already dropped.
Symptom: sorted numbers are in the wrong order
Cause. The sort compares strings, so 100 sorts before 20.
Fix. Store a raw value in data-value and compare as numbers or dates.
Symptom: the CSV shows accented characters as garbage
Cause. The file is not UTF-8, or a byte order mark was read as data, so the first header name begins with strange characters.
Fix. Read the file with the correct encoding, for example utf-8-sig in Python, and set <meta charset="utf-8"> in the page.
Best practices checklist
Use this list when building or reviewing a table.
- Confirm the data is tabular. If not, use a list, CSS Grid or cards.
- Add a
captionthat names the table and states units. - Use
thead,tbodyand, where relevant,tfoot. - Mark every header cell as
thwithscope="col"orscope="row". - Keep the structure simple. Avoid
rowspanandcolspanin data rows. If you cannot, useheadersandid, and test. - Give empty cells a meaning, such as a dash, "n/a" or hidden text.
- Escape all cell content, or build cells with
textContent. - Right-align numbers, use tabular figures and consistent decimals.
- Style in CSS, not with obsolete attributes. Check contrast, including striped rows.
- Make wide tables scroll in a labelled, focusable container, and show a visible focus ring.
- Test responsive behavior at narrow widths and at high zoom.
- Paginate large tables instead of rendering everything.
- Make sorting accessible with buttons and
aria-sort. - Test with a screen reader and with the keyboard alone. Automated checkers catch missing scopes and captions, but they cannot judge whether a table reads sensibly.
- Validate the markup, including that every id referenced by
headersexists.
Teams that publish many tables benefit from a shared snippet or component, so that these decisions are made once. Keep the generated HTML formatted consistently; our guide to HTML formatting and semantic markup describes conventions that make table markup easy to review, and the CSS formatting and architecture guide covers how to organize the table styles.
Comparison: ways to present tabular data
| Approach | Best for | Accessibility | Effort | Drawbacks |
|---|---|---|---|---|
Native HTML table |
Most data on web pages | Excellent when built correctly | Low | Wide tables need responsive handling |
| Table with scroll container | Wide data with many columns | Good with tabindex and a label |
Low | Hidden columns may go unnoticed |
| CSS-stacked rows | Narrow tables on phones | Risky, depends on testing | Medium | Can lose table semantics |
| ARIA grid with scripting | Editable, spreadsheet-like widgets | Good if implemented carefully | High | Complex keyboard model |
| Data grid library | Large, interactive data | Varies by library | Medium | Heavy, may not fit your design |
| Lists or definition lists | Simple label and value pairs | Excellent | Low | No column comparison |
| CSS Grid cards | Items with several attributes | Good | Medium | Not a table, no cell relationships |
| Image of a table | Never | Poor | Low | Not searchable, not accessible, does not scale |
| Embedded spreadsheet | Live, shared data | Varies | Low | External dependency, privacy and performance costs |
| Markdown table | Documentation and READMEs | Basic, no caption or scope | Very low | Limited control, needs post-processing |
The first row is the right answer more often than people think. Start with a native table, add the responsive wrapper, and move to something heavier only when users need behavior that a table cannot provide.
FAQ
When should I use a table instead of CSS Grid?
Use a table when the content is data with row and column relationships, such as a schedule or comparison. Use Grid for page layout and for collections of independent items, such as cards.
Do I still need scope if I use th?
Browsers can often infer the relationship in simple tables, but the attribute makes it explicit and is flagged by accessibility checkers when missing. It costs nothing, so add it.
What is the difference between scope and headers?scope goes on a th and says which cells it labels, which works for simple tables. headers goes on a td and lists the ids of the th cells that label it, which handles irregular tables with multi-level headers.
Is <caption> required?
It is not required by the HTML syntax, but every data table benefits from a name. It gives screen reader users context and gives everyone a title attached to the data.
Can I use rowspan and colspan at all?
Yes, mainly for group headers in thead. Avoid them in data rows, because they complicate sorting, copy and paste, and header relationships.
How do I make a table scroll on mobile accessibly?
Wrap it in a container with overflow-x: auto, tabindex="0", role="region" and an accessible name from aria-labelledby, and give the container a visible focus style.
Should I put CSS display: block on table elements to stack rows on phones?
Only with testing. It can remove table semantics in some browser and screen reader combinations. A scroll container is the safer default for most tables.
How do I convert a CSV to an HTML table safely?
Parse the CSV with a real parser that handles quotes, escape every cell for HTML, mark header cells with th and scope, and add a caption. The code in this guide does exactly that.
How do I get data from Excel or Google Sheets into HTML?
Copy the cells and paste them into a converter such as the DevUtilX Table to HTML tool or type them in, or export as CSV and run a script. Both routes produce a table skeleton, to which you add a caption and scopes.
Why do numbers in my table not line up?
They need right alignment, tabular figures and the same number of decimals. Use a class on numeric cells with text-align: right and font-variant-numeric: tabular-nums.
How do I handle empty cells?
Decide what they mean and show it: a dash, "n/a" or hidden text such as "No data". Blank cells are announced inconsistently and are ambiguous for everyone.
Is it safe to build tables from user input?
Only if you escape the content or use textContent. Unescaped input is a direct route to XSS. A Content Security Policy adds a second layer.
Can I use tables for HTML email layout?
Yes. Tables are the most reliable layout method across email clients. Add role="presentation" to layout tables, and inline your styles.
Do I need a library for sortable tables?
Not for simple cases. A button in the header, an aria-sort attribute and a typed comparison function are enough for a few hundred rows. For larger or editable data, use a maintained grid library.
How many rows is too many for one table?
There is no fixed limit. Generating 10,000 rows is possible, but paging at 50 to 100 rows is faster to render, and is easier to use. Measure with your own data and devices.
Conclusion
A good HTML table is not complicated: a caption, header cells with scope, honest cell content, escaped text and a sensible container for small screens. Those few habits make the same markup work for people who read it, hear it, zoom into it or navigate it with a keyboard. When your data starts in a spreadsheet, convert it with a real parser, escape everything and add the semantics a generator cannot guess. To get a clean table skeleton quickly, build your grid in the DevUtilX Table to HTML converter, which runs in your browser and uploads nothing, then add the caption and scopes described above. To keep going, read our guides to HTML formatting and semantic markup and HTML minification pros and cons.


