Skip to content

Dashboards produce no heading elements #14698

Description

@cwickham

Authors want, and readers need, real headings

Navigating by headings is the most commonly reported way screen reader users find information on a page (WebAIM screen reader surveys). A rendered format: dashboard document contains zero <h1><h6> elements: the dashboard title renders as div.navbar-title, page titles as tab links, card titles as div.card-title, and markdown headings are consumed by layout. For a screen reader user, a dashboard that looks richly structured is a single undifferentiated block.

Authors are trying to do the right thing and being silently undone:

  • Markdown headings at every level (2–6) in flow content are treated as layout boundaries and their text is discarded from the output. In the canonical ## Row / ### Chart One pattern, "Chart One" appears nowhere in the rendered HTML.
  • Markdown headings inside a ::: {.card} div survive, but are converted to <div class="h4"> etc. — styled like headings, invisible to heading navigation.
  • Card titles (title= / #| title:) visually function as headings but are divs, which is a WCAG 2.2 SC 1.3.1 Info and Relationships failure: structure conveyed visually is not programmatically determinable.

The current workaround

Raw HTML heading tags inside cards pass through untouched:

---
title: "Website Analytics"
format: dashboard
---

# Traffic

## Row

::: {.card title="About this report"}
<h2>How we measure traffic</h2>

This report summarizes visits recorded by our analytics platform.

<h3>Data sources</h3>

Counts combine server logs and client-side events.
:::

::: {.card title="Visits by day"}
Weekday visits are roughly double weekend visits.
:::

# Search

This renders with exactly two heading elements: the raw <h2> and <h3>.

Problems with the workaround

  • The outline starts at h2 with no h1 anywhere, and there is nothing to anchor levels to — the author picks numbers blind. Automated checkers flag this (axe page-has-heading-one, heading-order).
  • The outline contradicts the visual hierarchy. The card title "About this report" sits visually above "How we measure traffic" but is a div, so heading navigation lands on the h2 with no context. Page names ("Traffic", "Search") are absent entirely.
  • No style normalization. Markdown headings in cards are deliberately renormalized to card-appropriate sizes (shallowest → .h4); raw tags bypass this, so a raw <h2> renders at full document scale inside a small card.

Proposed behavior

One principle: text already visible to sighted users becomes a real heading automatically; invisible text joins the outline only by explicit opt-in. A hypothetical dashboard:

---
title: "Website Analytics"
format: dashboard
---

# Traffic

## Row

Value boxes here — purely visual arrangement, no heading wanted.

## Where visitors come from {.sr-heading}

::: {.card title="Visits by day"}
Weekday visits are roughly double weekend visits.

### Data sources

Counts combine server logs and client-side events.
:::

# Search

Heading tree while the "Traffic" page is active:

h1  Traffic                          ← page title, visually hidden
│   (## Row: no attribute → not in the outline)
└── h2  Where visitors come from     ← opted-in row, visually hidden
    └── h3  Visits by day            ← card title, visible, real element
        └── h4  Data sources         ← card-body heading, renumbered from ###

The "Search" page's h1 exists in the DOM but its tab panel is display: none (Bootstrap .tab-content > .tab-pane { display: none }), so exactly one h1 is exposed to assistive technology at a time.

Design details

  1. Page title → visually hidden h1 inside each page's tab panel (Bootstrap's .visually-hidden is already bundled; zero visual change). A pageless dashboard is a single page whose title is the dashboard title, so single- and multi-page follow one rule. The navbar title stays a non-heading in the banner landmark — exactly how Quarto websites already treat the site title vs. the page h1.
  2. Card titles → real heading elements, one level below their nearest in-outline ancestor (h2 in the common case). Keep the existing classes (<h2 class="card-title">): Bootstrap declares heading rules as element/class pairs (h4, .h4 { … }), so framework styling and class-based user CSS are unchanged.
  3. Layout headings join the outline only with an explicit attribute (e.g. {.sr-heading}), rendered as a visually hidden heading one level below the nearest in-outline ancestor. Containers without it — implicit, or named Row/left/anything — stay outline-transparent, like layout divs in plain HTML. This is backwards compatible by construction (no existing dashboard changes visually or aurally), avoids guessing intent from throwaway names, gives purely-visual rows (value-box strips) no heading, and handles arbitrarily deep row/column nesting without exhausting h1–h6.
  4. Card-body markdown headings → real <h*> elements. The per-card renumbering machinery already normalizes the shallowest heading to level 4; keep it, but make the base dynamic (one below the card title's level) instead of the hardcoded 4, emit real tags with the .hN classes, and address the cap-at-6 collision (currently ### and #### in a card starting at # both flatten to .h6).
  5. Warn when layout-heading text is discarded. Today it vanishes silently. A warning ("layout heading text is discarded; add {.sr-heading} to expose it to assistive technology") makes the attribute discoverable; the documented Row/Column placeholders silence it.

If compatibility is a concern despite the above, the whole behavior could ship behind a format option before becoming the default.

Where this happens in the source

Behavior above observed with Quarto 1.10.12. AI assistance was used to investigate, grounded in a local clone of this repository (per CONTRIBUTING.md).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions