Skip to content

Make dark/light mode toggle an accessible switch - #14739

Merged
cderv merged 7 commits into
mainfrom
fix/13463-color-scheme-toggle-switch
Sep 2, 2026
Merged

Make dark/light mode toggle an accessible switch#14739
cderv merged 7 commits into
mainfrom
fix/13463-color-scheme-toggle-switch

Conversation

@cwickham

@cwickham cwickham commented Jul 29, 2026

Copy link
Copy Markdown
Member

Closes #13463.

Description

The dark/light mode toggle was an <a href=""> with a CSS-drawn icon, which fails all three prongs of WCAG 4.1.2 Name, Role, Value: no accessible name (the axe link-name violation reported in #13463), a link role for a button-like control (so Space doesn't activate it), and mode state conveyed only by a background-image swap that never reaches the accessibility tree — a screen-reader user gets no feedback on which mode is active, before or after toggling.

This PR makes the toggle a WAI-ARIA APG switch<button type="button" role="switch" aria-checked> with the existing localized toggle-dark-mode string as aria-label — in all three places the control is created:

  • website navbar/sidebar (navdarktoggle.ejs; keeps its existing title tooltip)
  • plain documents with a light/dark theme pair (the floating fallback injected by quarto-html-after-body.ejs)
  • multi-page dashboards (format-dashboard-page.ts, which now receives format.language)

Why <button> rather than ARIA on the existing <a>: role="switch" is permitted on a[href], but ARIA only changes what's announced, not how the element behaves: Space would still scroll the page instead of activating (links respond to Enter only), the empty href would still navigate/reload if JS hasn't bound yet, and link affordances (middle-click, "Open in New Tab", status-bar URL) would still apply. Making it a real <button> gets correct focus and Enter+Space activation natively, at the one-time cost of a selector change (the five Playwright specs updated here; custom CSS targeting a.quarto-color-scheme-toggle should now target the button).

setColorSchemeToggle() keeps aria-checked in sync alongside the existing .alternate class, so screen readers announce "Toggle dark mode, switch, off/on" and announce the state change on activation. A small SCSS reset (button.quarto-color-scheme-toggle) neutralizes UA button styling; all existing toggle styling is class-based, so visuals are unchanged (verified by screenshot in navbar, dashboard navbar, and floating placements).

Behavior checked as unchanged: light-only documents still emit no toggle at all; the reader-mode toggle and navbar tool links keep their existing markup; the theme-switching JS itself is untouched apart from the one aria-checked line.

Verified with axe-core (the link-name violation clears; no new violations) and the Chrome accessibility tree (role=switch, name, and checked round-tripping on activation) for all three creation paths. Also verified with a manual VoiceOver pass across all placements — website navbar, website sidebar, dashboard navbar, and the floating fallback — plus the light-only control document (no toggle announced).

Dark-by-default behavior verified with VoiceOver: the toggle announces the correct on/off state on load and after activation for author-dark-default (theme: with dark: listed first), for respect-user-color-scheme: true under both OS appearance settings, and when a previously chosen scheme is restored on reload.

No documentation update needed: the quarto.org docs describe the toggle at the author-facing level (placement and behavior, both unchanged) and never reference its markup.

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
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

cwickham added 2 commits July 29, 2026 10:45
The toggle was an <a href=""> with a CSS-drawn icon: no accessible
name (axe link-name, WCAG 2.4.4/4.1.2), link role for a button-like
control (Space did not activate it), and mode state conveyed only by
a background-image swap that never reaches the accessibility tree.

Now a <button type="button" role="switch" aria-checked> with the
localized toggle-dark-mode string as aria-label, in all three places
the control is created: website navbar/sidebar (navdarktoggle.ejs),
plain-document fallback (quarto-html-after-body.ejs), and multi-page
dashboards (format-dashboard-page.ts). setColorSchemeToggle() keeps
aria-checked in sync alongside the existing .alternate class.
Positive: the toggle renders as button[role=switch][aria-checked]
with a localized aria-label on websites (static), multi-page
dashboards (static), and plain documents (script-text regex, since
the fallback toggle is injected at DOMContentLoaded).

Guard rails: light-only documents still emit no toggle markup at
all, and the reader-mode toggle and navbar tool links keep their
existing anchor shape.

All fail against the pre-fix source except the light-only guard,
which passes on both (verified by stashing the fix).
@posit-snyk-bot

posit-snyk-bot commented Jul 29, 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.

cwickham added 2 commits July 29, 2026 14:08
The toggle is now a <button role="switch"> instead of an <a>, so
locators using a.quarto-color-scheme-toggle no longer match. Verified
locally: all 36 tests across the five affected specs pass.
@cwickham
cwickham marked this pull request as ready for review July 30, 2026 15:39
@cwickham
cwickham requested a review from cderv July 30, 2026 15:40

@cderv cderv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is thorough. I went through the three creation paths on my side and the behavior holds up: the accessibility tree reports switch "Toggle dark mode" [checked=false] in the navbar, in the floating fallback and in the dashboard navbar, aria-checked follows the active scheme (including on a dark-by-default document, where it correctly starts at true), Space activates, and the icon geometry is unchanged next to the other navbar tools. Dropping the href="" also removes a small hazard, since quarto-nav.js was rewriting it into an absolute URL.

One thing I found that I think is worth deciding before merge. On a plain document with a light/dark theme pair, our own axe checker now reports a different violation rather than none:

---
title: Axe check
format:
  html:
    axe:
      output: document
    theme:
      light: flatly
      dark: darkly
---

Body text.

On main the report is Serious · WCAG 2.0 A (2.4.4, 4.1.2): Ensure links have discernible text on a. With this branch it becomes Moderate · Best Practice: Ensure all page content is contained by landmarks on button.

The cause is role="switch", not the button element. axe's region rule exempts elements whose role is button or link, but not switch or checkbox, and the fallback toggle is appended straight to document.body, outside any landmark. Keeping the button and dropping the role reports nothing at all, and swapping in role="checkbox" reports the same thing. The navbar and sidebar placements are fine since they sit inside nav, and the dashboards already have region hits of their own.

It is a best-practice rule and not a WCAG failure, so trading 4.1.2 for it is still a clear win. But it is the exact document shape from #13463, best-practice rules run by default when standard: is unset, and the description says no new violations. We could wrap the floating toggle in a named landmark, or keep it as is and note the trade in the description. Did you look at that case maybe ?

Two smaller things:

  • In quarto-html-after-body.ejs the created element is still named a now that it is a button.
  • The new website fixture declares a navbar tool with icon: and no text:, which renders as title="" aria-label="" and shows up as an unnamed link in the accessibility tree. That is existing behavior and not something this PR changes, but the fixture bakes in an example of it. Adding text: there would keep the fixture clean, and the empty aria-label probably deserves its own issue.

The floating toggle on a plain document was appended straight to
document.body, outside every landmark. With role="switch" that trips
axe's region rule ("all page content contained by landmarks"): the rule
exempts role=button but not switch, so giving the toggle a name traded
the 4.1.2 link-name failure for a new best-practice finding on the exact
document shape from the issue.

The toggle now gets a container of its own, always a fresh direct child
of body. It is deliberately not moved into page furniture that the theme
or the author controls, because the toggle is positioned against the page
and any ancestor that establishes a containing block captures it. The
resulting shift is silent and visual. #title-block-header is both
positioned and a grid, so appending the toggle there draws it 258px from
the right edge instead of 17px. position: fixed does not make that safe
either: transform, filter, will-change and contain on an ancestor all
reintroduce the same failure, and theme or user CSS can set any of them.
A direct child of body has none of these to inherit.

Only the landmark type varies: a <header> (banner), or a labelled
div[role=region] when the page already has a banner. ARIA states that a
document should have at most one banner, and axe's
landmark-no-duplicate-banner counts banners without considering their
labels, so a second labelled banner would still be reported.

Detecting that existing banner needs care. A header is a banner wherever
it sits, unless it is inside sectioning content, and a plain div ancestor
does not strip the role: with `page-layout: custom` the title block
renders as body > div.page-layout-custom > header#title-block-header. So
the probe looks for a header anywhere, keeps an explicit role="banner"
unconditionally, and otherwise keeps a header only when it has no
sectioning-content ancestor.

The structural claims live in the Playwright spec rather than the smoke
test, and are asserted through the computed role with
getByRole("banner"). Asserting them through the selector the
implementation probes with cannot fail when that probe is wrong, and
asserting the selector text in the smoke test only pins the wording.

Verified with `quarto call axe` over three document shapes x 2 viewports
x light/dark: no banner or region findings. Toggle geometry is unchanged
in every shape (17px/17px, matching the pre-change position). The website
and dashboard placements are untouched, since the fallback only runs when
no toggle exists at all.

The custom-layout fixture still reports landmark-one-main and region.
Both come from `page-layout: custom` emitting no <main>: the same
document rendered from main reports both, plus the link-name violation
that this branch fixes.

Also renames the created element from `a` to `toggle`, left over from
when it was an anchor.
The fixture declared a tool with icon: and no text:, which renders as
title="" aria-label="" and reads as an unnamed link. That is existing
behavior rather than something this PR changes, but the fixture should
not bake in an example of it -- `quarto call axe` reported it as a
serious link-name violation on the fixture's own output.

The empty aria-label on icon-only navbar tools deserves its own issue.
@cwickham

cwickham commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Good catch, and no, I had not looked at that case. Fixed now — I took the named landmark option.

The fix. The toggle gets a landmark container of its own, always a fresh direct child of body:

  • no banner on the page → <header class="quarto-color-scheme-toggle-container">
  • a banner already present → <div role="region" aria-label="…">, reusing the localized string

Two implementation details worth knowing:

  • The container is a new element rather than the existing banner. The toggle is positioned against the page, so any ancestor that establishes a containing block captures it — #title-block-header is both positioned and a grid, which draws the toggle 258px from the right edge instead of 17px. position: fixed does not help, since transform, filter, will-change and contain all do the same thing.
  • Finding an existing banner is not a check on the children of body. A div ancestor does not strip a header's banner role, so with page-layout: custom the title block sits at body > div.page-layout-custom > header#title-block-header. The probe looks for a header anywhere and excludes only those inside sectioning content.

Tests. New html-color-scheme-toggle.spec.ts with three fixtures: no banner, title-block-banner, and page-layout: custom. Each checks the container, the banner count, and that the toggle stays in the corner. The banner count goes through getByRole("banner") rather than the selector the implementation probes with, so it can fail when that probe is wrong.

Verified with quarto call axe (#14815) over the three shapes × 2 viewports × light/dark: no banner or region findings, and toggle geometry unchanged. Navbar, sidebar and dashboard placements are untouched, since the fallback only runs when no toggle exists.

Your two smaller points. Both done — the created element is toggle now, and the fixture tool has a text:. Thanks for opening #14828.

Follow-up, not for this PR. #14838: the floating toggle is last in reading and focus order while drawn first — predates this change.

@cderv
cderv merged commit fa48b15 into main Sep 2, 2026
51 checks passed
@cderv
cderv deleted the fix/13463-color-scheme-toggle-switch branch September 2, 2026 09:10
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.

a11y: dark-mode vs light-mode button violates axe-core

3 participants