I have:
Bug description
In a Quarto revealjs deck, Tab does not reach the current slide's content first. Focusable elements on .past/.future slides inside viewDistance stay in the tab order, so a keyboard user tabs into slides that are not on screen with nothing to tell them they have left the slide they were reading. This is a WCAG 2.4.3 (Focus Order) failure in output Quarto produces.
The intent is already in the markup — reveal.js sets both hidden and aria-hidden="true" on non-present slides — but neither takes effect:
- reveal also writes an inline
display: block on every slide inside viewDistance. An inline declaration outranks the UA stylesheet's [hidden] { display: none }, so hidden has no effect and the subtree stays tabbable.
aria-hidden="true" hides the subtree from assistive technology but has no bearing on the tab order.
The result is slides that are announced as hidden and are reachable by Tab at the same time.
DOM on slide 5 of a ten-slide deck (viewDistance: 3, the Quarto default):
slide-3 .past hidden aria-hidden="true" style="display: block"
slide-4 .past hidden aria-hidden="true" style="display: block"
slide-5 .present - - style="display: block"
slide-6 .future hidden aria-hidden="true" style="display: block"
slide-7 .future hidden aria-hidden="true" style="display: block"
slide-8 .future hidden aria-hidden="true" style="display: none"
Slides outside viewDistance get display: none and leave the tab order correctly. The ones inside it do not.
Why I think this belongs in Quarto rather than upstream alone
Upstream is hakimel/reveal.js#1587, open since May 2016. The answer given in that thread is "use the a11y plugin", meaning marcysutton/reveal-a11y — last commit June 2020, written against the pre-4.x plugin API, and not bundled with Quarto. Quarto's bundled revealjs plugins are chalkboard, line-highlight, menu, multiplex, pdfexport, support, tone; none of them handles this, and grep -r inert over the bundled revealjs resources returns nothing relevant.
The other route users are pointed at is the third-party mcanouil/quarto-revealjs-a11y extension, whose slide-landmarks feature targets exactly this. It is opt-in, and its current release has two defects that make it unreliable for the content that suffers most from the bug — see mcanouil/quarto-revealjs-a11y#25: elements focusable only through tabindex are demoted and never restored, and iframe is never demoted at all.
So both routes are currently unavailable or unreliable, while the defect ships in Quarto's own output. A patch in Quarto seems right until upstream lands a fix, at which point it can be dropped.
On the tabindex approaches, and why inert is the one that holds up
| approach |
outcome |
hidden on the section |
already applied by reveal; defeated by the inline display: block written on the same element |
aria-hidden="true" |
already applied; hides from AT, no effect on tab order |
per-element tabindex="-1" with save/restore |
needs an accurate list of what is focusable, plus per-element state to restore the author's original value. The list is easy to get wrong: iframe, audio[controls], video[controls], summary and [contenteditable] are tabbable with no tabindex attribute at all, and tabindex="0" on an author's own widget has to be preserved, not dropped. The restore pass then has to find elements it has itself demoted — which is exactly where the third-party extension breaks. |
inert on the section |
one attribute per slide, no per-element state, no focusable-element list to keep in sync. Removes the subtree from the tab order and the accessibility tree. Not display-based, so the inline display: block does not defeat it. Baseline across Chrome, Safari and Firefox since April 2023. |
Proposed patch
function updateInertSlides() {
const current = Reveal.getCurrentSlide();
document.querySelectorAll(".reveal .slides section").forEach((s) => {
s.toggleAttribute("inert", !(s === current || s.contains(current)));
});
}
function clearInertSlides() {
document
.querySelectorAll(".reveal .slides section[inert]")
.forEach((s) => s.removeAttribute("inert"));
}
Reveal.on("ready", updateInertSlides);
Reveal.on("slidechanged", updateInertSlides);
Reveal.on("overviewshown", clearInertSlides);
Reveal.on("overviewhidden", updateInertSlides);
s.contains(current) keeps a vertical stack's ancestor reachable while its non-present siblings go inert. Overview mode needs the escape hatch because every slide is legitimately on screen there; the print/PDF and scroll views need the same treatment, which I would cover in the PR.
I would be glad to open the PR — say where you would want it to live (the bundled support plugin, a new bundled plugin, or the reveal init in revealjs.ts) and whether it should be unconditional or sit behind a format option, and I will send it that way.
Steps to reproduce
A deck with nothing but links reproduces it — no code cells or extensions involved:
---
title: "Tab order probe"
format: revealjs
---
## Slide 1 {#slide-1}
[link on slide 1](https://example.com/1)
## Slide 2 {#slide-2}
[link on slide 2](https://example.com/2)
<!-- ... repeat through slide 10 ... -->
Open deck.html#/slide-5, click nothing, and press Tab.
Actual behavior
On slide 5, the first four Tab presses go:
Tab 1 -> "link on slide 3" section#slide-3 .past (off screen)
Tab 2 -> "link on slide 4" section#slide-4 .past (off screen)
Tab 3 -> "link on slide 5" section#slide-5 .present (the slide on screen)
Tab 4 -> "link on slide 6" section#slide-6 .future (off screen)
The reader has to tab past two invisible slides to reach the one they are on, and tabbing once more takes them off it again. Nothing is announced at either boundary, because those sections carry aria-hidden="true".
Measured in Chrome by dispatching real key presses and reading document.activeElement after each.
The impact scales with embedded interactive content. I maintain maidr, which renders charts a blind reader explores from the keyboard; on a ten-slide deck with one chart per slide the reader lands inside a chart belonging to a slide they are not on, and the chart takes over the arrow keys once focused.
Expected behavior
Tab from the current slide reaches that slide's own content first, and does not reach content on slides that are not on screen.
With the patch above applied to the same deck:
on slide 5: Tab 1 -> "link on slide 5" section#slide-5 .present
Tab 2 -> deck menu button (outside .slides)
Tab 3 -> leaves the document
Space -> slide 6
on slide 6: Tab 1 -> "link on slide 6" section#slide-6 .present
o -> overview: inert cleared on all 11 sections
Escape -> back to slide 6, inert restored to the other 10
Verified in Chrome on the deck above. Transitions and slide navigation are unaffected.
Your environment
- IDE: none (rendered from the command line)
- OS: Windows 11 26200
- Browser: Chrome
- reveal.js: 5.1.0 (
Reveal.VERSION, as bundled by Quarto)
Reproduced on Quarto 1.8.27 (the quarto check below) and on 1.10.18 on Linux. Both bundle reveal.js 5.1.0 and behave identically, which is expected since the behaviour comes from that bundle rather than from Quarto's own code.
Quarto check output
Quarto 1.8.27
[>] Checking environment information...
Quarto cache location: C:\Users\...\AppData\Local\quarto
[>] Checking versions of quarto binary dependencies...
Pandoc version 3.6.3: OK
Dart Sass version 1.87.0: OK
Deno version 2.3.1: OK
Typst version 0.13.0: OK
[>] Checking versions of quarto dependencies......OK
[>] Checking Quarto installation......OK
Version: 1.8.27
Path: C:\ProgramData\chocolatey\lib\quarto\tools\bin
CodePage: 1252
[>] Checking tools....................OK
TinyTeX: (external install)
Chromium: (not installed)
[>] Checking LaTeX....................OK
Using: Installation From Path
Path: C:\tools\TinyTeX\bin\windows
Version: 2026
[>] Checking Chrome Headless....................OK
Using: Chrome found on system
Source: Windows Registry
[>] Checking basic markdown render....OK
[>] Checking Python 3 installation....OK
Version: 3.12.10
Jupyter: 5.9.1
Kernels: python3
[>] Checking Jupyter engine render....OK
[>] Checking R installation...........OK
Version: 4.6.1
knitr: (None)
rmarkdown: (None)
I have:
Bug description
In a Quarto
revealjsdeck, Tab does not reach the current slide's content first. Focusable elements on.past/.futureslides insideviewDistancestay in the tab order, so a keyboard user tabs into slides that are not on screen with nothing to tell them they have left the slide they were reading. This is a WCAG 2.4.3 (Focus Order) failure in output Quarto produces.The intent is already in the markup — reveal.js sets both
hiddenandaria-hidden="true"on non-present slides — but neither takes effect:display: blockon every slide insideviewDistance. An inline declaration outranks the UA stylesheet's[hidden] { display: none }, sohiddenhas no effect and the subtree stays tabbable.aria-hidden="true"hides the subtree from assistive technology but has no bearing on the tab order.The result is slides that are announced as hidden and are reachable by Tab at the same time.
DOM on slide 5 of a ten-slide deck (
viewDistance: 3, the Quarto default):Slides outside
viewDistancegetdisplay: noneand leave the tab order correctly. The ones inside it do not.Why I think this belongs in Quarto rather than upstream alone
Upstream is hakimel/reveal.js#1587, open since May 2016. The answer given in that thread is "use the a11y plugin", meaning marcysutton/reveal-a11y — last commit June 2020, written against the pre-4.x plugin API, and not bundled with Quarto. Quarto's bundled revealjs plugins are
chalkboard,line-highlight,menu,multiplex,pdfexport,support,tone; none of them handles this, andgrep -r inertover the bundled revealjs resources returns nothing relevant.The other route users are pointed at is the third-party mcanouil/quarto-revealjs-a11y extension, whose
slide-landmarksfeature targets exactly this. It is opt-in, and its current release has two defects that make it unreliable for the content that suffers most from the bug — see mcanouil/quarto-revealjs-a11y#25: elements focusable only throughtabindexare demoted and never restored, andiframeis never demoted at all.So both routes are currently unavailable or unreliable, while the defect ships in Quarto's own output. A patch in Quarto seems right until upstream lands a fix, at which point it can be dropped.
On the
tabindexapproaches, and whyinertis the one that holds uphiddenon the sectiondisplay: blockwritten on the same elementaria-hidden="true"tabindex="-1"with save/restoreiframe,audio[controls],video[controls],summaryand[contenteditable]are tabbable with notabindexattribute at all, andtabindex="0"on an author's own widget has to be preserved, not dropped. The restore pass then has to find elements it has itself demoted — which is exactly where the third-party extension breaks.inerton the sectiondisplay: blockdoes not defeat it. Baseline across Chrome, Safari and Firefox since April 2023.Proposed patch
s.contains(current)keeps a vertical stack's ancestor reachable while its non-present siblings go inert. Overview mode needs the escape hatch because every slide is legitimately on screen there; the print/PDF and scroll views need the same treatment, which I would cover in the PR.I would be glad to open the PR — say where you would want it to live (the bundled
supportplugin, a new bundled plugin, or the reveal init inrevealjs.ts) and whether it should be unconditional or sit behind a format option, and I will send it that way.Steps to reproduce
A deck with nothing but links reproduces it — no code cells or extensions involved:
Open
deck.html#/slide-5, click nothing, and press Tab.Actual behavior
On slide 5, the first four Tab presses go:
The reader has to tab past two invisible slides to reach the one they are on, and tabbing once more takes them off it again. Nothing is announced at either boundary, because those sections carry
aria-hidden="true".Measured in Chrome by dispatching real key presses and reading
document.activeElementafter each.The impact scales with embedded interactive content. I maintain maidr, which renders charts a blind reader explores from the keyboard; on a ten-slide deck with one chart per slide the reader lands inside a chart belonging to a slide they are not on, and the chart takes over the arrow keys once focused.
Expected behavior
Tab from the current slide reaches that slide's own content first, and does not reach content on slides that are not on screen.
With the patch above applied to the same deck:
Verified in Chrome on the deck above. Transitions and slide navigation are unaffected.
Your environment
Reveal.VERSION, as bundled by Quarto)Reproduced on Quarto 1.8.27 (the
quarto checkbelow) and on 1.10.18 on Linux. Both bundle reveal.js 5.1.0 and behave identically, which is expected since the behaviour comes from that bundle rather than from Quarto's own code.Quarto check output