Lists Must Only Contain List Items

No Comments
Lists must only contain list items
TL;DR: The only elements allowed as direct children of a <ul> or <ol> are <li> (plus <script> and <template>). Wrapping list items in <div>s or dropping stray tags between them breaks the list semantics that screen readers and content parsers rely on. Move the extra markup inside the <li> and the problem disappears.
Check ID
HT-024
Category
HTML Structure
Severity
Medium
Standard
WCAG 1.3.1
Fix Effort
Low to Medium

What this check actually flags

The HTML content model for <ul> and <ol> is strict: direct children must be <li> elements. The spec also tolerates <script> and <template> because they render nothing, and the axe-core rule this check mirrors (rule id: list) allows exactly those. Everything else placed directly inside a list, a <div>, a <p>, a stray <a>, a <span> separator, is a violation.

The classic offender looks like this:

<ul class="products">
  <div class="row">
    <li>Product one</li>
    <li>Product two</li>
  </div>
  <span class="divider"></span>
  <li>Product three</li>
</ul>

I see this constantly in component-based frontends. Someone needs a grid row wrapper or a decorative divider, the list is already there, so the extra element gets jammed between the <li>s. It renders fine visually, which is exactly why nobody catches it until an audit does.

There is a nastier version of the same mistake: the wrapper div arrives via a framework component. A React map that returns <div key=...><li>...</li></div>, a Vue transition-group defaulting to a div inside a ul, a slider library that injects its own wrapper elements into your markup at runtime. The source template looks clean, the rendered DOM does not. Always audit the rendered DOM.

Why broken list structure costs you

Assistive technology loses the plot. Screen readers announce lists as lists: "list, 8 items", then track position as "3 of 8". That announcement is built from the accessibility tree, which is built from your DOM. Non-<li> children can fragment the list, throw off item counts, or split one list into several phantom lists depending on the browser and screen reader pairing. For a navigation menu, that means a user hears a wrong or confusing map of your site. This is a WCAG 1.3.1 (Info and Relationships) failure, and it is the kind that shows up in accessibility complaints, not just tool reports.

Parsers get a worse version of your content. Search engines lift list markup into list featured snippets and use structural HTML to segment and understand content. So do the AI crawlers everyone suddenly cares about. Clean <ul>/<li> structure is trivially machine-readable. A list interleaved with divs and spans forces every parser to guess where items begin and end. Browsers are forgiving because their error recovery is excellent; I would not bet my snippet eligibility on every downstream parser being as generous.

Error recovery can mangle your DOM. With certain invalid nestings, the HTML parser relocates elements while repairing your markup, so the DOM your CSS and JavaScript operate on is not the DOM you wrote. If you have ever lost an hour to a selector that "should" match, invalid nesting is a classic cause.

Wrong versus right, structurally

Broken: div as child of ul <ul> <div class="row"> <li>Item 1</li> <span class="divider"> <li>Item 2</li> </ul> Screen reader: item count unreliable Fixed: only li children <ul> <li class="row"> Item 1 <li class="row"> Item 2 </ul> Styling moved onto the li itself, divider drawn with CSS border Screen reader: "list, 2 items"

How to detect it at scale

  • W3C Nu HTML Checker: flags every non-li child of ul/ol as a document error. Best for checking a template once you know which one is dirty.
  • axe DevTools or Lighthouse: the axe list rule reports these per page under the accessibility audit. Lighthouse runs it out of the box, so you may already have this data in your CI and nobody is reading it.
  • Screaming Frog: use a Custom Extraction with XPath like //ul/*[not(self::li or self::script or self::template)] to pull offending elements across an entire crawl. This is my go-to because it turns a per-page accessibility finding into a site-wide inventory grouped by template.
  • Sitebulb: includes list structure among its accessibility hints during a standard crawl, useful when you want the finding bundled with the rest of an audit.
  • Browser console spot check: document.querySelectorAll("ul > :not(li):not(script):not(template), ol > :not(li):not(script):not(template)") gives you an instant answer on the rendered DOM, which catches the JavaScript-injected wrappers a source-code review misses.

