|$ curl https://forge-ai.dev/api/markdown?path=docs/html/tables
$cat docs/html-tables.md
updated This week·40 min read·published

HTML Tables

HTMLTablesIntermediateIntermediate
Introduction

HTML tables present structured data in rows and columns. When used correctly, they provide a clear, accessible way to display relationships between data points — financial reports, schedules, comparison matrices, and statistical data. HTML tables are designed for tabular data, NOT for page layout.

Core Structure

Every table is composed of rows (<tr>), header cells (<th>), and data cells (<td>). The <caption> provides a title. Headers, body, and footer are grouped with <thead>, <tbody>, and <tfoot>.

basic-table.html
HTML
1<table>
2 <caption>
3 Monthly Savings Report — Q1 2026
4 </caption>
5 <thead>
6 <tr>
7 <th scope="col">Month</th>
8 <th scope="col">Income</th>
9 <th scope="col">Expenses</th>
10 <th scope="col">Savings</th>
11 </tr>
12 </thead>
13 <tbody>
14 <tr>
15 <th scope="row">January</th>
16 <td>$5,000</td>
17 <td>$3,200</td>
18 <td>$1,800</td>
19 </tr>
20 <tr>
21 <th scope="row">February</th>
22 <td>$5,200</td>
23 <td>$3,500</td>
24 <td>$1,700</td>
25 </tr>
26 <tr>
27 <th scope="row">March</th>
28 <td>$5,400</td>
29 <td>$3,100</td>
30 <td>$2,300</td>
31 </tr>
32 </tbody>
33 <tfoot>
34 <tr>
35 <th scope="row">Total</th>
36 <td>$15,600</td>
37 <td>$9,800</td>
38 <td>$5,800</td>
39 </tr>
40 </tfoot>
41</table>

best practice

NEVER use tables for page layout. Use CSS Grid or Flexbox for layout purposes. Tables create rigid, non-responsive layouts that are inaccessible and difficult to maintain. Reserve tables strictly for tabular data.
Table Elements

The HTML table specification includes a rich set of elements for structuring data semantically. Each element serves a specific purpose for accessibility, styling, and content organization.

ElementPurposeKey Attributes
<table>Table container
<caption>Table title / description
<thead>Header rows group
<tbody>Body rows group
<tfoot>Footer rows group
<tr>Table row
<th>Header cellscope, abbr, colspan, rowspan
<td>Data cellcolspan, rowspan, headers
<col>Column definitionspan, style/class
<colgroup>Column group containerspan

The scope attribute on <th> defines whether the header applies to a column (col), row (row), or group (colgroup, rowgroup). The abbr attribute provides a short version of the header for screen readers.

table-scope.html
HTML
1<table>
2 <caption>Employee Directory</caption>
3 <thead>
4 <tr>
5 <th scope="col" abbr="ID">Employee ID</th>
6 <th scope="col">Full Name</th>
7 <th scope="col">Department</th>
8 <th scope="col">Role</th>
9 <th scope="col">Status</th>
10 </tr>
11 </thead>
12 <tbody>
13 <tr>
14 <th scope="row">EMP-001</th>
15 <td>Jane Doe</td>
16 <td>Engineering</td>
17 <td>Frontend Lead</td>
18 <td>Active</td>
19 </tr>
20 <tr>
21 <th scope="row">EMP-002</th>
22 <td>John Smith</td>
23 <td>Design</td>
24 <td>UI Designer</td>
25 <td>Active</td>
26 </tr>
27 <tr>
28 <th scope="row">EMP-003</th>
29 <td>Alice Johnson</td>
30 <td>Marketing</td>
31 <td>Content Strategist</td>
32 <td>On Leave</td>
33 </tr>
34 </tbody>
35</table>

info

Always use scope="col" on column headers and scope="row" on row headers. This is the single most impactful thing you can do for table accessibility — it lets screen readers correctly announce which header applies to each cell.
Column & Row Grouping

