Data Tables
Create accessible data tables with proper headers, captions, and semantic structure for screen reader users.
The Bad (Inaccessible)
<div class="table">
<div class="row">
<div class="cell header">Name</div>
<div class="cell header">Email</div>
</div>
<div class="row">
<div class="cell">John Doe</div>
<div class="cell">john@example.com</div>
</div>
</div>
Accessibility-Ready Code
<!-- Gold Standard: Semantic table with proper structure -->
<table class="data-table">
<caption>
Company Employees
<span class="sr-only">
- A list of employee names, emails, and roles
</span>
</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Department</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">John Smith</th>
<td><a href="mailto:john@example.com">john@example.com</a></td>
<td>Engineering</td>
<td>Senior Developer</td>
</tr>
<tr>
<th scope="row">Sarah Johnson</th>
<td><a href="mailto:sarah@example.com">sarah@example.com</a></td>
<td>Design</td>
<td>UX Designer</td>
</tr>
<tr>
<th scope="row">Mike Williams</th>
<td><a href="mailto:mike@example.com">mike@example.com</a></td>
<td>Marketing</td>
<td>Marketing Manager</td>
</tr>
<tr>
<th scope="row">Emily Brown</th>
<td><a href="mailto:emily@example.com">emily@example.com</a></td>
<td>Engineering</td>
<td>Junior Developer</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4">
Total: 4 employees across 3 departments
</td>
</tr>
</tfoot>
</table>
// Gold Standard: Semantic table with proper structure
table.data-table
caption
| Company Employees
span.sr-only - A list of employee names, emails, and roles
thead
tr
th(scope="col") Name
th(scope="col") Email
th(scope="col") Department
th(scope="col") Role
tbody
tr
th(scope="row") John Smith
td
a(href="mailto:john@example.com") john@example.com
td Engineering
td Senior Developer
tr
th(scope="row") Sarah Johnson
td
a(href="mailto:sarah@example.com") sarah@example.com
td Design
td UX Designer
tr
th(scope="row") Mike Williams
td
a(href="mailto:mike@example.com") mike@example.com
td Marketing
td Marketing Manager
tr
th(scope="row") Emily Brown
td
a(href="mailto:emily@example.com") emily@example.com
td Engineering
td Junior Developer
tfoot
tr
td(colspan="4") Total: 4 employees across 3 departments
<table className="data-table">
<caption>
Company Employees
<span className="sr-only">
- A list of employee names, emails, and roles
</span>
</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Department</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id}>
<th scope="row">{employee.name}</th>
<td>
<a href={`mailto:${employee.email}`}>
{employee.email}
</a>
</td>
<td>{employee.department}</td>
<td>{employee.role}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4}>
Total: {employees.length} employees across {departments} departments
</td>
</tr>
</tfoot>
</table>
<template>
<table class="data-table">
<caption>
Company Employees
<span class="sr-only">
- A list of employee names, emails, and roles
</span>
</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Department</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in employees" :key="employee.id">
<th scope="row">{{ employee.name }}</th>
<td>
<a :href="`mailto:${employee.email}`">
{{ employee.email }}
</a>
</td>
<td>{{ employee.department }}</td>
<td>{{ employee.role }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td :colspan="4">
Total: {{ employees.length }} employees across {{ departments }} departments
</td>
</tr>
</tfoot>
</table>
</template>
<table className="min-w-full border-collapse border border-gray-300">
<caption>
Company Employees
<span className="sr-only">
- A list of employee names, emails, and roles
</span>
</caption>
<thead className="bg-gray-100">
<tr>
<th scope="col" className="border border-gray-300 px-4 py-2 text-left">
Name
</th>
<th scope="col" className="border border-gray-300 px-4 py-2 text-left">
Email
</th>
<th scope="col" className="border border-gray-300 px-4 py-2 text-left">
Department
</th>
<th scope="col" className="border border-gray-300 px-4 py-2 text-left">
Role
</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id} className="even:bg-gray-50">
<th scope="row" className="border border-gray-300 px-4 py-2 font-medium">
{employee.name}
</th>
<td className="border border-gray-300 px-4 py-2">
<a href={`mailto:${employee.email}`} className="text-blue-600">
{employee.email}
</a>
</td>
<td className="border border-gray-300 px-4 py-2">
{employee.department}
</td>
<td className="border border-gray-300 px-4 py-2">
{employee.role}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<td colSpan={4} className="border border-gray-300 px-4 py-2 font-semibold">
Total: {employees.length} employees
</td>
</tr>
</tfoot>
</table>
The Standard
Data tables present tabular data with proper semantic structure. Use native HTML table elements for accessibility, not CSS layouts that look like tables.
WCAG Criteria
- 1.3.1 Info and Relationships: Semantic table structure.
- 4.1.2 Name, Role, Value: Proper table roles.
- 2.4.6 Headings and Labels: Clear captions and headers.
β The Bad (Inaccessible)
Whatβs Wrong?
- Using
divfor layout: Screen readers canβt navigate as a table. - No caption: No context for table contents.
- Missing scope: Screen reader canβt map headers.
- Using tables for layout: Confusing for screen readers.
β The Good (Accessibility-Ready Code)
Why This Works
- Semantic
<table>: Creates a table landmark for screen readers. - Caption: Provides context for all users.
- scope Attributes: Maps headers to data cells.
- Row Headers:
scope="row"for the first column header.
Accessibility Checklist
- Use
<table>for tabular data, not layout. - Always include a
<caption>describing the table. - Use
<thead>and<tbody>to structure sections. - Add
scope="col"to column headers. - Add
scope="row"to row headers (first column). - Use
<th>for headers,<td>for data cells. - Include
<tfoot>for summary rows if applicable.
<table>
<caption>
Monthly Sales Report
<span class="sr-only">
- Sales by product and region
</span>
</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Widget A</th>
<td>$10,000</td>
<td>$12,000</td>
</tr>
</tbody>
</table>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
</style>
Technical Deep Dive
Screen Reader Announcements
- NVDA: βTable with 4 columns and 4 rows, John Smith, row 1, column 1, Emailβ
- VoiceOver: βCompany Employees, table, 4 columns, 4 rowsβ
- JAWS: βCompany Employees, table, John Smith, row 1, column 1, Email linkβ
Best Practice: Tables vs. Layout Grids
Only use <table> for tabular data relationships, not for layout. When you have complex data with multiple levels of headers, use the headers attribute to explicitly associate each cell with all applicable headers. Use scope="col" for simple column headers and scope="row" for row headers in the first column.
Interactive Behavioral Lab
Interactive Sandbox
π¬ Technical Internals
Understand how this component is processed by the browser and Assistive Technology (AT). This section bridges the gap between visual code and the hidden logic that powers accessibility.
π² Accessibility Tree
The data structure used by screen readers to "see" your page. It translates HTML roles and attributes into standardized objects.
βοΈ Event Logic
Expected behavioral standards for keyboard navigation and state transitions. Crucial for users who don't use a mouse.
- Focus: Highlights via
:focus-visible - Activation: Responds to
EnterandSpace - Role: Identified as
Table