quarto-dev / quarto-dev/quarto-cli

revealjs: off-screen slides stay in the keyboard tab order (Tab from slide N lands on slide N-2)

Open
#14,795 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

accessibility bug revealjs
Dominant language
JavaScript
Stars
6k
Forks
458
Avg merge
1d 9h
Merged PRs (30d)
41

Description

I have:
  • searched the issue tracker for similar issues
  • installed the latest version of Quarto CLI
  • formatted my issue following the Bug Reports guide
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 ... -->
quarto render deck.qmd

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)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing reveal.js initialization in revealjs.ts and the bundled support plugin, the two locations proposed in the issue. Reproduce with a ten-slide deck via quarto render deck.qmd, then verify focus on present slides and behavior in overview, print/PDF, and scroll views. Done means off-screen slides no longer enter the Tab order without disrupting navigation.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.