Grouping elements organize the table into logical sections. <colgroup> and <col> style entire columns without repeating classes on every cell. <thead>, <tbody>, and <tfoot> segment rows into header, body, and footer groups.

colgroup.html
HTML
1<table>
2 <caption>Quarterly Revenue by Region</caption>
3 <colgroup>
4 <col span="1" style="background: rgba(0, 255, 65, 0.03);" />
5 <col span="2" style="background: rgba(0, 255, 65, 0.06);" />
6 <col span="1" />
7 </colgroup>
8 <thead>
9 <tr>
10 <th scope="col">Region</th>
11 <th scope="col">Q1</th>
12 <th scope="col">Q2</th>
13 <th scope="col">Total</th>
14 </tr>
15 </thead>
16 <tbody>
17 <tr>
18 <th scope="row">North America</th>
19 <td>$245K</td>
20 <td>$312K</td>
21 <td>$557K</td>
22 </tr>
23 <tr>
24 <th scope="row">Europe</th>
25 <td>$189K</td>
26 <td>$223K</td>
27 <td>$412K</td>
28 </tr>
29 <tr>
30 <th scope="row">Asia Pacific</th>
31 <td>$167K</td>
32 <td>$198K</td>
33 <td>$365K</td>
34 </tr>
35 </tbody>
36 <tfoot>
37 <tr>
38 <th scope="row">Total</th>
39 <td>$601K</td>
40 <td>$733K</td>
41 <td>$1.33M</td>
42 </tr>
43 </tfoot>
44</table>

Live preview of a table with column grouping and sectioned rows:

preview
🔥

pro tip

The <col> element only supports a limited set of CSS properties: border, background, width, and visibility. For more control, apply classes directly to cells or use CSS pseudo-classes like :nth-child.
Spanning Cells

The colspan and rowspan attributes let cells span multiple columns or rows. This is essential for complex tables with merged headers, grouped categories, or summary cells.

cell-spanning.html
HTML
1<table>
2 <caption>Employee Schedule — Week 28</caption>
3 <thead>
4 <tr>
5 <th scope="col">Employee</th>
6 <th scope="col">Mon</th>
7 <th scope="col">Tue</th>
8 <th scope="col">Wed</th>
9 <th scope="col">Thu</th>
10 <th scope="col">Fri</th>
11 </tr>
12 </thead>
13 <tbody>
14 <tr>
15 <th scope="row">Jane</th>
16 <td colspan="2">Remote AM</td>
17 <td>Office</td>
18 <td>Office</td>
19 <td>Remote</td>
20 </tr>
21 <tr>
22 <th scope="row">John</th>
23 <td>Office</td>
24 <td colspan="3">Training Workshop</td>
25 <td>Office</td>
26 </tr>
27 <tr>
28 <th scope="row">Alice</th>
29 <td colspan="5" style="text-align:center;">On Vacation</td>
30 </tr>
31 <tr>
32 <th scope="row">Bob</th>
33 <td>Office</td>
34 <td>Office</td>
35 <td rowspan="2">Project Sync</td>
36 <td>Office</td>
37 <td>Remote</td>
38 </tr>
39 <tr>
40 <th scope="row">Carol</th>
41 <td>Remote</td>
42 <td>Remote</td>
43 <td>Office</td>
44 <td>Office</td>
45 </tr>
46 </tbody>
47</table>

Live preview of a table with colspan and rowspan:

preview

warning

Use colspan and rowspan sparingly. Cells that span multiple rows create accessibility challenges because screen readers may have difficulty associating them with the correct headers. When using rowspan, verify the total number of cells remains consistent across rows.
Table Styling

CSS provides several properties specifically for table layout. Understanding how these interact is key to creating well-designed, readable tables.

