Tables and Tabular Data
1. Table Structure (table, thead, tbody, tfoot)
| Element | Purpose | Parent | Required |
|---|---|---|---|
| <table> | Root table container | Any block element | Yes |
| <caption> | Table title/description | <table> (first child) | Recommended |
| <thead> | Table header section | <table> | Optional |
| <tbody> | Table body section (main data) | <table> | Optional (implicit) |
| <tfoot> | Table footer section (summary) | <table> | Optional |
| <tr> | Table row | <thead>, <tbody>, <tfoot>, <table> | Yes |
| <th> | Header cell | <tr> | In header rows |
| <td> | Data cell | <tr> | In data rows |
| <colgroup> | Group of columns for styling | <table> (after caption, before thead) | Optional |
| <col> | Single column definition | <colgroup> | Optional |
Table Attributes
| Attribute | Element | Purpose |
|---|---|---|
| border | <table> | Border width (deprecated, use CSS) |
| cellpadding | <table> | Cell padding (deprecated, use CSS) |
| cellspacing | <table> | Cell spacing (deprecated, use CSS) |
| width | <table>, <col> | Table/column width (use CSS) |
Section Order:
<caption>(optional, first)<colgroup>(optional)<thead>(optional)<tbody>(optional, can have multiple)<tfoot>(optional, displays at bottom)
Note: <tfoot> can be placed before
<tbody> in HTML but will render at the bottom.
Example: Proper table structure
<!-- Complete table structure -->
<table>
<caption>Quarterly Sales Report 2024</caption>
<colgroup>
<col style="background-color: #f0f0f0;">
<col span="3" style="background-color: #fff;">
</colgroup>
<thead>
<tr>
<th>Quarter</th>
<th>Revenue</th>
<th>Expenses</th>
<th>Profit</th>
</tr>
</thead>
<tbody>
<tr>
<th>Q1</th>
<td>$100,000</td>
<td>$60,000</td>
<td>$40,000</td>
</tr>
<tr>
<th>Q2</th>
<td>$120,000</td>
<td>$65,000</td>
<td>$55,000</td>
</tr>
<tr>
<th>Q3</th>
<td>$110,000</td>
<td>$62,000</td>
<td>$48,000</td>
</tr>
<tr>
<th>Q4</th>
<td>$150,000</td>
<td>$70,000</td>
<td>$80,000</td>
</tr>
</tbody>
<tfoot>
<tr>
<th>Total</th>
<td>$480,000</td>
<td>$257,000</td>
<td>$223,000</td>
</tr>
</tfoot>
</table>
<!-- Simple table (minimal structure) -->
<table>
<caption>Employee Contact List</caption>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
</tr>
<tr>
<td>John Doe</td>
<td>john@example.com</td>
<td>555-1234</td>
</tr>
<tr>
<td>Jane Smith</td>
<td>jane@example.com</td>
<td>555-5678</td>
</tr>
</table>
<!-- Multiple tbody sections -->
<table>
<caption>Departments</caption>
<thead>
<tr>
<th>Employee</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<th colspan="2">Engineering</th>
</tr>
<tr>
<td>Alice</td>
<td>Developer</td>
</tr>
<tr>
<td>Bob</td>
<td>Designer</td>
</tr>
</tbody>
<tbody>
<tr>
<th colspan="2">Sales</th>
</tr>
<tr>
<td>Charlie</td>
<td>Manager</td>
</tr>
<tr>
<td>Diana</td>
<td>Representative</td>
</tr>
</tbody>
</table>
Note: Use
<thead>, <tbody>, and
<tfoot> for accessibility and to enable features like fixed headers on scroll. Browser
implicitly creates <tbody> if omitted.
2. Table Rows and Cells (tr, td, th)
| Element | Attributes | Purpose | Use Case |
|---|---|---|---|
| <tr> | class, id, style | Define table row | Container for cells |
| <th> | scope, colspan, rowspan, headers | Header cell (bold, centered) | Column/row headers |
| <td> | colspan, rowspan, headers | Data cell | Table data values |
Example: Table rows and cells with scope
<!-- Column headers with scope -->
<table>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Price</th>
<th scope="col">Quantity</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>Widget</td>
<td>$10.00</td>
<td>5</td>
<td>$50.00</td>
</tr>
<tr>
<td>Gadget</td>
<td>$25.00</td>
<td>2</td>
<td>$50.00</td>
</tr>
</tbody>
</table>
<!-- Row headers with scope -->
<table>
<caption>Monthly Expenses</caption>
<thead>
<tr>
<th></th>
<th scope="col">January</th>
<th scope="col">February</th>
<th scope="col">March</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Rent</th>
<td>$1,500</td>
<td>$1,500</td>
<td>$1,500</td>
</tr>
<tr>
<th scope="row">Utilities</th>
<td>$200</td>
<td>$220</td>
<td>$180</td>
</tr>
<tr>
<th scope="row">Food</th>
<td>$600</td>
<td>$650</td>
<td>$580</td>
</tr>
</tbody>
</table>
<!-- Complex table with headers attribute -->
<table>
<tr>
<th id="name">Name</th>
<th id="math">Math</th>
<th id="science">Science</th>
</tr>
<tr>
<td headers="name">Alice</td>
<td headers="math">95</td>
<td headers="science">88</td>
</tr>
<tr>
<td headers="name">Bob</td>
<td headers="math">87</td>
<td headers="science">92</td>
</tr>
</table>
<!-- Styled cells -->
<style>
table {
border-collapse: collapse;
width: 100%;
}
th, td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #4caf50;
color: white;
text-align: center;
}
tr:nth-child(even) {
background-color: #f2f2f2;
}
tr:hover {
background-color: #ddd;
}
</style>
Warning: Always use
scope attribute on <th> elements for
accessibility. Screen readers use this to associate headers with data cells. Use scope="col" for
column headers and scope="row" for row headers.
3. Cell Spanning (colspan, rowspan)
| Attribute | Applies To | Purpose | Default |
|---|---|---|---|
| colspan | <td>, <th> | Span across multiple columns | 1 |
| rowspan | <td>, <th> | Span across multiple rows | 1 |
Example: Colspan and rowspan usage
<!-- Colspan: Header spanning columns -->
<table>
<thead>
<tr>
<th colspan="3">Student Performance</th>
</tr>
<tr>
<th>Name</th>
<th>Math</th>
<th>Science</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>95</td>
<td>88</td>
</tr>
</tbody>
</table>
<!-- Rowspan: Merged rows -->
<table>
<tr>
<th rowspan="2">Name</th>
<th colspan="2">Scores</th>
</tr>
<tr>
<!-- Name cell continues from above -->
<th>Test 1</th>
<th>Test 2</th>
</tr>
<tr>
<td>John</td>
<td>85</td>
<td>90</td>
</tr>
</table>
<!-- Combined colspan and rowspan -->
<table>
<caption>Complex Schedule</caption>
<thead>
<tr>
<th>Time</th>
<th>Monday</th>
<th>Tuesday</th>
<th>Wednesday</th>
</tr>
</thead>
<tbody>
<tr>
<th>9:00 AM</th>
<td rowspan="2">Team Meeting</td>
<td>Project A</td>
<td>Project B</td>
</tr>
<tr>
<th>10:00 AM</th>
<!-- Team Meeting continues -->
<td>Code Review</td>
<td>Planning</td>
</tr>
<tr>
<th>11:00 AM</th>
<td colspan="3">Lunch Break</td>
</tr>
</tbody>
</table>
<!-- Pricing table with spanning -->
<table>
<thead>
<tr>
<th rowspan="2">Feature</th>
<th colspan="3">Plans</th>
</tr>
<tr>
<th>Basic</th>
<th>Pro</th>
<th>Enterprise</th>
</tr>
</thead>
<tbody>
<tr>
<td>Storage</td>
<td>10 GB</td>
<td>100 GB</td>
<td>Unlimited</td>
</tr>
<tr>
<td>Users</td>
<td>1</td>
<td>5</td>
<td>Unlimited</td>
</tr>
<tr>
<td>Support</td>
<td>Email</td>
<td colspan="2">24/7 Phone & Email</td>
</tr>
</tbody>
</table>
Note: When using
rowspan, omit cells in subsequent rows that are spanned. When
using colspan, ensure the total columns in each row match. Complex spanning can make tables
difficult to maintain and less accessible.
4. Table Captions and Summaries
| Element/Attribute | Purpose | Placement | Accessibility |
|---|---|---|---|
| <caption> | Table title/description | First child of <table> | Announced by screen readers |
| summary (deprecated) | Table description for screen readers | <table summary="..."> | Use <caption> or aria-describedby instead |
| aria-label | Accessible name for table | <table aria-label="..."> | Alternative to caption |
| aria-describedby | Reference to description element | <table aria-describedby="id"> | Link to external description |
Caption Styling (CSS)
| Property | Values |
|---|---|
| caption-side | top (default), bottom |
| text-align | left, center, right |
| font-weight | normal, bold |
Best Practices:
- Use
<caption>for all data tables - Keep captions concise and descriptive
- Position caption at top for visibility
- Use aria-describedby for longer descriptions
- Avoid
summaryattribute (deprecated)
Example: Table captions and descriptions
<!-- Basic caption -->
<table>
<caption>Monthly Sales Report - Q4 2024</caption>
<thead>
<tr>
<th>Month</th>
<th>Revenue</th>
<th>Growth</th>
</tr>
</thead>
<tbody>
<tr>
<td>October</td>
<td>$150,000</td>
<td>+15%</td>
</tr>
</tbody>
</table>
<!-- Styled caption -->
<style>
caption {
caption-side: top;
font-size: 1.2em;
font-weight: bold;
margin-bottom: 10px;
text-align: left;
color: #333;
}
</style>
<table>
<caption>Employee Directory</caption>
<!-- table content -->
</table>
<!-- Caption at bottom -->
<style>
.bottom-caption caption {
caption-side: bottom;
font-style: italic;
margin-top: 10px;
}
</style>
<table class="bottom-caption">
<caption>Data as of December 2024</caption>
<!-- table content -->
</table>
<!-- Using aria-describedby for detailed description -->
<p id="table-desc">
This table shows quarterly performance metrics including revenue,
expenses, and profit margins for each department. Data is sorted
by revenue in descending order.
</p>
<table aria-describedby="table-desc">
<caption>2024 Quarterly Performance by Department</caption>
<thead>
<tr>
<th>Department</th>
<th>Revenue</th>
<th>Expenses</th>
<th>Profit Margin</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sales</td>
<td>$500,000</td>
<td>$200,000</td>
<td>60%</td>
</tr>
</tbody>
</table>
<!-- Using aria-label (when caption is not desired visually) -->
<table aria-label="Customer feedback ratings by product">
<!-- No visible caption, but screen readers announce the label -->
<thead>
<tr>
<th>Product</th>
<th>Rating</th>
<th>Reviews</th>
</tr>
</thead>
</table>
<!-- Caption with formatting -->
<table>
<caption>
<strong>Financial Summary</strong><br>
<small>All amounts in USD</small>
</caption>
<!-- table content -->
</table>
Warning: The
summary attribute is deprecated in
HTML5. Use <caption> for short descriptions or aria-describedby to reference a
detailed description elsewhere in the page.
5. Sortable Tables and Interactive Features
| Feature | Implementation | Use Case | Accessibility |
|---|---|---|---|
| Sortable Columns | JavaScript + click events on <th> | Sort data by column | Add aria-sort attribute |
| Filterable Rows | JavaScript + input field | Search/filter table data | Announce filtered count |
| Expandable Rows | JavaScript + detail rows | Show/hide additional details | Use aria-expanded |
| Selectable Rows | Checkboxes in first column | Multi-select for bulk actions | Proper label association |
| Pagination | JavaScript + page controls | Handle large datasets | Announce current page |
ARIA Attributes for Tables
| Attribute | Purpose |
|---|---|
| aria-sort | ascending, descending, none |
| aria-expanded | true/false for expandable rows |
| aria-selected | true/false for selected rows |
| aria-rowcount | Total rows (for pagination) |
| aria-rowindex | Current row index |
JavaScript Table Libraries:
- DataTables: Feature-rich table plugin
- Tabulator: Interactive tables with filtering
- AG Grid: Enterprise data grid
- TanStack Table: Headless table library
- SortableJS: Drag-and-drop sorting
Example: Sortable table with JavaScript
<!-- Sortable table -->
<table id="sortableTable">
<caption>Employee List (Click headers to sort)</caption>
<thead>
<tr>
<th onclick="sortTable(0)" style="cursor: pointer;" aria-sort="none">
Name ↕
</th>
<th onclick="sortTable(1)" style="cursor: pointer;" aria-sort="none">
Department ↕
</th>
<th onclick="sortTable(2)" style="cursor: pointer;" aria-sort="none">
Salary ↕
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>Engineering</td>
<td>90000</td>
</tr>
<tr>
<td>Bob</td>
<td>Sales</td>
<td>75000</td>
</tr>
<tr>
<td>Charlie</td>
<td>Marketing</td>
<td>80000</td>
</tr>
</tbody>
</table>
<script>
function sortTable(columnIndex) {
const table = document.getElementById('sortableTable');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const header = table.querySelectorAll('th')[columnIndex];
// Determine sort direction
const currentSort = header.getAttribute('aria-sort');
const ascending = currentSort !== 'ascending';
// Sort rows
rows.sort((a, b) => {
const aValue = a.cells[columnIndex].textContent;
const bValue = b.cells[columnIndex].textContent;
// Numeric comparison for salary column
if (columnIndex === 2) {
return ascending
? Number(aValue) - Number(bValue)
: Number(bValue) - Number(aValue);
}
// String comparison
return ascending
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
// Re-append sorted rows
rows.forEach(row => tbody.appendChild(row));
// Update ARIA attributes
table.querySelectorAll('th').forEach(th => {
th.setAttribute('aria-sort', 'none');
});
header.setAttribute('aria-sort', ascending ? 'ascending' : 'descending');
}
</script>
<!-- Filterable table -->
<input type="text" id="searchInput" placeholder="Search table..."
onkeyup="filterTable()">
<table id="filterableTable">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>City</th>
</tr>
</thead>
<tbody>
<tr>
<td>John Doe</td>
<td>john@example.com</td>
<td>New York</td>
</tr>
<tr>
<td>Jane Smith</td>
<td>jane@example.com</td>
<td>Los Angeles</td>
</tr>
</tbody>
</table>
<script>
function filterTable() {
const input = document.getElementById('searchInput');
const filter = input.value.toLowerCase();
const table = document.getElementById('filterableTable');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(filter) ? '' : 'none';
});
}
</script>
<!-- Selectable rows with checkboxes -->
<table>
<thead>
<tr>
<th>
<input type="checkbox" id="selectAll" onclick="toggleAll(this)">
</th>
<th>Task</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" class="row-select"></td>
<td>Design mockup</td>
<td>Complete</td>
</tr>
<tr>
<td><input type="checkbox" class="row-select"></td>
<td>Write tests</td>
<td>In Progress</td>
</tr>
</tbody>
</table>
<script>
function toggleAll(source) {
const checkboxes = document.querySelectorAll('.row-select');
checkboxes.forEach(cb => cb.checked = source.checked);
}
</script>
Note: When implementing sortable tables, update
aria-sort attributes to indicate
sort state. Use role="button" on sortable headers and ensure keyboard accessibility (Enter/Space to
activate).
6. Responsive Table Techniques
| Technique | CSS/HTML | Pros | Cons |
|---|---|---|---|
| Horizontal Scroll | overflow-x: auto |
Simple, preserves layout | Hidden content, poor UX |
| Stacked Cells | Display: block on mobile | All data visible | Loses table semantics |
| Hidden Columns | Hide less important columns | Keeps key data visible | Data loss on mobile |
| Flip Layout | Transpose rows/columns | Better for narrow screens | Complex CSS |
| Cards/List View | Convert to card layout | Mobile-friendly | Loses table semantics |
Example: Responsive table implementations
<!-- Method 1: Horizontal scroll wrapper -->
<div style="overflow-x: auto;">
<table style="min-width: 600px;">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Department</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<tr>
<td>John Doe</td>
<td>john@example.com</td>
<td>555-1234</td>
<td>Engineering</td>
<td>New York</td>
</tr>
</tbody>
</table>
</div>
<!-- Method 2: Stacked cells on mobile -->
<style>
@media (max-width: 768px) {
.responsive-table thead {
display: none;
}
.responsive-table tr {
display: block;
margin-bottom: 20px;
border: 1px solid #ddd;
}
.responsive-table td {
display: block;
text-align: right;
padding: 10px;
border-bottom: 1px solid #eee;
}
.responsive-table td::before {
content: attr(data-label);
float: left;
font-weight: bold;
}
.responsive-table td:last-child {
border-bottom: none;
}
}
</style>
<table class="responsive-table">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Stock</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Product">Widget</td>
<td data-label="Price">$19.99</td>
<td data-label="Stock">50</td>
</tr>
<tr>
<td data-label="Product">Gadget</td>
<td data-label="Price">$29.99</td>
<td data-label="Stock">25</td>
</tr>
</tbody>
</table>
<!-- Method 3: Hide columns on mobile -->
<style>
@media (max-width: 768px) {
.hide-mobile {
display: none;
}
}
</style>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th class="hide-mobile">Phone</th>
<th class="hide-mobile">Address</th>
</tr>
</thead>
<tbody>
<tr>
<td>John</td>
<td>john@example.com</td>
<td class="hide-mobile">555-1234</td>
<td class="hide-mobile">123 Main St</td>
</tr>
</tbody>
</table>
<!-- Method 4: Card layout on mobile -->
<style>
.card-table {
width: 100%;
border-collapse: collapse;
}
@media (max-width: 768px) {
.card-table,
.card-table thead,
.card-table tbody,
.card-table tr,
.card-table th,
.card-table td {
display: block;
}
.card-table thead {
display: none;
}
.card-table tr {
margin-bottom: 15px;
background: #f9f9f9;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-table td {
padding: 5px 0;
border: none;
}
.card-table td::before {
content: attr(data-label) ": ";
font-weight: bold;
display: inline-block;
min-width: 100px;
}
}
</style>
<table class="card-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Name">Alice Johnson</td>
<td data-label="Role">Developer</td>
<td data-label="Status">Active</td>
</tr>
</tbody>
</table>
<!-- Method 5: Flip table (transpose) -->
<style>
@media (max-width: 768px) {
.flip-table {
display: block;
}
.flip-table thead {
float: left;
}
.flip-table tbody {
display: block;
overflow-x: auto;
white-space: nowrap;
}
.flip-table tr {
display: inline-block;
vertical-align: top;
}
.flip-table th,
.flip-table td {
display: block;
}
}
</style>
Warning: When converting tables to cards or lists on mobile, ensure you maintain semantic HTML
and add appropriate ARIA attributes. Use
data-label attributes to preserve context when hiding
headers.
Section 9 Key Takeaways
- Use
<thead>,<tbody>, and<tfoot>for proper table structure and accessibility - Always include
scopeattribute on<th>elements (scope="col" or scope="row") - Use
<caption>for table titles - essential for screen readers - When using
colspanorrowspan, ensure row/column totals remain consistent - Add
aria-sortattributes to sortable column headers - For responsive tables, use
data-labelattributes when stacking cells on mobile - Wrap tables in
<div style="overflow-x: auto">for horizontal scrolling on small screens - The
summaryattribute is deprecated - use<caption>oraria-describedbyinstead