quarto-dev / quarto-dev/quarto-cli
Broken aria-labelledby for htmlwidgets that emit aria attributes (e.g., rgl)
@cderv is already working on this.
Since May 15, 2026.
- Dominant language
- JavaScript
- Stars
- 6k
- Forks
- 458
- Avg merge
- 1d 9h
- Merged PRs (30d)
- 41
Description
Thanks to @jfieberg for the report in quarto-dev/quarto-cli#14515.
htmlwidgets that emit their own aria-labelledby attribute pointing to a sibling label element produce broken ARIA references in Quarto HTML output. The label target element is expected to be supplied by the renderer (knitr or Quarto), but Quarto's override of knitr::add_html_caption() silently discards the id and never emits the target. WAVE and axe report the broken reference at scan time.
The concrete trigger is rgl's rglwidget(), but any htmlwidget that uses the same pattern (own role="img" + aria-labelledby, with no embedded target) will hit this.
Reproduction
---
title: "RGL Accessibility"
format: html
---
::: {#fig-rgl}
```{r}
#| echo: false
library(rgl)
setupKnitr(autoprint = FALSE)
plot3d(iris$Sepal.Length, iris$Sepal.Width, iris$Petal.Length,
col = as.numeric(iris$Species), type = "s", size = 1)
rglwidget()
```
An interactive 3D plot.
:::
Open in browser, run WAVE → two "Broken ARIA reference" errors on the rgl widget's outer <div> and on the <canvas> it injects at runtime. Both aria-labelledby attributes reference htmlwidget-XXXX-aria, but no element with that id exists.
Mechanism
rgl emits aria-labelledby on the outer widget div statically (source):
widget_html.rglWebGL <- function(id, style, class, ...){
result <- tags$div(id = id, style = style, class = class, role = "img",
"aria-labelledby" = ariaLabelId(id))
# In shiny, we need to write the alt text label.
if (inShiny())
result <- tags$div(tags$p("3D plot 1", id = ariaLabelId(id),
hidden = NA),
result)
result
…and on the <canvas> dynamically when the widget initializes (source):
labelid = this.el.getAttribute("aria-labelledby");
newcanvas.width = this.el.width;
newcanvas.height = this.el.height;
newcanvas.setAttribute("aria-labelledby",
labelid);
rgl deliberately skips emitting its own <p id="X-aria" hidden>alt</p> target whenever knitr ≥ 1.42.12 is in use, on the assumption that knitr (or its host) will emit the label (source):
# We always emit aria-labelledby. We need to
# choose here whether to write the label, or rely
# on other code to write it. We let other code write it
# in new knitr and Shiny, and otherwise do it ourselves.
if (!in_knitr_with_altText_support() && !inShiny())
result <- htmlwidgets::prependContent(result,
tags$p(altText, id = ariaLabelId(elementId),
hidden = NA))
knitr ≥ 1.43 added that capability — in non-Quarto mode, sew.knit_asis() extracts the aria-labelledby value from the widget HTML via regex and passes it as id to add_html_caption(), which then emits the hidden <p> target (source):
if (inherits(x, 'knit_asis_htmlwidget')) {
options$fig.cur = plot_counter()
options = reduce_plot_opts(options)
# TODO: remove this when quarto > 1.3.353 is widely used
if (is_quarto()) return(add_html_caption(options, wrap_asis(x, options)))
# look for attribute 'aria-labelledby="label"' in the first HTML tag and
# use the label to provide alt text if found
return(add_html_caption(
options, wrap_asis(x, options),
xfun::grep_sub('^[^<]*<[^>]+aria-labelledby[ ]*=[ ]*"([^"]+)".*$', '\\1', x)
))
}
And add_html_caption() itself (source):
add_html_caption = function(options, code, id = NULL) {
cap = .img.cap(options)
if (cap == '' && !length(id)) return(code)
if (length(id)) {
alt = .img.cap(options, alt = TRUE, escape = TRUE)
if (cap == alt && cap != '') {
# both are the same, so insert cap with id
alttext = sprintf('<p class="caption" id="%s">%s</p>\n', id, cap)
# prevent a second insertion
cap = ''
} else {
alttext = sprintf('<p id="%s" hidden>%s</p>\n', id, alt)
}
} else alttext = ''
captext = if (cap == '') '' else sprintf('<p class="caption">%s</p>\n', cap)
In Quarto mode, the early-return on line 525 skips the regex extraction and calls add_html_caption(options, wrap_asis(x, options)) with no id. Even if that branch passed the id, Quarto replaces add_html_caption in knitr's namespace with a version that swallows it via ...:
wrap_asis_output wraps the widget in a cell-output-display div and appends the figure caption, but never injects an aria target element:
Result: rgl's two aria-labelledby references resolve to nothing → two broken-reference errors per widget.
The figure-level ARIA wiring Quarto does generate (aria-describedby on the float content div, matching figcaption id in floatreftarget.lua) works correctly for plain images and for htmlwidgets that don't emit their own aria attributes (e.g., plotly). That part is not the bug.
How this regressed
The ...-swallow in add_html_caption was added in #5704 as a deliberate minimal hotfix for the "unused argument" crash introduced when knitr 1.43 added the id parameter (#5702, #5762). The PR body explicitly flagged this as a hotfix rather than feature parity:
"I only did this specific function to make a possible hot fix minimal, but we should probably do that in all our
assignInNamespace()call."
The crash was fixed; the aria-labelledby support knitr was wiring up has been silently dropped on the Quarto side since then.
Possible fix
We could extract the aria-labelledby id from the widget HTML in Quarto's add_html_caption (the same regex knitr uses) and have wrap_asis_output emit a hidden <p id="X-aria" hidden>alt</p> next to the widget. That keeps the fix entirely on the Quarto side and doesn't require any knitr coordination.
Related
- #5702 / #5762 — original
add_html_captioncrash after knitr 1.43 - #5704 — the minimal
...hotfix - #1800 / #1857 — initial rgl integration in Quarto
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.