PropertyValuesEffect
border-collapsecollapse | separateAdjacent borders share or remain separate
border-spacing<length>Gap between cells (separate mode only)
empty-cellsshow | hideShow/hide borders of empty cells
table-layoutauto | fixedColumn width algorithm
caption-sidetop | bottomCaption position relative to table
vertical-aligntop | middle | bottomVertical alignment of cell content
table-styling.css
CSS
1/* Modern table reset */
2table {
3 border-collapse: collapse;
4 border-spacing: 0;
5 width: 100%;
6 table-layout: auto;
7 caption-side: top;
8}
9
10caption {
11 text-align: left;
12 font-weight: 600;
13 padding: 8px 0;
14 color: #1a1a1a;
15}
16
17/* Header styling */
18thead th {
19 background: #f8f9fa;
20 font-weight: 600;
21 text-transform: uppercase;
22 font-size: 11px;
23 letter-spacing: 0.5px;
24 padding: 12px 16px;
25 text-align: left;
26 border-bottom: 2px solid #dee2e6;
27 white-space: nowrap;
28}
29
30/* Body cell styling */
31tbody td,
32tbody th {
33 padding: 10px 16px;
34 border-bottom: 1px solid #e9ecef;
35 vertical-align: middle;
36}
37
38/* Row hover */
39tbody tr:hover {
40 background: #f8f9fa;
41}
42
43/* Striped rows */
44tbody tr:nth-child(even) {
45 background: #fcfcfc;
46}
47
48/* Fixed layout for consistent column widths */
49table.fixed {
50 table-layout: fixed;
51}
52
53table.fixed th,
54table.fixed td {
55 overflow: hidden;
56 text-overflow: ellipsis;
57 white-space: nowrap;
58}
59
60/* Empty cell handling */
61table.separate {
62 border-collapse: separate;
63 border-spacing: 4px;
64 empty-cells: hide;
65}
66
67/* Numeric column alignment */
68td.numeric,
69th.numeric {
70 text-align: right;
71 font-variant-numeric: tabular-nums;
72}

best practice

Use border-collapse: collapse for most tables — it eliminates double borders and creates cleaner lines. Reserve separate with border-spacing for tables where you want visible gaps between cells. Use table-layout: fixed for performance on large tables — the browser computes column widths in a single pass.
Responsive Tables

Tables inherently resist small screens. The most straightforward responsive technique is horizontal scrolling with overflow-x: auto on a wrapper. For better mobile experiences, consider transforming rows into stacked cards using data attributes.

TechniqueProsCons
Horizontal scrollSimple, preserves full dataRequires horizontal scrolling
Card layout (CSS)Mobile-optimized, readableJavaScript or complex CSS needed
Hidden columnsShows most important dataHides potentially useful columns
Font-size reductionSimple CSS onlyCan make text too small
responsive-tables.html
HTML
1<!-- Horizontal scroll wrapper -->
2<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
3 <table>
4 <!-- table content -->
5 </table>
6</div>
7
8<!-- Stacked card layout for mobile via CSS -->
9<style>
10 @media (max-width: 600px) {
11 table.responsive-card thead {
12 position: absolute;
13 width: 1px;
14 height: 1px;
15 overflow: hidden;
16 clip: rect(0, 0, 0, 0);
17 }
18
19 table.responsive-card tbody tr {
20 display: block;
21 margin-bottom: 16px;
22 border: 1px solid #ddd;
23 border-radius: 8px;
24 padding: 12px;
25 }
26
27 table.responsive-card td,
28 table.responsive-card th {
29 display: block;
30 text-align: right;
31 padding: 6px 8px;
32 border: none;
33 }
34
35 table.responsive-card td::before {
36 content: attr(data-label);
37 float: left;
38 font-weight: 600;
39 text-transform: uppercase;
40 font-size: 11px;
41 }
42 }
43</style>
44
45<table class="responsive-card">
46 <thead>
47 <tr>
48 <th>Name</th>
49 <th>Role</th>
50 <th>Department</th>
51 <th>Status</th>
52 </tr>
53 </thead>
54 <tbody>
55 <tr>
56 <td data-label="Name">Jane Doe</td>
57 <td data-label="Role">Engineer</td>
58 <td data-label="Dept.">Engineering</td>
59 <td data-label="Status">Active</td>
60 </tr>
61 </tbody>
62</table>
🔥

