Marking Up Lists and Tables Accessibly
AI generated
A11Y
WCAG
Accessibility · WCAG · Semantic HTML · Screen Readers
Marking Up Lists and Tables Accessibly
ul, ol, dl, and th with scope instead of div soup

Treating lists as stacked div elements and tables as a pure layout tool makes content unusable for screen reader users. Using ul, ol, dl for genuine enumerations and th with scope plus caption for genuine tabular data makes relationships between content programmatically detectable, navigable, and WCAG compliant, without sacrificing visual design.

14 min. read ul · ol · dl · th scope · caption WCAG 2.2 · Magento 2 · Hyvä Theme

1. Why semantic markup determines accessibility

Screen readers and other assistive technologies do not rely on visual design, but on the semantic structure of the HTML document. A list that only looks like a list through line breaks and CSS spacing is, for a screen reader, simply a collection of unrelated div elements. Only <ul>, <ol>, or <dl> tell the accessibility API that a group of related elements exists that can be navigated as a unit. This exact programmatically detectable structure is precisely what WCAG success criteria 1.3.1 (Info and Relationships) and 4.1.2 (Name, Role, Value) require.

The difference is invisible to sighted users but fundamental for screen reader users: NVDA announces a correctly marked-up list as "list with 8 items" and allows navigation from item to item with a single key. With a div construction, this announcement disappears entirely, and the user must work through unstructured text element by element without knowing how many items remain or where a logical group ends. The same principle applies to tables in an even stricter form: without <th scope>, a screen reader can no longer map a cell to its row or column heading, and complex data tables become effectively unreadable.

2. ul, ol, and dl: the three list types in HTML

HTML offers three native list types, each carrying a different semantic meaning. <ul> (unordered list) signals that the order of items carries no meaning, for example a list of product features or navigation links. <ol> (ordered list), by contrast, indicates that order matters, for example step-by-step instructions in a checkout flow or a ranking. <dl> (description list) represents key-value pairs and is ideal for product attributes such as material, size, and weight, marked up as <dt>/<dd> pairs.

Choosing the right list type is not a style question, it directly affects what a screen reader announces. VoiceOver automatically reads the position "item 3 of 7" for an <ol>, which provides important orientation in a numbered guide. For a <ul>, this position information is dropped in favor of a plain "list item" announcement. Important: every direct child of <ul> or <ol> must be an <li> element; anything else violates the HTML specification and causes inconsistent behavior across browsers in accessibility tree computation.


<!-- ul: order irrelevant, e.g. product features -->
<ul class="space-y-2">
  <li>Water resistant to 30 meters</li>
  <li>Titanium case, 42 mm diameter</li>
  <li>Scratch-resistant sapphire glass</li>
</ul>

<!-- ol: order relevant, e.g. checkout steps -->
<ol class="list-decimal pl-6 space-y-1">
  <li>Review cart</li>
  <li>Enter shipping address</li>
  <li>Choose payment method</li>
  <li>Confirm order</li>
</ol>

<!-- dl: key-value pairs, e.g. product attributes -->
<dl class="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1">
  <dt class="font-semibold">Material</dt>
  <dd>Brushed titanium</dd>
  <dt class="font-semibold">Weight</dt>
  <dd>68 g</dd>
  <dt class="font-semibold">Water resistance</dt>
  <dd>30 m (3 ATM)</dd>
</dl>

3. The problem with div soup instead of real lists

"Div soup" describes the widespread anti-pattern of rebuilding list-like content exclusively out of nested <div> elements and CSS classes like flex flex-col gap-2. Visually, the result is indistinguishable from a real list, because Tailwind utility classes can produce the same spacing and alignment as native list elements. For the accessibility API, however, the semantic relationship simply does not exist: the accessibility tree reports only a generic "group" role, or no role at all, and the number of items remains hidden from the user.

This pattern often creeps in gradually in practice: a developer copies an existing component that happens to use divs instead of li, and multiplies the problem across further components. Automated tools like axe-core or Lighthouse do not reliably flag div soup as an error, because no WCAG criterion is technically violated as long as no ARIA role is set incorrectly. That is exactly why manual review with a screen reader is indispensable. The rule is simple: whenever content represents an enumeration, an order, or a group of related items, a native list element belongs there, regardless of the visual styling.


