# Lists Are ul/li, Tables Are th/td

**MUST** · **ID:** `content-list-and-table-semantics` · **Category:** content
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-list-and-table-semantics

> MUST: Lists are `<ul>`/`<ol>` + `<li>`; tables are `<table>` with `<th scope="col|row">` for headers. A `<div className="space-y-2">` of divs announces nothing — no "list, 5 items", no way to skip it — and a div grid with a bold header row reads a cell as a naked "42" instead of "Revenue, Q3, 42". Bold is not a header. Tailwind's `list-none` drops the marker without dropping the semantics, so there is no styling reason to use divs.

A stack of divs looks like a list and a grid of divs looks like a table, but neither announces any structure to a screen reader

These are the two most common div-soup offences in generated markup, because a `<div className="space-y-2">` of `<div>`s and a flexbox grid with a bold header row *look* exactly right. What they lose is everything a non-visual user navigates by. A real `<ul>` announces "list, 5 items" and gives the reader a way to jump to the next item or skip the list entirely; a stack of divs announces nothing, and the reader has no idea how much is left. The table case is worse, because the loss is per-cell: ibelick's companion rule is "tables must use th for headers when applicable", and a `<th scope="col">` is what makes a screen reader read a cell in the middle of the grid as "Revenue, Q3, 42" instead of a naked "42" with no idea which column or row it belongs to. That association cannot be recovered from visual weight — bold is not a header. content-semantics-first states the principle in general; this is what it actually costs in the two places it is most often ignored. Note that Tailwind's `list-none` removes the marker without removing the semantics, so there is no styling reason to reach for divs.

## Bad — do not do this

`content-list-and-table-semantics-bad`

```tsx
/**
 * BAD: div soup. It looks exactly right and announces nothing.
 * The "list" is a stack of divs — no item count, no list navigation. The "table" is
 * flexbox rows with a bold div header — a screen reader reading the cell in the middle
 * of the grid says "42", with no column and no row to attach it to.
 */
export function ListAndTableSemanticsBad() {
  return (
    <div className="space-y-4">
      <div>
        <p className="mb-2 text-sm font-medium text-foreground">Deploy steps</p>
        <div className="space-y-2">
          {['Install dependencies', 'Run tests', 'Build bundle', 'Upload artifact', 'Invalidate cache'].map(
            (step) => (
              <div key={step} className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground">
                {step}
              </div>
            )
          )}
        </div>
      </div>

      <div>
        <p className="mb-2 text-sm font-medium text-foreground">Revenue</p>
        <div className="rounded-md border border-border">
          <div className="flex border-b border-border bg-muted">
            <div className="flex-1 px-3 py-2 text-xs font-bold text-foreground">Region</div>
            <div className="flex-1 px-3 py-2 text-xs font-bold text-foreground">Q2</div>
            <div className="flex-1 px-3 py-2 text-xs font-bold text-foreground">Q3</div>
          </div>
          {[
            ['EMEA', '38', '42'],
            ['AMER', '51', '55'],
          ].map((row) => (
            <div key={row[0]} className="flex border-b border-border last:border-b-0">
              {row.map((cell) => (
                <div key={cell} className="flex-1 px-3 py-2 text-sm text-foreground">
                  {cell}
                </div>
              ))}
            </div>
          ))}
        </div>
      </div>

      <p className="text-xs text-destructive">
        Announced for the EMEA/Q3 cell: <span className="font-mono">&ldquo;42&rdquo;</span>. That is all.
        No &ldquo;list, 5 items&rdquo;, no column, no row — bold is not a header, and a div is not an item.
      </p>
    </div>
  );
}
```

## Good — do this

`content-list-and-table-semantics-good`

```tsx
/**
 * GOOD: the same two blocks, with the structure the browser can actually expose.
 * `ul`/`li` gives "list, 5 items" and list navigation. `th scope` associates every cell
 * with its row and column, so a cell in the middle of the grid reads as
 * "Revenue, Q3, 42" instead of a naked number.
 * Identical pixels — `list-none` removes the marker, not the semantics.
 */
export function ListAndTableSemanticsGood() {
  return (
    <div className="space-y-4">
      <div>
        <p id="deploy-steps-label" className="mb-2 text-sm font-medium text-foreground">
          Deploy steps
        </p>
        <ul aria-labelledby="deploy-steps-label" className="list-none space-y-2">
          {['Install dependencies', 'Run tests', 'Build bundle', 'Upload artifact', 'Invalidate cache'].map(
            (step) => (
              <li key={step} className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground">
                {step}
              </li>
            )
          )}
        </ul>
      </div>

      <table className="w-full table-fixed border-collapse overflow-hidden rounded-md border border-border">
        <caption className="mb-2 text-left text-sm font-medium text-foreground">Revenue</caption>
        <thead>
          <tr className="border-b border-border bg-muted">
            <th scope="col" className="px-3 py-2 text-left text-xs font-medium text-foreground">
              Region
            </th>
            <th scope="col" className="px-3 py-2 text-left text-xs font-medium text-foreground">
              Q2
            </th>
            <th scope="col" className="px-3 py-2 text-left text-xs font-medium text-foreground">
              Q3
            </th>
          </tr>
        </thead>
        <tbody>
          {[
            ['EMEA', '38', '42'],
            ['AMER', '51', '55'],
          ].map(([region, q2, q3]) => (
            <tr key={region} className="border-b border-border last:border-b-0">
              <th scope="row" className="px-3 py-2 text-left text-sm font-normal text-foreground">
                {region}
              </th>
              <td className="px-3 py-2 text-sm text-foreground">{q2}</td>
              <td className="px-3 py-2 text-sm text-foreground">{q3}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <p className="text-xs text-success">
        Announced for the same cell: <span className="font-mono">&ldquo;Revenue, EMEA, Q3, 42&rdquo;</span> —
        caption, row header, column header, value. And the list announces &ldquo;list, 5 items&rdquo;.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [MDN — <th> element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/th)
- [W3C — Web Accessibility Tutorials: Tables](https://www.w3.org/WAI/tutorials/tables/)