pro tip

For the card layout approach, add a data-label attribute to every <td> that matches its header text. The CSS ::before pseudo-element displays this attribute as the label on small screens, making each row read like a labeled card.

Live preview of a responsive table showing how it adapts on smaller screens (resize viewport):

preview
Sortable Tables

Making tables sortable by column improves usability for data-heavy pages. The simplest approach uses a click handler on table headers that sorts the rows client-side using JavaScript.

sortable-table.html
HTML
1<table id="sortableTable">
2 <thead>
3 <tr>
4 <th onclick="sortTable(0)" style="cursor:pointer;">
5 Name <span class="sort-icon">↕</span>
6 </th>
7 <th onclick="sortTable(1)" style="cursor:pointer;">
8 Age <span class="sort-icon">↕</span>
9 </th>
10 <th onclick="sortTable(2)" style="cursor:pointer;">
11 Score <span class="sort-icon">↕</span>
12 </th>
13 </tr>
14 </thead>
15 <tbody>
16 <tr><td>Alice</td><td>28</td><td>92</td></tr>
17 <tr><td>Bob</td><td>35</td><td>78</td></tr>
18 <tr><td>Charlie</td><td>22</td><td>95</td></tr>
19 </tbody>
20</table>
21
22<script>
23 let sortDirections = {};
24
25 function sortTable(colIndex) {
26 const table = document.getElementById('sortableTable');
27 const tbody = table.querySelector('tbody');
28 const rows = Array.from(tbody.querySelectorAll('tr'));
29
30 // Toggle sort direction
31 const direction = sortDirections[colIndex] === 'asc' ? 'desc' : 'asc';
32 sortDirections[colIndex] = direction;
33
34 rows.sort((a, b) => {
35 const aVal = a.cells[colIndex].textContent.trim();
36 const bVal = b.cells[colIndex].textContent.trim();
37
38 // Detect numeric vs text
39 const aNum = parseFloat(aVal);
40 const bNum = parseFloat(bVal);
41 const isNumeric = !isNaN(aNum) && !isNaN(bNum);
42
43 if (isNumeric) {
44 return direction === 'asc' ? aNum - bNum : bNum - aNum;
45 }
46 return direction === 'asc'
47 ? aVal.localeCompare(bVal)
48 : bVal.localeCompare(aVal);
49 });
50
51 // Re-append sorted rows
52 rows.forEach((row) => tbody.appendChild(row));
53
54 // Update sort icons
55 table.querySelectorAll('.sort-icon').forEach((icon) => {
56 icon.textContent = '↕';
57 });
58 const headerRow = table.querySelectorAll('th');
59 const icon = headerRow[colIndex].querySelector('.sort-icon');
60 icon.textContent = direction === 'asc' ? '↑' : '↓';
61 }
62</script>

info

For production applications, consider using a library like Tabulator, AG Grid, or DataTables for advanced features like multi-column sort, filtering, pagination, and virtual scrolling. The vanilla JavaScript approach shown here is ideal for simple, dependency-free tables.

Live preview of a sortable table — click column headers to sort:

preview
Accessibility

Accessible tables are critical for screen reader users. Proper use of scope, caption, headers, and ARIA attributes makes tabular data navigable and understandable for all users.

