Making Data Tables Accessible: Markup, WCAG Conformance, and Responsive Styling

How to build data tables that are semantically correct for screen readers and visually usable on narrow viewports, with complete CSS and HTML samples.

Making Data Tables Accessible: Markup, WCAG Conformance, and Responsive Styling

A data table does not become accessible the moment you wrap it in a <table> tag. Native HTML table elements give you structural hooks, but screen readers only understand the relationships between headers and data cells when you explicitly declare them with scope or headers attributes. The real work has two halves. First, mark up the semantics so a screen reader can announce which column or row a cell belongs to. Second, style the table so it stays readable when the viewport narrows, without ever changing that underlying semantic structure. You will meet WCAG 2.2 Success Criterion 1.3.1 (Info and Relationships) by number. And you will see why the most common responsive pattern, setting display: block on table elements, destroys the accessibility tree.

The Minimum Viable Accessible Table

Start with the smallest correct structure. A data table needs a <caption> element to give it a programmatic name. Each <th> needs a scope attribute that tells assistive technology whether it labels a column or a row. The attribute takes the values col, row, colgroup, or rowgroup. For a simple table, scope="col" on header cells in the <thead> and scope="row" on header cells that label each row in the <tbody> is all you need. This is the baseline that satisfies WCAG 2.2 Success Criterion 1.3.1, which requires that information and relationships conveyed by visual formatting are also available programmatically. Without these attributes, a screen reader announces a flat list of cells. The user cannot tell which number belongs to which month or which product.

Why the Caption and Scope Attributes Matter

The <caption> element is not decorative. It is the table's accessible name, read aloud before the first row. The summary attribute is a legacy alternative, but it is not supported in HTML5 and you should avoid it. The scope attribute is the modern replacement for the headers attribute in simple layouts. The headers attribute is reserved for complex tables where a cell has multiple headers from different groups, and it requires ID references. For most real-world tables, scope is sufficient and far less error-prone. The following sample is complete and runnable. It passes the assistive-technology check for 1.3.1.

<table>
  <caption>Quarterly sales by region (USD)</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
      <th scope="col">Q3</th>
      <th scope="col">Q4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>12,000</td>
      <td>13,500</td>
      <td>14,200</td>
      <td>15,800</td>
    </tr>
    <tr>
      <th scope="row">South</th>
      <td>9,800</td>
      <td>10,500</td>
      <td>11,200</td>
      <td>12,100</td>
    </tr>
  </tbody>
</table>

Use this as your starting point. If you cannot explain why each <th> needs a scope attribute, you have not yet understood the cell association that makes the table navigable. A screen reader user moves through the table by row and column, hearing the header names repeated for each data cell. That only works because the association is programmatic.

Responsive Table Using CSS Display Properties

When the viewport narrows, a table with many columns becomes a horizontal-scrolling nightmare. The common answer is to set display: block on the <table> and each <tr>, then style the cells so each one sits on its own line. That pattern makes the table readable on a narrow phone. But it breaks the semantics. The display property changes what the browser exposes to the accessibility tree. Set display: block on a <tr> and the row no longer exists as a row in that tree. The association between header and data cell is lost. The correct responsive pattern preserves the native table-model values for screen readers while using a different approach for visual layout on narrow screens.

Preserving Header Association with Table-Row and Table-Cell

Keep the HTML table elements intact. Use CSS values that match the native defaults: table, table-header-group, table-row, table-cell. The accessibility tree stays unchanged. For small viewports, do not change the display value. Instead, use a technique that reflows the visual order without altering the semantic structure. One method makes each row a flex container on small screens. But that changes the <tr> from table-row to flex, which again removes the row semantics. Allowing horizontal scrolling keeps the structure intact but often delivers poor UX.

A better pattern uses a data-label attribute on each <td>. On narrow viewports, a CSS rule adds a pseudo-element that shows the header name. The <td> stays as display: table-cell, so the accessibility tree remains valid. The following sample demonstrates this with a media query and overflow-x as a fallback.

<table class="responsive">
  <caption>Product inventory with stock levels</caption>
  <thead>
    <tr>
      <th scope="col">SKU</th>
      <th scope="col">Name</th>
      <th scope="col">Stock</th>
      <th scope="col">Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" data-label="SKU">A-101</th>
      <td data-label="Name">Hammer</td>
      <td data-label="Stock">45</td>
      <td data-label="Price">$12.50</td>
    </tr>
  </tbody>
</table>

<style>
  .responsive {
    border-collapse: collapse;
    width: 100%;
  }
  .responsive th, .responsive td {
    border: 1px solid #ccc;
    padding: 0.5rem;
    text-align: left;
  }
  @media (max-width: 600px) {
    .responsive {
      display: block;
      overflow-x: auto;
    }
    .responsive thead {
      display: table-header-group;
    }
    .responsive tbody {
      display: table-row-group;
    }
    .responsive tr {
      display: table-row;
    }
    .responsive th, .responsive td {
      display: table-cell;
    }
    /* No change to display; just add visual labels for narrow screens */
    .responsive td[data-label]::before {
      content: attr(data-label) ": ";
      font-weight: bold;
    }
  }
</style>

Notice what this does not do. It does not set display: block on any table element. The overflow-x: auto on the table allows horizontal scroll if the content is too wide. The display values stay table-valid. The screen reader still sees a table with rows and columns. The pseudo-element is purely visual text inserted before the cell content. It is not part of the accessibility tree, but the real <td> content remains associated with the header in the normal way. This is the responsive pattern that survives an audit.

The Common Failure Pattern: display: block on Table Elements

Now the failure case, because you will see it everywhere. Someone writes a responsive table and, to avoid horizontal scrolling, applies display: block to the <table>, the <thead>, the <tbody>, every <tr>, every <th>, and every <td>. Visually, each cell becomes a full-width block, stacked vertically. The table looks fine on a phone. But the accessibility tree collapses. The browser no longer exposes a table structure. The screen reader announces a series of unrelated blocks. The user hears "Hammer", "45", "$12.50" without any context of which column is which. This violates WCAG 2.2 Success Criterion 1.3.1 because the visual relationship between headers and data cells is not preserved programmatically.

Why the Browser Does Not Infer the Semantics

The CSS display property is not just a visual instruction. It determines how the element participates in the accessibility tree. Change display: table-row to display: block and the element is no longer a table row in that tree. The implicit rowgroup and row roles are lost. The scope attributes you carefully wrote are ignored because there is no table context. The <caption> is still read, but it does not help a user navigate cells. This is a silent regression. The page passes a visual test, but a screen reader user is stranded. The following sample replicates the broken pattern so you can inspect it in the browser.

<table class="broken">
  <caption>Team roster with roles</caption>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Role</th>
      <th scope="col">Email</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Ada</th>
      <td>Engineer</td>
      <td>[email protected]</td>
    </tr>
  </tbody>
</table>

<style>
  .broken, .broken thead, .broken tbody, .broken tr, .broken th, .broken td {
    display: block;
    width: 100%;
  }
  .broken th, .broken td {
    border: 1px solid #999;
    padding: 0.5rem;
  }
</style>

Open the accessibility inspector in your browser's developer tools. The table role is gone. Each cell appears as a generic group or text node. The scope attributes are ignored. This is why the failure is so dangerous. It looks like a small CSS choice, but it changes the semantic meaning of the content. Do not do this. If you need a different visual layout on small screens, use the data-label pattern from the previous section. Or use a container query that changes the white-space property to allow wrapping while keeping the table structure.

Styling Accessible Tables: Borders, Alignment, and Captions

Once the structure is correct, styling becomes a matter of visual clarity. The border-collapse property with the value collapse is the standard choice for data tables. It removes the double borders between adjacent cells, making the grid look clean. The alternative value separate is the default, and it keeps the spacing that border-spacing controls. If you want gaps between cells, use border-spacing with one or two lengths. For most data tables, border-collapse: collapse is what you want. The text-align property matters too. Header cells conventionally use text-align: left for text and text-align: right for numbers. Align data cells to match their header's alignment so the visual relationship is consistent.

Caption Placement and Vertical Alignment

The caption-side property controls whether the caption appears above or below the table. The default is top. Set caption-side: bottom to put it after the data, which can be useful when the table is part of a longer figure. You do not need a fallback; every modern browser supports it. For vertical alignment within cells, be careful. vertical-align on a display: table-cell element aligns the cell itself relative to sibling cells, not the content inside it. To vertically center content within a cell, use align-content or set the cell to a flex container. A common mistake is applying vertical-align: middle to a <td> expecting the text to center. It does not do that reliably. It aligns the cell box in the row. Instead, keep the <td> as display: table-cell and use padding to achieve the visual result.

Zebra striping is a visual aid, not a semantic requirement. Use the :nth-child(even) selector to apply a background colour to alternate rows. This helps sighted users track across a wide row. It has no effect on the accessibility tree. If you also want to support high-contrast themes, avoid relying solely on colour to distinguish rows. Add a bottom border instead. The white-space property is useful for preventing cell content from wrapping in narrow columns, but it can cause horizontal overflow. Set white-space: nowrap on short codes or numbers. Use overflow-x: auto on the table wrapper to contain the overflow. The following sample shows a well-styled table with these properties.

<table class="styled">
  <caption style="caption-side: top;">Monthly expenses by category</caption>
  <thead>
    <tr>
      <th scope="col" style="text-align: left;">Category</th>
      <th scope="col" style="text-align: right;">January</th>
      <th scope="col" style="text-align: right;">February</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" style="text-align: left;">Rent</th>
      <td style="text-align: right;">1,200</td>
      <td style="text-align: right;">1,200</td>
    </tr>
    <tr>
      <th scope="row" style="text-align: left;">Groceries</th>
      <td style="text-align: right;">350</td>
      <td style="text-align: right; white-space: nowrap;">420</td>
    </tr>
  </tbody>
</table>

<style>
  .styled {
    border-collapse: collapse;
    width: 100%;
  }
  .styled th, .styled td {
    border: 1px solid #aaa;
    padding: 0.5rem;
  }
  .styled tbody tr:nth-child(even) {
    background: #f4f4f4;
  }
  .styled tbody tr:hover {
    background: #eaeaea;
  }
</style>

Notice that the white-space property on the February cell prevents the number from wrapping. If the table were too narrow, the column would overflow. That is what overflow-x on a wrapper is for. In practice, wrap the table in a <div style="overflow-x: auto;"> to contain the scroll. The combination of border-collapse, text-align, and white-space is what makes a table legible, not decorative flourishes.

Semantic Table Structure: Beyond the Basics

When your table grows complex, you need more than scope. In a table with merged cells or multiple header levels, the headers attribute becomes necessary. Each <td> can reference the IDs of one or more <th> elements, separated by spaces. This builds an explicit graph of relationships that a screen reader can announce. A cell at the intersection of a column header "Q1" and a row header "North" would have headers="q1-header north-header". This is verbose. It is also the only way to convey the semantics in a complex layout. The scope attribute cannot express that a cell belongs to two different groups.

Using thead, tbody, and tfoot as Structural Landmarks

The <thead>, <tbody>, and <tfoot> elements are not just for styling. They give the table internal structure that assistive technology can use for navigation. A screen reader user can jump to the header row, then to the data rows, then to the footer, without cycling through every cell. You can reinforce this with the display: table-header-group and display: table-footer-group CSS values if you ever need to reorder the visual presentation. The native elements already map to those roles. Setting a <thead> to display: table-header-group is redundant but harmless. The rule is absolute: never set any of these to display: block or display: flex. That removes the row-group role.

For a table that spans multiple pages when printed, the <thead> repeats on each page in most browsers. You get that for free by using the correct element, not because of any CSS. Try to recreate it with display values on non-table elements and you will fail. This is why the structural advice is always to use native HTML as the backbone and CSS only for visual refinement. A table built with div elements and ARIA roles will not expose the table role to screen readers in the same way. The accessibility tree is generated from the HTML semantics, not from the computed display value. That is a hard truth many CSS tutorials omit.

FAQ: Accessible Data Tables

Q: What is the minimum markup for an accessible table?
A: Use a <caption>, a <thead> with <th scope="col"> for each column, and a <tbody> with <th scope="row"> for each row label. That satisfies WCAG 1.3.1.

Q: Can I use CSS to make a table responsive without breaking accessibility?
A: Yes, keep the native display values and use a wrapper with overflow-x: auto, or add data-label attributes and pseudo-elements for visual labels. Never set display: block on table elements.

Q: Does the summary attribute still work?
A: No, summary is deprecated in HTML5. Use a <caption> for the table name. If you need a longer description, place it in a paragraph before the table.

Q: What is the difference between scope and headers?
A: scope works for simple row and column relations. headers with IDs is for complex tables where a cell has multiple headers, such as a table with merged cells or multiple row groups.

The Honest Caveat: CSS Cannot Fix Broken HTML

Here is the truth that cannot be softened. No amount of CSS will rescue a table that lacks proper markup. You can spend hours tuning border-collapse, text-align, and white-space. But if you omit the scope attributes or use display: block on a <tr>, the screen reader experience is still broken. The responsibility lies entirely in the HTML structure. CSS is the presentation layer. It cannot add semantics that are not in the document. The data-label pseudo-element pattern is a stopgap, not a solution, because it adds visual context for sighted users but leaves the accessibility tree unchanged, so you still need the native table semantics to be correct in the first place.