How to fix it, step by step

  1. Group findings by template. A thousand flagged URLs is usually three components: a nav menu, a card grid, a footer link block. Fix components, not pages.
  2. Move wrappers inside the li. If a div exists to style or group content, it lives happily inside <li>: <li><div class="card">...</div></li>. An <li> can contain nearly any flow content, so there is almost never a layout reason the wrapper must sit outside it.
  3. Or promote the wrapper's job onto the li. Often the cleaner move: put the class on the <li> itself and delete the div. Less markup, same rendering.
  4. Replace decorative separators with CSS. Divider spans between items become li + li { border-top: 1px solid #e3e5e8; }. Presentational markup out, stylesheet in.
  5. If the content is not actually a list, stop using ul. Some builds use <ul> as a generic layout hook for things that are not lists at all. Swap it for <div>s and the semantics problem evaporates.
  6. Watch third-party scripts. Slider and carousel libraries that wrap your slides at runtime are repeat offenders. Configure them to use your <li>s as slides, or hand them divs instead of a list.
  7. Re-verify on the rendered DOM. Re-run the console query and a Lighthouse pass after deploy, not just against source templates.

Reference: what may sit where

ElementDirect child of ul/ol?Inside an li?Notes
<li>YesOnly via a nested listThe one true child.
<script>, <template>YesYesRender nothing, so the spec and axe both allow them.
<div>, <span>NoYesMove them one level down; nothing visual changes.
<a>, <p>, headingsNoYesCommon in hand-rolled nav menus; wrap each in an li.
Nested <ul>/<ol>NoYesSublists go inside the parent li, not beside it. Menus get this wrong constantly.

DO

  • Keep ul/ol children to li, script, and template only
  • Put wrapper divs and rich content inside the li
  • Nest sublists inside their parent li
  • Draw separators with CSS borders or pseudo-elements
  • Verify the rendered DOM, since scripts inject wrappers

DON'T

  • Wrap groups of li elements in layout divs
  • Drop decorative spans or hr tags between items
  • Place a nested ul as a sibling of li elements
  • Use ul as a generic container for non-list content
  • Slap role="presentation" on a real list to silence the checker

What good looks like

Every <ul> and <ol> on the site contains nothing but <li> elements, all styling hooks live on or inside those items, sublists sit within their parent item, and a Screaming Frog extraction for non-li children comes back empty on the rendered DOM. Screen readers announce accurate item counts, the W3C validator stays quiet, and your lists are in the cleanest possible shape for featured snippet extraction. Honestly, of all the audit findings I hand to dev teams, this is one of the most satisfying: the fix is mechanical, the diff is small, and it never regresses if you add the console one-liner to your review checklist.

FAQ

Browsers render my list fine, so does this really matter?
Visual rendering survives because browser error recovery is excellent. The damage lands in the accessibility tree, in parsers that are stricter than browsers, and occasionally in a repaired DOM that no longer matches your source. "Looks fine" is the least reliable test in this whole area.
Can I put a div inside an li?
Yes, freely. An li accepts flow content, so divs, images, headings, even entire card layouts are legal inside it. The restriction applies only to the direct children of the ul or ol itself.
How do I nest a submenu correctly?
The nested ul goes inside the parent li, after that item's link: <li><a>Products</a><ul>...</ul></li>. Placing the sublist as a direct child of the outer ul is the single most common nesting mistake in navigation markup.
Does fixing list markup improve rankings?
Not directly, and anyone promising a rankings jump from valid HTML is overselling. What you get is reliable machine readability: cleaner extraction for list snippets, an accurate accessibility tree, and one less source of parser guesswork. Those are worth having; treat them as infrastructure, not a growth hack.
My CSS framework requires a wrapper between ul and li. Now what?
Then stop using ul for that component. If the framework's grid demands intermediate wrappers, build it from divs and, only if it genuinely represents a list, add role="list" to the container and role="listitem" to the items. Valid divs beat an invalid ul every time.

One flagged list usually means a dozen quieter template bugs

Structure errors travel in packs: broken lists, orphaned headings, invalid nesting, duplicate IDs. Our advanced audit sweeps the full rendered DOM across your site and hands you a prioritized, component-level fix list.

Get the Advanced SEO Audit

Claude Vincent is a technical SEO consultant focused on crawlability, rendering, and AI-search visibility. He writes the field guides and case studies at SEO ProCheck, with a bias toward the durable, unglamorous work that decides whether search engines and AI answer engines can actually read and cite a site.

About SEO ProCheck

Technical SEO consulting and GEO strategy with 20 years of enterprise experience. Case studies, resources, and tools for search and AI visibility.

Work With Me

Technical SEO audits, GEO strategy, site migrations, and international SEO. Hourly consulting for teams who need hands-on support, not just reports.

Subscribe to our newsletter!

More from our blog