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.
Table of contents
- 1. Why semantic markup determines accessibility
- 2. ul, ol, and dl: the three list types in HTML
- 3. The problem with div soup instead of real lists
- 4. Navigation, cart, and product grids as lists
- 5. Table basics: th, scope, and caption
- 6. Complex tables: headers, id, and multi-level headers
- 7. Why tables should never be used for visual layout
- 8. Screen reader testing: how NVDA and VoiceOver read lists and tables
- 9. Practical example: an accessible product comparison table
- 10. Summary
- 11. FAQ
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>
4. Navigation, cart, and product grids as lists
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.
| 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.