# Empty headings, dangling id references, headerless tables: the defects nobody sees

Mega Analyzer counts empty h1 to h6 tags, label and aria references to ids not on the page, and data tables with no th cells. What each row reads, who it hurts, the fix.

Author: J.A. Watte
Published: September 21, 2026
Source: https://jwatte.com/blog/blog-mega-analyzer-invisible-structure/

---

Three rows on the Mega Analyzer's A11y (WCAG) tab share a trait: the page they flag looks finished. **N empty heading(s) (WCAG 2.4.6)**, **N aria-labelledby / for / skip-link reference(s) to a missing id (WCAG 1.3.1)** and **N data table(s) without `<th>` header cells (WCAG 1.3.1)** all describe markup that renders exactly as the designer intended, and that a screen reader, a crawler or a script reads as something else: a heading level with no heading, a label attached to nothing, a grid of numbers with no column meaning. The rows measure the gap between what the page shows and what the page says.

## What the check actually tests

All three rules live in the shared quick-check module at `/js/wcag-quick-checks.js`, which the Mega Analyzer and the Site Analyzer both load. They run against the server-rendered HTML the analyzer fetched and parsed into a document; your JavaScript never executes. Each rule pushes a row only when it fails. A clean page shows no "0 empty headings" line, so the absence of the row is the pass. Every fail lands under the red Fails heading in the Accessibility (WCAG quick check) card and adds one to the Critical fails tile.

**Empty headings.** The rule collects every `h1` through `h6`, drops the ones the tool treats as inert (inside `noscript` or `template`, under a `hidden` attribute, carrying `aria-hidden="true"`, or with an inline style of `display:none` or `visibility:hidden`), and counts the rest whose text content trims to nothing. A heading is rescued if it contains an `img` with a non-empty `alt`, an `svg` with an `aria-label`, or any descendant with an `aria-label`. So `<h1><img src="logo.svg" alt="Site name"></h1>` passes. `<h2></h2>` fails, `<h2> </h2>` fails, and `<h2>&nbsp;</h2>` fails too, because the trim the tool applies removes non-breaking spaces along with ordinary ones. The detail text reads: "Page builders leave `<h2></h2>` behind; screen readers announce "heading level 2" with nothing after it and the outline breaks. Delete the empty tags."

Three edges are worth knowing. A heading with an icon-font span and nothing else (`<h3><i class="icon-star"></i></h3>`) is counted as empty, correctly, since the icon has no name. A heading hidden by a stylesheet class rather than an inline style is still counted, because the tool never computes styles. And an `aria-label` on the heading tag itself does not rescue it; the rescue selector looks at descendants only. The [Headings Outline Audit](/tools/headings-outline/) is stricter in one respect: it reads text content alone, so a logo heading with a perfectly good `alt` shows as "(empty)" in its outline and earns a warning there.

**Broken id references.** This rule resolves references in three places. First, every element carrying `aria-labelledby`, `aria-describedby` or `aria-controls` that is not inert and has text content, its own or a descendant's: each space-separated id is looked up with `getElementById`, and every miss is recorded as, for example, `aria-labelledby="hero-title"`. Second, every `label for="…"` whose id has no element. Third, in-page links whose `href` starts with `#`, but only the ones that look like skip links: the href plus the link text has to contain skip, main or content. Those are resolved against ids and, as a fallback, against `name` attributes, so an old `<a name="content">` anchor still counts. The misses are deduplicated and the first four print in the detail after the sentence "The reference points at an id that is not on the page, so the label, description or skip target silently does nothing."

The text-content filter on the ARIA half is deliberate and has a side effect you need to know. It exists to skip empty containers a script fills later, such as a `<div aria-labelledby="status-heading"></div>` that a widget populates on load. But an `<input>` has no text content either, so `<input aria-labelledby="ghost">` is skipped by this rule, and the form-labels row on the same tab accepts `aria-labelledby` on sight without resolving it. A form control whose only label is an ARIA reference to nothing passes both rows today. The common form defect is caught from the other side: a `<label for="email">` whose input is `id="mail"` fails here every time, and so does `for="Email"` against `id="email"`, because id lookup is case-sensitive. Not checked at all: `aria-owns`, `aria-details`, `aria-errormessage`, the `headers` attribute on table cells, and ordinary table-of-contents links like `#pricing`.

**Table headers.** The rule takes every `table` that is not inert, does not carry `role="presentation"` or `role="none"`, has at least three `tr` rows, has at least one row with three or more `td` cells, contains no `th` anywhere, and contains no nested table. Each match is one data table without header cells. The detail names the usual suspects: "Rates, unit sizes and menus laid out as a table need header cells (`<th scope="col">`) so a screen reader can say which column a price belongs to. Layout-only tables should carry role="presentation" instead."

