Skip to content

Fix keyboard focus reaching off-screen slides in revealjs - #15010

Merged
cderv merged 4 commits into
mainfrom
revealjs-off-screen-slides-stay-in-the-keyboard
Oct 9, 2026
Merged

cderv merged 4 commits into
mainfrom
revealjs-off-screen-slides-stay-in-the-keyboard

Conversation

@cderv

@cderv cderv commented Oct 8, 2026

Copy link
Copy Markdown
Member

In revealjs presentations, pressing Tab on the current slide can move focus to links and controls on nearby slides that are not shown. Reveal gives slides within viewDistance an inline display: block, which defeats the hidden attribute, and aria-hidden does not affect tab order.

reveal.js fixed this upstream in 6.0.2 (hakimel/reveal.js#1587) by toggling inert on every slide that is neither the current slide nor an ancestor of it, except in overview, scroll and print views where all slides are shown. Quarto bundles reveal.js 5.1.0, and moving to 6.x is a separate upgrade, so this adds the same rule to Quarto's support plugin. The block is marked for removal once the bundled reveal.js is 6.0.2 or later.

Fix

updateInertSlides() in support.js copies reveal's 6.0.2 condition verbatim. It is unconditional, with no format option, and it is skipped in print view. reveal.js 5.1.0 has no hook inside updateSlidesVisibility(), so it runs from events:

  • ready and slidechanged. The slidechanged listener is registered in the capture phase. support registers after format and user plugins, and a plain listener would run after theirs while the new slide is still inert, so a plugin that focuses into the new slide would silently fail.
  • overviewshown and overviewhidden. Inert slides do not receive pointer events, so overview needs it cleared to keep click-to-navigate working.
  • A MutationObserver on the viewport class. Leaving scroll view restores the slides' markup from a snapshot taken on entry, with whatever inert values they had then, and reveal.js 5.1.0 dispatches no event when that happens. Scroll from slide 3 to slide 7, leave scroll view, and slide 7 would come back inert and unreachable by keyboard. A resize listener does not help for the responsive scroll view, because resize fires before the switch.

The axe checker has to follow. It un-hides slides to scan them all, and the bundled axe-core treats inert subtrees as hidden, so violations on non-current slides would silently stop being reported. revealUnhideSlides() now lifts inert as well, and the restore recomputes it from the live reveal state instead of replaying saved values, so a slide change during the scan cannot leave the current slide inert.

Fixes #14795

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone, reveal.js source
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.

@posit-snyk-bot

posit-snyk-bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

cderv added 4 commits October 8, 2026 18:28
…lide

Reveal sets an inline `display: block` on slides within `viewDistance`, which defeats `hidden`, so Tab from the current slide reaches links and controls on nearby slides that are not shown (#14795).

reveal.js 6.0.2 fixes this by toggling `inert` on every slide that is neither current nor an ancestor of the current slide. The bundled reveal.js 5.1.0 has no hook inside `updateSlidesVisibility()`, so the same condition is applied from `ready` and `slidechanged` until the bundled version is upgraded.

The `slidechanged` listener is registered in the capture phase. This plugin is registered after format and user plugins, and a plain listener would run after theirs, while the new slide is still inert, so a plugin that focuses into the new slide would silently fail.
Non-present slides carry `inert`, which axe-core treats as hidden, so removing only `hidden` and `aria-hidden` would leave every off-screen slide unscanned. `revealUnhideSlides` now clears `inert` as well.

On restore, `inert` is recomputed from live reveal state instead of replayed from a snapshot. A slide change while the scan is running would otherwise put back the stale value and leave the newly current slide inert.
Overview, scroll and print views show every slide, so `inert` must be cleared there. Without that, overview slides cannot be clicked to navigate.

Leaving scroll view restores the slides' markup from a snapshot taken on entry, including whatever `inert` values they had then, and reveal.js 5.1.0 dispatches no event when that happens. The current slide could come back inert and unreachable by keyboard, so a `MutationObserver` on the viewport's `class` recomputes it. A `resize` listener does not work for the responsive scroll view: `resize` fires before the switch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants