HTML Tables
HTML Tables Overview
HTML tables have a bit of a complicated reputation and I think it's worth addressing that upfront: for most of the early web, tables were used to build entire page layouts - two-column designs, sidebars, navigation, all of it crammed into nested table cells - and that was a genuinely terrible practice that made pages inaccessible, hard to maintain, and impossible to style properly. So when people say 'don't use tables', that's what they mean. The actual correct use of tables - displaying data that has real row-and-column relationships like a spreadsheet does - is completely valid and tables are the right tool for it, semantically and practically. A price comparison, a class schedule, a dataset with headers and values - that's what tables are for and there's no better HTML element for that job.
Key Benefits
Structure- Provides a genuine grid structure for two-dimensional data where rows and columns both carry meaning.Semantics- The table element and its children communicate to browsers, screen readers, and search engines that the data has relational structure.Accessibility- Properly marked up tables with header cells and scope attributes give screen reader users a way to navigate and understand complex data.
Core Components
A basic HTML table uses four tags and they all work together - the table as the outer container, tr for each row, th for header cells, and td for data cells - and once you see the pattern it's fairly intuitive, though writing tables by hand gets tedious fast for anything beyond a few rows.
1<table>
2 <tr>
3 <th>Product</th>
4 <th>Price</th>
5 <th>In Stock</th>
6 </tr>
7 <tr>
8 <td>Widget A</td>
9 <td>$12.99</td>
10 <td>Yes</td>
11 </tr>
12 <tr>
13 <td>Widget B</td>
14 <td>$24.99</td>
15 <td>No</td>
16 </tr>
17</table>Essential Elements
<table>- The outer container for the entire table. Everything else lives inside this.<tr>- Defines a row. Your table is essentially a stack of tr elements, each holding the cells for that row.<th>- A header cell - bold and centered by default. Use these for column headers and row headers, not td, because th has semantic meaning that screen readers announce differently.<td>- A standard data cell. This is where your actual data goes.
Semantic Table Structure
The basic table works but for anything more than a few rows you really want to add thead, tbody, and tfoot to divide the table into sections - and this isn't just for semantics, it has practical benefits too like allowing the tbody to scroll independently while the header stays visible, and giving you clean CSS hooks to style the header and footer rows differently from the data rows. I skipped these for a long time because they felt like extra tags for no reason and then I tried to print a long table and the headers only appeared on the first page, which is a problem that thead fixes automatically in most browsers.
1<table>
2 <thead>
3 <tr>
4 <th>Month</th>
5 <th>Sales</th>
6 <th>Expenses</th>
7 </tr>
8 </thead>
9 <tbody>
10 <tr>
11 <td>January</td>
12 <td>$5,000</td>
13 <td>$3,200</td>
14 </tr>
15 <tr>
16 <td>February</td>
17 <td>$5,800</td>
18 <td>$3,500</td>
19 </tr>
20 </tbody>
21 <tfoot>
22 <tr>
23 <td>Total</td>
24 <td>$10,800</td>
25 <td>$6,700</td>
26 </tr>
27 </tfoot>
28</table>Semantic Elements
<thead>- Groups the header row or rows. In printed tables, browsers repeat these rows at the top of each page automatically.<tbody>- Groups the main data rows. If you only use one of the three sectioning elements, make it this one.<tfoot>- Groups footer rows like totals or summaries. Visually it renders at the bottom even if you write it before tbody in the HTML, which is a slightly odd quirk.
Advanced Table Features
Three features come up enough in real table work that they're worth knowing: the caption element for giving the table a title, colspan and rowspan for merging cells across columns or rows, and colgroup for applying styles to entire columns at once. Colspan and rowspan are the ones that trip people up most - I had to draw a grid on paper the first few times I used them to keep track of which cells were merging where - but they make sense once you think of the numbers as "this cell takes up X columns" rather than some kind of offset.
1<!-- Table with caption -->
2<table>
3 <caption>Monthly Sales Report 2024</caption>
4 <!-- table content -->
5</table>
6
7<!-- Column groups for styling whole columns -->
8<table>
9 <colgroup>
10 <col span="2" style="background-color: #f2f2f2;">
11 <col style="background-color: #e6f7ff;">
12 </colgroup>
13 <!-- table content -->
14</table>
15
16<!-- Cells spanning multiple columns -->
17<table>
18 <tr>
19 <th colspan="2">Name</th>
20 <th>Age</th>
21 </tr>
22 <tr>
23 <td>John</td>
24 <td>Doe</td>
25 <td>30</td>
26 </tr>
27</table>Advanced Features
<caption>- Provides a visible title for the table that's also read by screen readers as the table's label. Goes directly after the opening table tag.colspan and rowspan- colspan makes a cell span multiple columns horizontally, rowspan makes it span multiple rows vertically. The number is how many cells it covers.<colgroup> and <col>- Lets you apply CSS to entire columns without adding a class to every single td in that column - genuinely useful for striping columns or highlighting one column.
Creating Accessible Tables
A basic table with th elements is already more accessible than no headers, but for tables where it's not immediately obvious which header governs which cell - multi-level headers, merged cells, tables where both rows and columns have headers - you need the scope attribute to tell screen readers explicitly how to associate headers with data. The scope attribute on a th can be set to col, row, colgroup, or rowgroup, and it gives a screen reader user navigating cell by cell the context of what header applies to what they're hearing. For very complex tables the headers attribute on td elements gives you even more explicit control, associating each data cell directly with specific header cell IDs.
1<!-- scope attribute on column headers -->
2<table>
3 <tr>
4 <th scope="col">Product</th>
5 <th scope="col">Price</th>
6 <th scope="col">In Stock</th>
7 </tr>
8 <tr>
9 <th scope="row">Widget A</th>
10 <td>$12.99</td>
11 <td>Yes</td>
12 </tr>
13</table>
14
15<!-- headers attribute for complex tables -->
16<table>
17 <tr>
18 <th id="product">Product</th>
19 <th id="price">Price</th>
20 </tr>
21 <tr>
22 <td headers="product">Widget A</td>
23 <td headers="price">$12.99</td>
24 </tr>
25</table>Accessibility Features
scope- Added to th elements to specify whether the header applies to a column (col), row (row), or a group of columns or rows. Simple tables should always have this.headers- Added to td elements in complex tables, referencing the IDs of the th elements that head that cell. More work to write but gives screen readers precise header associations.
Styling Tables with CSS
Default unstyled tables look pretty rough - no borders, inconsistent spacing, no visual separation between rows - so almost every real project adds at least some CSS to make them readable. The border-collapse: collapse declaration is the first thing most people add because without it you get double borders between cells which looks strange and the fix is one line. Zebra striping with tr:nth-child(even) is another very common pattern because alternating row colors make it much easier to track which row you're reading across a wide table, and I find it's one of those small things that makes a data-heavy page feel significantly more polished.
1table {
2 width: 100%;
3 border-collapse: collapse;
4 margin: 1em 0;
5 font-family: sans-serif;
6}
7th, td {
8 border: 1px solid #ddd;
9 padding: 0.75em;
10 text-align: left;
11}
12th {
13 background-color: #f2f2f2;
14 font-weight: bold;
15}
16
17/* Zebra striping */
18tr:nth-child(even) {
19 background-color: #f9f9f9;
20}
21
22/* Hover highlight */
23tr:hover {
24 background-color: #f1f1f1;
25}
26
27/* Responsive wrapper */
28.table-container {
29 overflow-x: auto;
30 max-width: 100%;
31}
32@media screen and (max-width: 600px) {
33 table {
34 font-size: 0.8em;
35 }
36 th, td {
37 padding: 0.5em;
38 }
39}Styling Techniques
border-collapse: collapse- The single most important table CSS declaration - merges the borders between cells so you get one clean line instead of two borders with a gap between them.Zebra striping- Alternating row background colors using tr:nth-child(even) - makes wide tables much easier to read across a row without losing your place.Responsive wrapper- Wrapping the table in a div with overflow-x: auto is the simplest way to handle tables on small screens - the table keeps its structure and the user can scroll horizontally.
Best Practices
The short version of best practices for HTML tables is: use them only when the data genuinely has row-and-column relationships, always use th for header cells with scope attributes, add thead and tbody to divide the structure, wrap wide tables in a scrollable container for mobile, and include a caption if the table's purpose isn't immediately obvious from the surrounding content. The one rule people break most often is using td instead of th for header cells - it looks the same after you add CSS but it breaks accessibility because screen readers announce th cells differently and use them for navigation.
1<!-- Good: genuine tabular data with proper structure -->
2<table>
3 <caption>Q1 Product Sales</caption>
4 <thead>
5 <tr>
6 <th scope="col">Product</th>
7 <th scope="col">Units Sold</th>
8 <th scope="col">Revenue</th>
9 </tr>
10 </thead>
11 <tbody>
12 <tr>
13 <th scope="row">Widget A</th>
14 <td>142</td>
15 <td>$1,843</td>
16 </tr>
17 </tbody>
18</table>
19
20<!-- Wrong: using a table for two-column page layout -->
21<!-- Use CSS Flexbox or Grid for layout instead -->Common Mistakes
Using tables for layout is the big one and has been covered, but there are a few other mistakes that show up regularly. Using td instead of th for header cells is probably the most common technical error - the visual difference with CSS is nothing but semantically they're very different and your table will be harder to navigate with a screen reader. Not adding any headers at all is even worse. The other thing worth mentioning is that old HTML attributes like cellpadding, cellspacing, and border directly on the table tag are deprecated in HTML5 - they still work in most browsers but they're not valid HTML and all that styling should be in your CSS.
1<!-- Wrong: table used for layout -->
2<table>
3 <tr>
4 <td>Sidebar content</td>
5 <td>Main content</td>
6 </tr>
7</table>
8
9<!-- Right: CSS handles layout -->
10<div class="layout">
11 <aside>Sidebar content</aside>
12 <main>Main content</main>
13</div>
14
15<!-- Wrong: td used for column headers -->
16<table>
17 <tr>
18 <td>Product</td>
19 <td>Price</td>
20 </tr>
21</table>
22
23<!-- Right: th with scope for headers -->
24<table>
25 <tr>
26 <th scope="col">Product</th>
27 <th scope="col">Price</th>
28 </tr>
29</table>When to Use Tables
The test for whether something should be a table is whether the data has meaningful relationships in two directions - across rows and down columns - where both directions carry information. A price comparison where each row is a product and each column is a feature, and the intersection of any row and column tells you something specific: that's a table. A list of team members with their job titles: that could go either way, but if you're just showing name and role with no complex cross-referencing it might be cleaner as a definition list or a styled div structure. Navigation menus, card grids, article layouts - those are all CSS layout jobs regardless of how they look visually.
1<!-- Use table for: financial reports, product comparisons,
2 schedules, statistical datasets, anything where both
3 rows AND columns carry meaning -->
4
5<!-- Use CSS Flexbox or Grid for:
6 page structure, card layouts, navigation,
7 anything that's layout rather than data -->
8
9<!-- Use lists for:
10 simple item collections, navigation links,
11 term-definition pairs (use dl) -->Tables: The Right Tool for the Right Data
Tables got a bad reputation from years of being misused for layout and I think that reputation has overcorrected to where some developers avoid them even for genuinely tabular data, which is a mistake in the other direction. For data that has real row-and-column structure, the table element with its thead, tbody, th, and scope attributes gives you semantics, accessibility, and styling hooks that no other HTML structure provides. The key is the scope question: if you removed either the row labels or the column labels and the data still made complete sense, it's probably not a table. If both directions of labeling are needed to understand what any given cell means, it almost certainly is