The size thresholds are the point. A two-column key-value table (Check-in: 3 PM, Check-out: 11 AM) never trips it, and neither does a two-row table. A table that wraps another table is treated as layout and skipped. A single `th` anywhere in the table, even one in the wrong place, passes the row; a `thead` full of `td` cells does not, because the rule wants the header element, not the header section. The dedicated [WCAG Accessibility Audit](/tools/wcag-accessibility-audit/) draws a harder line: it fails any table with neither `th` nor `thead`, whatever its size, so expect it to report tables the quick check let through.

## Why it matters

Headings are how screen reader users move through a page. The W3C's page-structure tutorial says browsers and assistive technologies use headings to provide in-page navigation. An empty `h2` is a stop on that route with nothing at it: the software announces the level, the user hears silence, and the outline has a gap where a section should be. WCAG 2.4.6 Headings and Labels is where the row files it. The Understanding document is explicit that the criterion does not require headings to exist, only that headings which do exist describe topic or purpose, and an empty tag describes nothing. The structural break is the 1.3.1 Info and Relationships side of the same defect, which is why the module's own comment lists both criteria.

A dangling reference is worse than a missing attribute, because it looks handled. The W3C's accessible name computation processes `aria-labelledby` only when the attribute contains at least one valid IDREF; references that do not resolve contribute nothing. The HTML standard says a `label` element's labeled control is the first element in tree order whose id matches `for`, and a label whose `for` matches nothing has no labeled control. So the control ends up with no name (a 4.1.2 Name, Role, Value failure) and the visible label sits next to it with no programmatic relationship (1.3.1). Techniques ARIA16 and H44 list exactly these two attributes as sufficient techniques for naming a control, and both depend on the id resolving. A skip link to a missing anchor is the same failure under 2.4.1 Bypass Blocks: technique G1 is a link that goes directly to the main content, and a link to `#content` on a page with no `id="content"` does nothing. The HTML standard's scroll-to-the-fragment steps find no indicated part, so the page does not scroll, focus stays on the link, and the user tabs through the navigation anyway.

Tables carry meaning in two directions, and header cells are how that meaning reaches a screen reader. Technique H63 covers `scope` on header cells so each data cell is associated with its column or row header; H43 covers `id` and `headers` for tables where a cell has more than one header; failure F91 describes what the row detects, a data table whose header cells are missing or not marked up as headers. When a rates table has no `th`, a screen reader reading the cell "$89" cannot say "Weekly, Queen" the way it can when the headers are marked up; the user gets the numbers in reading order and has to count columns in their head.

All three findings share one property that makes them worth fixing first: a scanner can prove each from the HTML alone, and demand letters quote scanner output. "Empty heading", "form label references missing id" and "table has no header cells" are the kind of plain-language lines that appear in them. The [ADA litigation risk post](/blog/blog-tool-ada-litigation-risk/) covers who sends those letters.

## How to fix it

**1. Empty headings.** Delete the tag; do not fill it. Page builders leave a heading widget with no text when someone clears the title of a section, and themes sometimes emit an `h2` for a widget area that has no widgets. Find them in the live DOM with the same test the row applies, then remove the element in the builder or the template, not with CSS:

```js
// DevTools: list the headings the Mega Analyzer row would count as empty
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => {
  const text = (h.textContent || '').replace(/\s+/g, ' ').trim();
  const labeled = h.querySelector('img[alt]:not([alt=""]), svg[aria-label], [aria-label]');
  if (!text && !labeled) console.log(h.tagName, h.className, h.outerHTML.slice(0, 120));
});
```

If the heading is a logo, give the image its alt and leave the tag; that is not an empty heading. If the heading is an icon that visually labels a section, the icon needs to be hidden from assistive technology and the section needs a text heading, so write one:

```html
<!-- before: an icon-only section heading and a leftover -->
<h2><i class="icon-key"></i></h2>
<h2></h2>

<!-- after -->
<h2><span class="icon-key" aria-hidden="true"></span> Check-in and keys</h2>
```

**2. Dangling id references.** Take the list from the row's detail and, for each one, decide which side moved. If the element exists under a different id, rename the reference. If the element was deleted, delete the reference too, or replace it with an `aria-label` when the text it pointed at is gone for good. Never drop the `for` off a label to silence the row; that turns a broken relationship into no relationship. The same rename discipline applies to skip links: when a template renames `id="main"` on the `<main>` element, the link needs updating too. The [WCAG Fix Generator](/tools/wcag-fix-generator/) turns the pasted failure list into a remediation prompt that walks each reference.