<!-- WRONG: div soup, no semantic list -->
<div class="flex flex-col gap-2">
  <div class="border-b py-2">Free shipping from 50 EUR</div>
  <div class="border-b py-2">2 year warranty</div>
  <div class="border-b py-2">30 day return policy</div>
</div>

<!-- RIGHT: semantic list with identical visual result -->
<ul class="flex flex-col gap-2 list-none m-0 p-0">
  <li class="border-b py-2">Free shipping from 50 EUR</li>
  <li class="border-b py-2">2 year warranty</li>
  <li class="border-b py-2">30 day return policy</li>
</ul>

Navigation menus, cart line items, and product grids are the three most common places in Magento and Hyvä stores where genuine lists are incorrectly implemented as div constructs. A main navigation always belongs inside <nav><ul><li><a>, so a screen reader user can jump directly to the list of navigation items via a keyboard shortcut and grasp its size. For cart line items, a <ul> signals that multiple similar units exist, which also makes aria-live announcements after adding an item more precise.

Product grids are a borderline case: a collection of product cards is semantically a list, but is often built with CSS Grid instead of flexbox. That is not a contradiction, because display: grid can be applied to <ul> and <li> without any problem, and CSS does not change the underlying semantics. In Hyvä themes built with Tailwind CSS, this means concretely: the grid classes move onto the <ul> element, while each product card sits inside an <li>, which itself gets list-none to remove the browser's default bullet marker without losing the semantics.


<!-- Hyvä phtml: product grid as semantic list with CSS grid -->
<nav aria-label="{{ __('Main navigation') }}">
  <ul class="flex gap-6 list-none m-0 p-0">
    <li><a href="{{ $categoryUrl }}" class="hover:underline">Watches</a></li>
    <li><a href="{{ $categoryUrl2 }}" class="hover:underline">Jewelry</a></li>
  </ul>
</nav>

<ul class="grid grid-cols-2 sm:grid-cols-4 gap-6 list-none m-0 p-0" aria-label="{{ __('Products') }}">
  <?php foreach ($products as $product): ?>
    <li>
      <a href="{{ $product->getProductUrl() }}" class="block">
        <img src="{{ $product->getImageUrl() }}" alt="{{ $escaper->escapeHtmlAttr($product->getName()) }}" width="300" height="300">
        <span class="block mt-2 font-semibold"><?= $escaper->escapeHtml($product->getName()) ?></span>
      </a>
    </li>
  <?php endforeach; ?>
</ul>

5. Table basics: th, scope, and caption

A <table> is meant for genuine tabular data: rows and columns that stand in a two-dimensional relationship to each other, for example a price list with sizes in the columns and products in the rows. The scope="col" attribute on a <th> element declares that this cell serves as a heading for the entire column, while scope="row" signals the same for a row. Without scope, a screen reader has to guess which heading belongs to which data cell, which quickly leads to mismatches once a table has more than two columns.

The <caption> element, placed right after the opening <table> tag, provides a short descriptive title that screen readers read automatically before the user dives into the actual table data. This replaces constructions using a separate heading outside the table, which has no programmatic link to the table. Combined with <thead>, <tbody>, and correctly set scope, the result is a table a screen reader can read cell by cell with full context, without the user having to count columns manually.


<!-- Simple data table with caption and scope -->
<table class="w-full text-sm border-collapse">
  <caption class="text-left font-semibold mb-2">Available sizes and prices</caption>
  <thead>
    <tr>
      <th scope="col">Size</th>
      <th scope="col">Color</th>
      <th scope="col">Price</th>
      <th scope="col">In stock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">S</th>
      <td>Black</td>
      <td>49.90 EUR</td>
      <td>Yes</td>
    </tr>
    <tr>
      <th scope="row">M</th>
      <td>Black</td>
      <td>49.90 EUR</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

6. Complex tables: headers, id, and multi-level headers

Once a table has multi-level headings, for example a grouped header row with "Q1" and "Q2" that each split further into "Revenue" and "Units", scope alone is no longer enough, because a data cell then belongs to more than one heading at once. HTML defines the headers attribute for exactly this case: each <th> cell gets a unique id, and each <td> cell references all applicable headings via headers="id1 id2", separated by spaces. Screen readers then read the full chain of associated headings for each data cell before announcing the actual value.

In practice, this complexity is rarely worth it for standard store data. Before building a complex nested table, it is worth asking whether the data can instead be split into two simpler, clearly separated tables. Simple tables with scope="col" and scope="row" are considerably easier for screen reader users to grasp than a table with three levels of nested headers, even when headers is technically set correctly. Complexity in the data structure should, where possible, be reduced in presentation, not merely marked up correctly on a technical level.


<!-- Multi-level table: headers attribute for unambiguous mapping -->
<table class="w-full text-sm border-collapse">
  <caption class="text-left font-semibold mb-2">Revenue and units by quarter</caption>
  <thead>
    <tr>
      <th id="product" rowspan="2" scope="col">Product</th>
      <th id="q1" colspan="2" scope="colgroup">Q1</th>
      <th id="q2" colspan="2" scope="colgroup">Q2</th>
    </tr>
    <tr>
      <th id="q1-revenue" headers="q1" scope="col">Revenue</th>
      <th id="q1-units" headers="q1" scope="col">Units</th>
      <th id="q2-revenue" headers="q2" scope="col">Revenue</th>
      <th id="q2-units" headers="q2" scope="col">Units</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th id="watch-a" scope="row">Watch A</th>
      <td headers="watch-a q1 q1-revenue">12,400 EUR</td>
      <td headers="watch-a q1 q1-units">248</td>
      <td headers="watch-a q2 q2-revenue">15,900 EUR</td>
      <td headers="watch-a q2 q2-units">318</td>
    </tr>
  </tbody>
</table>

7. Why tables should never be used for visual layout

In the early 2000s, the layout table was standard practice for building multi-column web pages before CSS Grid and flexbox were available. This pattern is not only technically outdated today, it is actively harmful to accessibility: a screen reader interprets every <table> as tabular data and announces row and column counts, even when the table only serves to visually arrange logo, navigation, and content. The user then hears meaningless announcements like "table with 3 columns and 1 row", which provide no informational value and actively hinder navigation.

The only permissible workaround, when a table must still be used for layout for legacy reasons, is role="presentation" on the <table> element, which completely removes the tabular semantics for the accessibility API. This solution is a stopgap, not a recommendation, since modern CSS layout mechanisms like grid and flexbox solve the same visual problem without semantic side effects. The clear rule is: use <table> exclusively for data that genuinely stands in a row-column relationship, never for arranging page elements.

8. Screen reader testing: how NVDA and VoiceOver read lists and tables

The most reliable way to verify semantic markup is direct testing with a screen reader, because automated linters like axe-core only catch a portion of possible errors. With NVDA (Windows, free) you navigate through all headings with the H key and through an element list with Insert+F7 that shows every list and table on a page along with its size. A correctly marked-up <ul> with five items is explicitly announced as "list with 5 items", while div soup does not show up there at all.

VoiceOver on macOS and iOS offers a comparable overview of tables and lists via the rotor (Ctrl+Option+U). Inside tables, VoiceOver navigates cell by cell with Ctrl+Option+arrow keys and automatically reads the associated column and row heading on every move, provided scope is set correctly. A practical test routine for every new component: navigate with tab only, no mouse, check the element list, and for tables, jump specifically into the middle cells to verify that the heading announcement is correct and not just coincidentally right in the first column.

As a supplement to manual screen reader testing, axe-core can be wired into the CI pipeline and scoped specifically to the th-has-data-cells, td-headers-attr, list, and listitem rules. Automated checks like these reliably catch missing th and scope mappings, but, as described in the previous section, they never replace manual testing with a real screen reader.

9. Practical example: an accessible product comparison table

Product comparison tables are a common e-commerce feature and, at the same time, a textbook example of genuine tabular data: multiple products in columns, multiple attributes in rows, clear row and column relationships. Implementing it with <caption>, <th scope="col"> for the product names in the header row, and <th scope="row"> for the attribute labels in the first column of each row fully satisfies WCAG 1.3.1 without requiring additional ARIA attributes. Native HTML semantics are deliberately the first choice here over ARIA, since the "no ARIA is better than bad ARIA" rule applies to tables as well.