TechniqueImplementationImpact
scopescope="col" / "row" on <th>Screen reader announces header for each cell
caption<caption>Table description</caption>Provides table title or summary
headers attributeheaders="id1 id2" on <td>Explicit association for complex tables
scope="colgroup"on <th> spanning columnsHeader applies to a column group
role="grid"role="grid" on <table>Interactive table with keyboard navigation
aria-sortaria-sort="ascending" on <th>Announces sort direction to screen readers
role="rowgroup"on <thead>/<tbody>/<tfoot>Explicit group semantics
accessible-tables.html
HTML
1<!-- Fully accessible table -->
2<table aria-label="Sales Revenue by Quarter and Region">
3 <caption>
4 Sales revenue figures for fiscal year 2026,
5 broken down by quarter and geographical region.
6 </caption>
7 <thead>
8 <tr>
9 <th scope="col" id="region">Region</th>
10 <th scope="col" id="q1">Q1 2026</th>
11 <th scope="col" id="q2">Q2 2026</th>
12 <th scope="col" id="q3">Q3 2026</th>
13 <th scope="col" id="q4">Q4 2026</th>
14 </tr>
15 </thead>
16 <tbody>
17 <tr>
18 <th scope="row" id="na">North America</th>
19 <td headers="q1 na">$1.2M</td>
20 <td headers="q2 na">$1.4M</td>
21 <td headers="q3 na">$1.3M</td>
22 <td headers="q4 na">$1.6M</td>
23 </tr>
24 <tr>
25 <th scope="row" id="eu">Europe</th>
26 <td headers="q1 eu">$890K</td>
27 <td headers="q2 eu">$950K</td>
28 <td headers="q3 eu">$1.1M</td>
29 <td headers="q4 eu">$1.0M</td>
30 </tr>
31 <tr>
32 <th scope="row" id="apac">Asia Pacific</th>
33 <td headers="q1 apac">$720K</td>
34 <td headers="q2 apac">$810K</td>
35 <td headers="q3 apac">$760K</td>
36 <td headers="q4 apac">$940K</td>
37 </tr>
38 </tbody>
39</table>
40
41<!-- Sortable accessible headers -->
42<th
43 scope="col"
44 aria-sort="ascending"
45 role="columnheader"
46 tabindex="0"
47>
48 Name
49</th>

best practice

For simple tables (single level of headers), scope is sufficient. For complex tables with multiple header levels, use the headers attribute on <td> to explicitly reference header cell IDs. The aria-sort attribute should be updated dynamically when sort state changes.
Best Practices

When to Use Tables vs. CSS Grid / Flexbox

The fundamental rule: use HTML tables for tabular data, CSS Grid for two-dimensional layout, and Flexbox for one-dimensional layout. Tables convey relationships between data points across rows and columns; layout systems position elements visually. Mixing these purposes leads to inaccessible, hard-to-maintain code.

Use CaseBest ApproachRationale
Financial dataHTML TableRows and columns have semantic relationships
Calendar / scheduleHTML Table (data) or Grid (layout)Depends on semantic vs visual nature
Page layout (header/sidebar/main)CSS GridLayout only — no tabular relationship
Card gridCSS GridVisual alignment, no row/column relation
Form field layoutCSS Grid or FlexboxLayout alignment, not tabular data
Product comparisonHTML TableData relationships across products
Navigation menusFlexboxOne-dimensional layout

Performance Considerations

Use table-layout: fixed for large tables — browser computes column widths in one pass instead of two, dramatically improving render time on tables with hundreds of rows
Virtual scrolling: for tables with thousands of rows, render only visible rows using intersection observers or libraries like TanStack Virtual
Avoid nesting tables — nested tables create exponentially more DOM nodes and are extremely difficult to style responsively
Use CSS content-visibility: auto on table sections to defer rendering of off-screen rows
Paginate or use infinite scroll for datasets exceeding 500 rows — users cannot meaningfully process more than that without search/filter tools
Apply will-change: transform on scrollable wrappers to promote to a compositor layer for smoother horizontal scrolling
Minimize DOM reflows by batching cell content updates and using document fragments for dynamic table population

Live preview showing a production-ready table with all best practices applied — proper scope, caption, hover states, striping, and numeric alignment:

preview
$Blueprint — Engineering Documentation·Section ID: HTML-04·Revision: 1.0