```html
<!-- before -->
<label for="guest-email">Email</label>
<input id="email" type="email" name="email" autocomplete="email">

<section aria-labelledby="rates-title">
  <h2 id="rates-heading">Nightly and weekly rates</h2>
</section>

<a class="skip-link" href="#content">Skip to main content</a>
<main id="main">…</main>

<!-- after -->
<label for="email">Email</label>
<input id="email" type="email" name="email" autocomplete="email">

<section aria-labelledby="rates-heading">
  <h2 id="rates-heading">Nightly and weekly rates</h2>
</section>

<a class="skip-link" href="#main">Skip to main content</a>
<main id="main">…</main>
```

Because the row skips elements with no text of their own, run a wider sweep in DevTools once, after the page's scripts have finished. This one resolves every id-reference attribute on every element, inputs included, against the live DOM:

```js
// DevTools: every id reference on the page that resolves to nothing
const attrs = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-details', 'aria-errormessage', 'for', 'headers'];
document.querySelectorAll(attrs.map(a => '[' + a + ']').join(',')).forEach(el => {
  attrs.forEach(a => {
    const v = el.getAttribute(a);
    if (v === null) return;
    if (a === 'for' && el.tagName !== 'LABEL') return;
    v.split(/\s+/).filter(Boolean).forEach(id => {
      if (!document.getElementById(id)) console.log(el.tagName, a + '="' + id + '"');
    });
  });
});
```

An id that only exists after a component library runs will show up in the analyzer and not here. That tells you the static HTML is the thing missing the id, which matters for every crawler that does not execute scripts; the [Prerender vs Hydration Parity](/tools/prerender-js-hydration-parity/) tool shows what else the static response lacks.

**3. Tables.** Put the header row inside a `thead` as `th` cells with `scope="col"`. If the first column is also a header (the room names in a rates table), mark those cells `th scope="row"`. Add a `caption` so the table has a name in the screen reader's table list. For a rates table on a lodging or storage site, the finished shape is:

```html
<table>
  <caption>Room rates, tax not included</caption>
  <thead>
    <tr>
      <th scope="col">Room</th>
      <th scope="col">Nightly</th>
      <th scope="col">Weekly</th>
      <th scope="col">Monthly</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Queen</th>
      <td>$89</td>
      <td>$490</td>
      <td>$1,400</td>
    </tr>
    <tr>
      <th scope="row">Double queen</th>
      <td>$109</td>
      <td>$600</td>
      <td>$1,700</td>
    </tr>
  </tbody>
</table>
```

Reserve `id` and `headers` (technique H43) for tables where a cell answers to more than one header on the same axis, such as grouped column headers spanning two rows; for a single header row, `scope` is enough and easier to keep correct. If the table is a layout grid that happens to use table tags (an old theme's three-column footer), the fix is `role="presentation"` on the `table` and nothing else: no `th`, no `caption`, no `summary`. Better still, replace it with CSS grid.

**4. Verify.** Re-run the [Mega Analyzer](/tools/mega-analyzer/) and confirm the three rows are gone from the Fails list; there is no green row to look for, since these rules only report failures. Run the [Headings Outline Audit](/tools/headings-outline/) to see the outline the way a screen reader's heading list presents it. Run the [WCAG Accessibility Audit](/tools/wcag-accessibility-audit/) for the full sweep: its table rule covers every table, and its duplicate-id rule separates a duplicate that something references (a critical 4.1.2 fail there, a warning in the quick set) from one nothing points at. A referenced id that exists twice is the mirror image of one that exists zero times. Paste that failure list into the [WCAG Fix Generator](/tools/wcag-fix-generator/) for a prompt keyed to each criterion.

## When to leave it alone

A heading that holds only a labeled image is fine. `<h1><img alt="Site name"></h1>` passes the row, and the accessible name is the alt text. Do not add hidden text beside it to satisfy the outline tool's "(empty)" warning; you would announce the name twice.

An empty heading hidden by a stylesheet class (a `d-none` utility on a section a client turned off) is counted by this row even though `display:none` keeps it out of the accessibility tree, so a screen reader never meets it. That flag is a false alarm in the strict sense. Delete the tag anyway; it costs nothing and the row goes quiet.

A reference to an id that a script adds later is a real gap in the static HTML and a non-issue in the rendered page. A tabs library that generates `id="panel-1"` on load and points `aria-controls` at it is the usual case. Confirm with the DevTools sweep above; if the live DOM resolves, decide whether server-rendering the id is worth it for crawlers, and do not remove the attribute to green the row.

Two-column and two-row tables are exempt by design and should stay tables; a check-in time list is tabular data, and it does not need a `th` when there is nothing to disambiguate. Conversely, a layout table must never get header cells to satisfy the row. WCAG failure F46 names `th`, `caption` or a non-empty `summary` in a layout table as a failure of 1.3.1, because it tells assistive technology to treat decoration as data. Use `role="presentation"` there, and the row is written to accept it.

## Fact-check notes and sources

- **Source**: https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html establishes WCAG 2.4.6 Headings and Labels (Level AA): headings and labels describe topic or purpose; the criterion does not require headings or labels to exist, only that those provided be descriptive.
- **Source**: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html establishes WCAG 1.3.1 Info and Relationships (Level A): information, structure and relationships conveyed through presentation can be programmatically determined or are available in text.
- **Source**: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html establishes WCAG 4.1.2 Name, Role, Value (Level A): the name of every user interface component can be programmatically determined.
- **Source**: https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html establishes WCAG 2.4.1 Bypass Blocks (Level A), the criterion a skip link serves.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/general/G1 establishes technique G1: a link at the top of each page that goes directly to the main content area.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA16 establishes technique ARIA16: using aria-labelledby to provide a name for user interface controls, where the attribute value is the id of the element whose text becomes the name.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/html/H44 establishes technique H44: using label elements to associate text labels with form controls through the for attribute and the control's id.
- **Source**: https://www.w3.org/TR/accname-1.2/ establishes the accessible name computation, which processes aria-labelledby only when it contains at least one valid IDREF, so a reference to a missing id contributes nothing to the name.
- **Source**: https://html.spec.whatwg.org/multipage/forms.html#attr-label-for establishes that a label's labeled control is the first element in tree order whose ID equals the for value, provided it is labelable, and that otherwise the label has no labeled control.
- **Source**: https://dom.spec.whatwg.org/#dom-nonelementparentnode-getelementbyid establishes that getElementById returns the first element in tree order whose ID equals the argument, an exact comparison, which is why a reference that differs only in letter case resolves to nothing.
- **Source**: https://html.spec.whatwg.org/multipage/browsing-the-web.html#scroll-to-the-fragment-identifier establishes the scroll-to-the-fragment steps: when the fragment names no element (the indicated part is null), the document's target element is cleared and no scrolling or focusing happens, so a skip link to a missing id leaves the user where they were.
- **Source**: https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion establishes that elements hidden with CSS display:none, visibility:hidden or the HTML hidden attribute are excluded from the accessibility tree, which is why a class-hidden empty heading is not announced even though the row counts it.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/html/H43 establishes technique H43: using id and headers attributes to associate data cells with header cells in data tables.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/html/H63 establishes technique H63: using the scope attribute to associate header cells and data cells in data tables.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/html/H51 establishes technique H51: using table markup to present tabular information.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/failures/F91 establishes failure F91: a data table whose header cells are not correctly marked up fails 1.3.1.
- **Source**: https://www.w3.org/WAI/WCAG22/Techniques/failures/F46 establishes failure F46: th elements, caption elements or non-empty summary attributes in a layout table fail 1.3.1.
- **Source**: https://www.w3.org/WAI/tutorials/page-structure/headings/ establishes that headings communicate the organization of the content and that browsers and assistive technologies use them for in-page navigation.
- **Source**: https://www.w3.org/WAI/tutorials/tables/ establishes that header cells must be marked up with th and data cells with td, and covers scope, id and headers for simple and complex tables.
- **Source**: https://www.w3.org/TR/wai-aria-1.2/#presentation establishes the presentation role, with none as its synonym, which removes an element's native semantics from the accessibility tree; this is what exempts a layout table from the row.
- **Source**: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/th establishes the scope attribute values row, col, rowgroup and colgroup on th.
- **Source**: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim establishes that trim removes whitespace including the no-break space, which is why a heading holding only a non-breaking space counts as empty.

## Related reading

- [Main and nav landmarks, one title, unique IDs: the skeleton screen readers rely on](/blog/blog-mega-analyzer-landmarks-title-unique-ids/)
- [Heading Outline Audits: Catching the H2-to-H4 Skip Search Engines Penalize](/blog/blog-tool-headings-outline/)
- [Why WCAG A11y Audit Exists](/blog/blog-tool-wcag-accessibility-audit/)
- [Most forms fail accessibility before anyone ever fills them out](/blog/blog-tool-form-accessibility/)
- [WCAG Fix Generator: Turn the Audit's Failure List Into an AI Remediation Prompt](/blog/blog-tool-wcag-fix-generator/)

If you built the site yourself on a page builder, the leftovers are yours to delete, and they are the cheapest accessibility fixes you will make this year. *The $97 Launch* is written for that builder: the person shipping a site alone who wants to know which of the hundred warnings to take seriously first.

*This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.*


---

Canonical HTML: https://jwatte.com/blog/blog-mega-analyzer-invisible-structure/
RSS: https://jwatte.com/feed.xml
JSON Feed: https://jwatte.com/feed.json
Hero image: https://jwatte.com/images/blog-mega-analyzer-invisible-structure.webp