An important point for visual styling with Tailwind CSS: zebra striping and color coding like "green means in stock" must never carry the only piece of information. A screen reader does not read out background color, which is why additional text such as "In stock" or a <span class="sr-only"> with the status is required. The table below shows a fully accessible implementation, including a right-versus-wrong comparison of the most common mistakes with lists and tables in practice.

Comparison of inaccessible and accessible implementations of lists and tables
Use case Inaccessible Accessible Why it matters
Feature list div.flex.flex-col ul>li Item count is announced
Checkout steps div with numbers as text ol>li Position "3 of 5" announced automatically
Product attributes div with colon-separated text dl>dt/dd Key-value relationship is detectable
Comparison table td instead of th in header row th scope="col" Column relation of each cell is clear
Availability only a green dot via CSS Text + sr-only status Color alone conveys nothing
Page layout table for column arrangement CSS Grid / flexbox No meaningless table announcement

Mironsoft

Accessibility, semantic HTML, and WCAG audits for Magento stores

Ready to make your lists and tables genuinely accessible?

We audit existing Magento and Hyvä templates for div soup, missing scope attributes, and layout tables, and replace them with semantically correct, WCAG-compliant markup, without changing the visual design.

Accessibility audit

Manual screen reader testing plus axe-core analysis of all templates

Template refactoring

Replace div soup with ul/ol/dl, retrofit th and scope in tables

WCAG training

Hands-on workshop for your team on semantic HTML and ARIA

10. Summary

The semantic markup of lists and tables directly determines whether content is usable for screen reader users. <ul>, <ol>, and <dl> replace div constructs for enumerations, sequences, and key-value pairs, and automatically provide position and count information that assistive technologies read aloud. <th scope="col">, <th scope="row">, and <caption> make genuine data tables navigable, while the headers attribute becomes necessary for multi-level headers once scope alone is no longer sufficient.

Tables for visual layout have been obsolete since the introduction of CSS Grid and flexbox and produce meaningless screen reader announcements on every page load. The most reliable way to find semantic errors remains manual testing with NVDA or VoiceOver, supplemented by automated axe-core checks in the CI pipeline. Consistently applying these fundamentals in Magento and Hyvä templates makes product lists, navigation menus, and comparison tables equally accessible for all user groups.

Marking up lists and tables accessibly: the key takeaways

Choose the right list type

ul without order, ol with order, dl for key-value pairs. Never div soup for genuine enumerations.

th with scope

scope="col" and scope="row" on every header unambiguously map data cells.

caption instead of a heading

caption inside the table element is read automatically by screen readers before the data follows.

No layout with table

CSS Grid and flexbox instead of layout tables. Otherwise screen readers announce meaningless rows and columns.

11. FAQ: Marking Up Lists and Tables Accessibly

1Why isn't CSS styling enough to make a list look like a list?
CSS only changes appearance, not semantics. A screen reader does not recognize a div construction as a list and announces neither count nor navigation.
2When do I use ul, when ol, and when dl?
ul without meaningful order, ol with relevant order, dl for key-value pairs with dt/dd.
3What does the scope attribute on a th element do?
scope=col applies to the column, scope=row to the row. This lets the screen reader map the correct heading to every data cell.
4What is the caption element in a table for?
Provides a title programmatically linked to the table, read automatically before the data follows.
5When do I need the headers attribute instead of scope?
For multi-level header rows where a cell belongs to several headings. th gets an id, td references it via headers.
6Why should tables never be used for visual layout?
Screen readers announce rows and columns for every table, which is meaningless for pure layout. CSS Grid/flexbox solve this without semantic side effects.
7How do I test whether a list or table is correctly marked up?
With NVDA (Insert+F7 element list) or VoiceOver (rotor). Automated tools like axe-core only supplement, they do not replace manual testing.
8Is a green color enough to indicate availability?
No. Color is not read out by screen readers. Additional text or an sr-only status is mandatory.
9Does every child of a ul or ol have to be an li element?
Yes, HTML specification. Other direct children lead to inconsistent behavior in the accessibility tree across browsers.
10Do automated tools like axe-core reliably detect div soup?
Only partially, since no WCAG criterion is technically violated directly. Manual screen reader review remains indispensable.