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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions