Skip to content

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

Description

@jooyoungseo

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions