HTML Tables
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.
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>.
| 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
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.
| Element | Purpose | Key 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 cell | scope, abbr, colspan, rowspan |
| <td> | Data cell | colspan, rowspan, headers |
| <col> | Column definition | span, style/class |
| <colgroup> | Column group container | span |
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.
| 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
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.
| 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:
pro tip
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.
| 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:
warning
CSS provides several properties specifically for table layout. Understanding how these interact is key to creating well-designed, readable tables.
| Property | Values | Effect |
|---|---|---|
| border-collapse | collapse | separate | Adjacent borders share or remain separate |
| border-spacing | <length> | Gap between cells (separate mode only) |
| empty-cells | show | hide | Show/hide borders of empty cells |
| table-layout | auto | fixed | Column width algorithm |
| caption-side | top | bottom | Caption position relative to table |
| vertical-align | top | middle | bottom | Vertical alignment of cell content |
| 1 | /* Modern table reset */ |
| 2 | table { |
| 3 | border-collapse: collapse; |
| 4 | border-spacing: 0; |
| 5 | width: 100%; |
| 6 | table-layout: auto; |
| 7 | caption-side: top; |
| 8 | } |
| 9 | |
| 10 | caption { |
| 11 | text-align: left; |
| 12 | font-weight: 600; |
| 13 | padding: 8px 0; |
| 14 | color: #1a1a1a; |
| 15 | } |
| 16 | |
| 17 | /* Header styling */ |
| 18 | thead 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 */ |
| 31 | tbody td, |
| 32 | tbody th { |
| 33 | padding: 10px 16px; |
| 34 | border-bottom: 1px solid #e9ecef; |
| 35 | vertical-align: middle; |
| 36 | } |
| 37 | |
| 38 | /* Row hover */ |
| 39 | tbody tr:hover { |
| 40 | background: #f8f9fa; |
| 41 | } |
| 42 | |
| 43 | /* Striped rows */ |
| 44 | tbody tr:nth-child(even) { |
| 45 | background: #fcfcfc; |
| 46 | } |
| 47 | |
| 48 | /* Fixed layout for consistent column widths */ |
| 49 | table.fixed { |
| 50 | table-layout: fixed; |
| 51 | } |
| 52 | |
| 53 | table.fixed th, |
| 54 | table.fixed td { |
| 55 | overflow: hidden; |
| 56 | text-overflow: ellipsis; |
| 57 | white-space: nowrap; |
| 58 | } |
| 59 | |
| 60 | /* Empty cell handling */ |
| 61 | table.separate { |
| 62 | border-collapse: separate; |
| 63 | border-spacing: 4px; |
| 64 | empty-cells: hide; |
| 65 | } |
| 66 | |
| 67 | /* Numeric column alignment */ |
| 68 | td.numeric, |
| 69 | th.numeric { |
| 70 | text-align: right; |
| 71 | font-variant-numeric: tabular-nums; |
| 72 | } |
best practice
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.
| Technique | Pros | Cons |
|---|---|---|
| Horizontal scroll | Simple, preserves full data | Requires horizontal scrolling |
| Card layout (CSS) | Mobile-optimized, readable | JavaScript or complex CSS needed |
| Hidden columns | Shows most important data | Hides potentially useful columns |
| Font-size reduction | Simple CSS only | Can make text too small |
| 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
Live preview of a responsive table showing how it adapts on smaller screens (resize viewport):
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.
| 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
Live preview of a sortable table — click column headers to sort:
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.
| Technique | Implementation | Impact |
|---|---|---|
| scope | scope="col" / "row" on <th> | Screen reader announces header for each cell |
| caption | <caption>Table description</caption> | Provides table title or summary |
| headers attribute | headers="id1 id2" on <td> | Explicit association for complex tables |
| scope="colgroup" | on <th> spanning columns | Header applies to a column group |
| role="grid" | role="grid" on <table> | Interactive table with keyboard navigation |
| aria-sort | aria-sort="ascending" on <th> | Announces sort direction to screen readers |
| role="rowgroup" | on <thead>/<tbody>/<tfoot> | Explicit group semantics |
| 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
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 Case | Best Approach | Rationale |
|---|---|---|
| Financial data | HTML Table | Rows and columns have semantic relationships |
| Calendar / schedule | HTML Table (data) or Grid (layout) | Depends on semantic vs visual nature |
| Page layout (header/sidebar/main) | CSS Grid | Layout only — no tabular relationship |
| Card grid | CSS Grid | Visual alignment, no row/column relation |
| Form field layout | CSS Grid or Flexbox | Layout alignment, not tabular data |
| Product comparison | HTML Table | Data relationships across products |
| Navigation menus | Flexbox | One-dimensional layout |
Performance Considerations
Live preview showing a production-ready table with all best practices applied — proper scope, caption, hover states, striping, and numeric alignment: