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.

What the browser gives you, and what you have to rebuild Comparison of the semantics a native table element provides for free against the work required to reproduce them on div elements. What the browser gives you, and what you have to rebuild✓ A real table gives youRow and column counts announced on entryHeader-to-cell association from scopeTable navigation commands in every screenreaderCorrect reading order with no ARIA at all✗ div soup requires you to addrole="table", "row", "columnheader", "cell" onevery elementaria-rowcount, aria-colcount, aria-rowindex,aria-colindexManual header associationAnd you still do not get table navigation keys
Every row on the right is code you now own and must test forever.

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; }
Converting a div grid in three passes Three-pass conversion: map the div structure to table elements, move the styling to the new elements, then delete the ARIA the markup now provides. Converting a div grid in three passesMap the structurediv grid to table, thead, tbody, tr, th, tdOne element per role, no wrappers inside rowsMove the stylingdisplay: table is rarely needed; keep the CSS grid on a wrapperDo not set display:block on rowsDelete the ARIAremove role and aria-* that the elements now implyAnything still needed is a real gap
Delete the ARIA last — it is the proof that the markup is doing the work.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
What a screen reader announces, before and after Matrix comparing announcements for a div-based grid and the equivalent semantic table across entry, cell reading and navigation. What a screen reader announces, before and afterdiv-based gridSemantic tableOn enteringnothing, or "group""table with 6 columns and 41 rows"Reading a cellthe value alonethe column header, then the valueCtrl+Alt+arrowsno effectmoves cell by cellSorting statemust be announced manuallyaria-sort on the th is read
The visual result is identical; only one of them is navigable.

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.

Permalink to "Related"

Back to Semantic HTML Table Construction