HTML Tables
An HTML table is a way to display tabular data — information that naturally fits into rows and columns, like a price list, a class schedule, or a comparison chart. Tables are built from a small family of elements that describe the grid structure explicitly, so the browser (and assistive technology) can understand exactly which cell belongs to which row and column. Used correctly, tables make dense data easy to scan; used incorrectly (for page layout, for example), they create confusing, inaccessible markup.
Overview / How Tables Work
A table is built as a nested structure of elements. The outermost element is <table>, which contains one or more rows created with <tr> (table row). Inside each row, individual cells are created with either <td> (table data, a regular cell) or <th> (table header, a heading cell for a row or column). The browser reads this markup and constructs a grid: the number of <tr> elements determines the row count, and the number of cell elements inside each row determines the column count for that row.
Beyond the basic grid, HTML gives you elements to describe the table’s larger structure. <thead>, <tbody>, and <tfoot> group rows into a header section, a body section, and a footer section. This is not just cosmetic — it tells the browser and screen readers which rows are headers versus data, and it lets long tables repeat header/footer rows when printed. A <caption> element, placed immediately after the opening <table> tag, gives the whole table an accessible title that screen readers announce before reading the data.
By default, browsers render tables with visible borders on cells only if you add CSS (in older HTML, a border attribute existed, but it is presentational and deprecated). Without any CSS, a table still renders as a grid layout — cells align in rows and columns — but there are no visible border lines, and header cells (<th>) are typically shown bold and centered by the browser’s default stylesheet. All actual visual styling (borders, striped rows, spacing, colors) belongs in CSS, not in the table markup itself.
Semantically, a table should only be used for data that truly has a row/column relationship. Using tables to arrange unrelated page content (like a navigation bar or a photo gallery) confuses screen readers, which announce table structure (“row 2 of 5, column 3 of 4”) to help users navigate real data — announcements that are meaningless noise on a non-tabular layout.
Syntax
<table>
<caption>Table title</caption>
<thead>
<tr>
<th scope="col">Header 1</th>
<th scope="col">Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data 1</td>
<td>Data 2</td>
</tr>
</tbody>
</table>
| Part | Purpose |
|---|---|
<table> |
The container for the entire table. |
<caption> |
An accessible title for the table; must come right after the opening <table> tag. |
<thead> |
Groups the header row(s) at the top of the table. |
<tbody> |
Groups the main data rows. |
<tfoot> |
Groups summary or footer rows, often shown at the bottom. |
<tr> |
Defines one table row. |
<th> |
A header cell; bold and centered by default. The scope attribute (col, row, colgroup, or rowgroup) tells assistive tech what the header describes. |
<td> |
A regular data cell. |
colspan |
An attribute on <td>/<th> that makes a cell span multiple columns. |
rowspan |
An attribute on <td>/<th> that makes a cell span multiple rows. |
Examples
Example 1: A basic data table
<table>
<caption>Weekly Fruit Prices</caption>
<thead>
<tr>
<th scope="col">Fruit</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apples</td>
<td>$1.20</td>
</tr>
<tr>
<td>Bananas</td>
<td>$0.60</td>
</tr>
</tbody>
</table>
Result: A two-column, three-row grid appears (one header row plus two data rows). “Weekly Fruit Prices” is displayed above the table as its caption. The words “Fruit” and “Price” appear bold and centered in the top row; “Apples”/”$1.20” and “Bananas”/”$0.60” appear left-aligned in the rows below, with no visible border lines since none were added with CSS.
This example shows the minimum useful table: a caption for context, a <thead> with column headers using scope="col", and a <tbody> with the actual data. Every header cell is explicitly associated with the column beneath it, so a screen reader announcing the “$0.60” cell will also say “Price” for context.
Example 2: Spanning cells with colspan and rowspan
<table>
<caption>Weekly Class Schedule</caption>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Monday</th>
<th scope="col">Tuesday</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">9:00 AM</th>
<td colspan="2">Assembly (All Classes)</td>
</tr>
<tr>
<th scope="row">10:00 AM</th>
<td rowspan="2">Mathematics</td>
<td>Science</td>
</tr>
<tr>
<th scope="row">11:00 AM</th>
<td>History</td>
</tr>
</tbody>
</table>
Result: A four-column-wide grid renders. In the 9:00 AM row, “Assembly (All Classes)” stretches across both the Monday and Tuesday columns as one merged cell. In the Monday column, “Mathematics” visually stretches down across both the 10:00 AM and 11:00 AM rows as one merged cell, while Tuesday shows “Science” and “History” as two separate cells beside it. The time values (9:00 AM, 10:00 AM, 11:00 AM) appear bold as row headers on the left.
colspan="2" tells the browser that a single cell should occupy the space of two columns, so the following row does not need a matching cell for that space. rowspan="2" does the same across rows: the “Mathematics” cell occupies the Monday-column position for both the 10:00 AM and 11:00 AM rows, so the 11:00 AM row does not repeat a Monday cell — it only needs the History cell for Tuesday. Getting the cell counts right after using spans is one of the trickiest parts of writing tables by hand.
Example 3: A realistic table with a footer
<table>
<caption>Monthly Subscription Plans</caption>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col">Storage</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Basic</th>
<td>5 GB</td>
<td>$0</td>
</tr>
<tr>
<th scope="row">Pro</th>
<td>100 GB</td>
<td>$9.99</td>
</tr>
<tr>
<th scope="row">Business</th>
<td>1 TB</td>
<td>$24.99</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3">Prices shown are billed monthly.</td>
</tr>
</tfoot>
</table>
Result: A three-column table appears with “Plan”, “Storage”, and “Price” as bold column headers, three data rows (Basic, Pro, Business, each with its own bold row-header plan name), and a final full-width row at the bottom reading “Prices shown are billed monthly.” spanning all three columns.
This example combines everything: <thead> for column labels, <th scope="row"> for row labels (so “5 GB” is understood as belonging to “Basic”), a <tbody> for the plan data, and a <tfoot> with a spanning note. On a long, printed, or multi-page table, keeping the footer in <tfoot> lets browsers repeat it on every printed page.
How It Works Step by Step
When the browser’s HTML parser encounters <table>, it begins constructing a table object in the DOM. As it reads child elements, it applies table-specific parsing rules that are stricter than for most other elements: if it sees a <td> or <th> that is not inside a <tr>, or a <tr> not inside a <table>, the parser will often try to recover by implicitly inserting the missing structural elements, which can produce a DOM tree that doesn’t match what you intended. This is why explicit, correctly nested markup matters even though the browser is somewhat forgiving.
Once the tree is built, the browser’s table layout algorithm runs a two-pass process: first it scans all rows to determine how many columns exist and how wide each column needs to be (based on content and any spans), then it lays out each row using that shared column grid. This is why a single missing <td> in one row, or a colspan miscount, can throw off the alignment of an entire table — every row shares the same column grid, so one broken row shifts everything after it.
Accessibility tools build their own model on top of this: screen readers use the <th>/<td> distinction and scope attributes to let a user jump to a cell and hear its row and column headers announced, without having to listen to the entire table read out loud.
Common Mistakes
Mistake 1: Using td for headers instead of th
<table>
<tr>
<td>Name</td>
<td>Age</td>
</tr>
<tr>
<td>Sam</td>
<td>34</td>
</tr>
</table>
This markup is technically well-formed, but “Name” and “Age” are just regular data cells with no semantic distinction from “Sam” and “34”. A screen reader has no way to announce them as headers, and they won’t get the default bold styling. Use <th> for any cell that labels a row or column:
<table>
<tr>
<th scope="col">Name</th>
<th scope="col">Age</th>
</tr>
<tr>
<td>Sam</td>
<td>34</td>
</tr>
</table>
Mistake 2: An unclosed row breaking the structure
<table>
<tr>
<th>Name</th>
<th>Age</th>
<tr>
<td>Sam</td>
<td>34</td>
</tr>
</table>
The first <tr> is never closed before the second one starts. Browsers will try to recover by auto-closing the first row, but the resulting DOM structure is unpredictable and shouldn’t be relied on — always close every <tr>, <td>, and <th> explicitly:
<table>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
<tr>
<td>Sam</td>
<td>34</td>
</tr>
</table>
Best Practices
- Only use tables for genuinely tabular data — never for arranging unrelated page layout sections.
- Always give a table a
<caption>so both sighted users and screen reader users know what the data represents. - Use
<th>with ascopeattribute for every header cell, whether it labels a column or a row. - Group rows with
<thead>,<tbody>, and<tfoot>to make the table’s structure explicit, even for small tables. - Keep every row’s cell count consistent with the shared column grid; double-check the math whenever you use
colspanorrowspan. - Leave visual styling (borders, stripes, spacing, colors) to CSS rather than deprecated attributes like
border,cellpadding, orcellspacing. - For very wide tables, consider whether the data would be clearer split into multiple smaller tables.
Practice Exercises
- Build a table listing three of your favorite movies with columns for Title, Year, and Rating. Include a
<caption>and use<th scope="col">for the headers. - Create a small “Team Roster” table where one cell spans two columns using
colspanto display a section label like “Coaching Staff” above two rows of names. - Take the roster table from the previous exercise and add a
<tfoot>row with a note (for example, “Roster last updated in March”) that spans all columns.
Summary
- Tables are built from
<table>,<tr>(rows), and<td>/<th>(cells). <thead>,<tbody>, and<tfoot>group rows into header, body, and footer sections.<caption>gives the table an accessible title, andscopeon<th>ties headers to the rows or columns they label.colspanandrowspanlet a single cell merge across multiple columns or rows.- Tables should only be used for real tabular data, never for page layout, and visual styling belongs in CSS.
