Skip to main content
CodeRocket
Accessibilitymediumdocument-structure

Use semantic table markup for screen readers

rule · accessible-tables

Proper table markup enables screen readers to announce header-cell relationships, making data understandable.

Code Example

HTML
<table>
  <caption>Q1 2024 Sales by Region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">January</th>
      <th scope="col">February</th>
      <th scope="col">March</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>$12,000</td>
      <td>$14,500</td>
      <td>$13,200</td>
    </tr>
    <tr>
      <th scope="row">South</th>
      <td>$9,800</td>
      <td>$11,000</td>
      <td>$10,500</td>
    </tr>
  </tbody>
</table>

Why It Matters

Screen readers read table data cell-by-cell—without proper headers, users hear meaningless numbers without context. 'Row 3, Column 2: $99' vs just '$99'.

Scope Attribute

ValueUse When
scope="col"Header applies to column below
scope="row"Header applies to row beside it
scope="colgroup"Header spans multiple columns
scope="rowgroup"Header spans multiple rows

Complex Tables with Headers Attribute

HTML
<table>
  <caption>Sales Report</caption>
  <thead>
    <tr>
      <th id="region" rowspan="2">Region</th>
      <th id="q1" colspan="2">Q1</th>
      <th id="q2" colspan="2">Q2</th>
    </tr>
    <tr>
      <th id="q1-units" headers="q1">Units</th>
      <th id="q1-revenue" headers="q1">Revenue</th>
      <th id="q2-units" headers="q2">Units</th>
      <th id="q2-revenue" headers="q2">Revenue</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th id="north" headers="region">North</th>
      <td headers="north q1 q1-units">150</td>
      <td headers="north q1 q1-revenue">$15,000</td>
      <td headers="north q2 q2-units">180</td>
      <td headers="north q2 q2-revenue">$18,000</td>
    </tr>
  </tbody>
</table>

React Table Component

TSX
interface Column<T> {
  key: keyof T
  header: string
}
 
interface TableProps<T> {
  caption: string
  columns: Column<T>[]
  data: T[]
  rowHeader?: keyof T
}
 
function DataTable<T extends Record<string, unknown>>({
  caption,
  columns,
  data,
  rowHeader
}: TableProps<T>) {
  return (
    <table>
      <caption>{caption}</caption>
      <thead>
        <tr>
          {columns.map(col => (
            <th key={String(col.key)} scope="col">
              {col.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row, i) => (
          <tr key={i}>
            {columns.map(col => {
              const isRowHeader = col.key === rowHeader
              const Tag = isRowHeader ? 'th' : 'td'
              return (
                <Tag
                  key={String(col.key)}
                  scope={isRowHeader ? 'row' : undefined}
                >
                  {String(row[col.key])}
                </Tag>
              )
            })}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

Responsive Tables

HTML
<!-- Scrollable wrapper for mobile -->
<div class="table-wrapper" tabindex="0" role="region" aria-label="Sales data table">
  <table>
    <!-- Table content -->
  </table>
</div>
CSS
.table-wrapper {
  overflow-x: auto;
  max-width: 100%;
}
 
/* Focus indicator for keyboard users scrolling */
.table-wrapper:focus {
  outline: 2px solid #005fcc;
}

Exceptions

  • Simple data tables can sometimes fail more from missing header relationships than from missing enhancements such as captions or mobile wrappers, so prioritize the strongest semantic issue.
  • Do not convert layout structures into data-table markup just to satisfy a rule; the correct fix may be to remove table semantics entirely.
  • When several table-accessibility issues overlap, resolve the header-cell relationship first because downstream announcements depend on it.

Verification

Automated Checks

  • Use browser accessibility tooling, axe, Lighthouse, or equivalent automated checks against a representative rendered state.

Manual Checks

  • Use screen reader table navigation (Ctrl+Alt+Arrow keys in NVDA)
  • Verify headers are announced with each cell
  • Check caption describes the table purpose
  • Confirm table is not used for layout