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
- 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.
- 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.
- 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.
- 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).
- 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).
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: dashboarddocument contains zero<h1>–<h6>elements: the dashboard title renders asdiv.navbar-title, page titles as tab links, card titles asdiv.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:
## Row/### Chart Onepattern, "Chart One" appears nowhere in the rendered HTML.::: {.card}div survive, but are converted to<div class="h4">etc. — styled like headings, invisible to heading navigation.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:
This renders with exactly two heading elements: the raw
<h2>and<h3>.Problems with the workaround
page-has-heading-one,heading-order)..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:
Heading tree while the "Traffic" page is active:
The "Search" page's
h1exists in the DOM but its tab panel isdisplay: none(Bootstrap.tab-content > .tab-pane { display: none }), so exactly one h1 is exposed to assistive technology at a time.Design details
h1inside each page's tab panel (Bootstrap's.visually-hiddenis 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 thebannerlandmark — exactly how Quarto websites already treat the site title vs. the pageh1.<h2 class="card-title">): Bootstrap declares heading rules as element/class pairs (h4, .h4 { … }), so framework styling and class-based user CSS are unchanged.{.sr-heading}), rendered as a visually hidden heading one level below the nearest in-outline ancestor. Containers without it — implicit, or namedRow/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.<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 hardcoded4, emit real tags with the.hNclasses, and address the cap-at-6 collision (currently###and####in a card starting at#both flatten to.h6).{.sr-heading}to expose it to assistive technology") makes the attribute discoverable; the documentedRow/Columnplaceholders 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
quarto-post/dashboard.lua#L407-L414modules/dashboard/card.lua#L48-L66, with the level-4 normalization atcard.lua#L228-L236(introduced in ff83f73 for visual consistency; the loss of semantics looks like a side effect).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).