Converting div-Based Grids to Semantic Tables
Permalink to "Converting div-Based Grids to Semantic Tables"Almost every data grid built on div elements started as a layout decision and became an accessibility problem. The visual result is identical to a table; the announced result is not. A screen reader entering a div grid hears nothing about its size, gets no column header with any cell, and has no table navigation commands available. This page covers the conversion — the element mapping, the styling that survives it, and the ARIA you get to delete afterwards.
It is the migration path into semantic HTML table construction, and it is the prerequisite for nearly everything else in this section: sorting, expansion and inline editing all assume relationships that only exist if the markup provides them.
Spec reference
Permalink to "Spec reference"SC 1.3.1 Info and Relationships (Level A) requires that information, structure and relationships conveyed through presentation are programmatically determinable. In a data grid the load-bearing relationship is “this cell is described by that column header”, and the HTML Living Standard defines it: a th with scope="col" is the header for every cell in its column, and the browser exposes that association to assistive technology without any further markup.
ARIA can reproduce the roles — role="table", role="row", role="columnheader", role="cell" — but it reproduces them at a cost. Every attribute is code you maintain, and the counts (aria-rowcount, aria-colcount) and positions (aria-rowindex, aria-colindex) that the browser derives for free from real markup must all be computed and kept in sync.
One thing ARIA cannot reproduce at all is table navigation. The cell-by-cell commands that NVDA, JAWS and VoiceOver offer inside tables are keyed to the platform table object, and a div structure with table roles does not always produce one.
When to use — and when not to
Permalink to "When to use — and when not to"Convert whenever the grid presents tabular data — rows of records with consistent columns. That is the overwhelming majority of data grids, including sortable, filterable and paginated ones.
Do not convert a layout that is not tabular. A card grid, a dashboard of tiles or a masonry list is not a table, and forcing it into one produces announcements about rows and columns that mean nothing.
Do not convert to role="grid" by default. role="grid" is for interactive spreadsheet-like widgets where cells take focus and arrow keys navigate; it switches screen readers into application mode, disabling the reading commands users rely on. A sortable data table is a table.
Annotated code example
Permalink to "Annotated code example"<!-- Before: visually correct, semantically empty -->
<div class="grid">
<div class="grid-head">
<div class="cell">Account</div>
<div class="cell">Amount</div>
</div>
<div class="grid-row">
<div class="cell">Nordics AB</div>
<div class="cell">412.00</div>
</div>
</div>
<!-- After: SC 1.3.1 satisfied by the markup, not by attributes -->
<table class="grid">
<caption>Outstanding invoices</caption>
<thead>
<tr>
<!-- scope makes this the header for every cell below it -->
<th scope="col">Account</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<!-- the row's identifying cell is a th, not a td -->
<th scope="row">Nordics AB</th>
<td>412.00</td>
</tr>
</tbody>
</table>
/* Keep the CSS layout on a wrapper — changing display on table parts
can drop the implicit semantics in some browsers */
.grid-wrapper { overflow-x: auto; }
.grid { width: 100%; border-collapse: collapse; }
.grid th, .grid td { padding: 0.75rem 1rem; text-align: left; }
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"The row that surprises teams is the third one. Table navigation commands are the reason many screen reader users can work with data tables at all: they let a user move down a column checking values without re-reading every row. No amount of ARIA on div elements reliably provides them.
Integration context
Permalink to "Integration context"The conversion unblocks the rest of this section. Sortable and filterable data grids puts aria-sort on a th, which requires a th. Expandable rows and nested data relies on real tr elements to insert children between. Correct usage of scope and headers is entirely about attributes that only exist on table elements.
If the grid is virtualized, the conversion is still worth doing — a windowed tbody with correct aria-rowcount and aria-rowindex is a valid pattern, and it is covered in accessible virtualized list patterns.
Gotchas
Permalink to "Gotchas"A wrapper div inside a row. tr may only contain th and td. A wrapper element between them is a parse error, and browsers recover by hoisting it out of the table, which visually breaks the row.
display: flex on a tr. It changes the box type and can drop row semantics. Style the cells, not the row.
Column-count mismatch. After conversion, every row must have the same number of cells, counting colspan. Rows that are short produce a jagged table that readers describe inconsistently.
Leftover ARIA. A converted table with role="table" still on it is harmless but pointless; role="grid" left behind is actively harmful, because it puts readers into application mode.
FAQ
Permalink to "FAQ"Is display: grid on a table element safe?
Setting display: grid or display: flex on a table, thead or tr changes the element’s CSS box type, and in several browsers that also drops the implicit table semantics from the accessibility tree. If you need CSS grid for layout, apply it to a wrapper around the table, or re-declare the roles explicitly — role=“table”, role=“row” and so on — which is exactly the work you converted the markup to avoid.
When is role="grid" the right choice instead of a plain table?
When the widget is interactive in a spreadsheet sense: cells take focus, arrow keys move between them, and the component manages its own keyboard model. A static or sortable data table is not a grid — it is a table, and adding role=“grid” puts screen readers into application mode where ordinary reading commands stop working.
Do I still need aria-rowcount on a semantic table?
Only when the DOM does not contain every row — a virtualized or paginated view. If all rows are present, the browser derives the counts from the markup and an aria-rowcount that disagrees is worse than none. When rows are windowed, aria-rowcount and aria-rowindex are the only way to state the true size and position.
Related
Permalink to "Related"- Semantic HTML table construction — the parent guide and the full element reference
- Correct usage of scope and headers in complex tables — the next step once the elements are real
- Composite widget roles and states — when role=“grid” genuinely is the right answer