Skip to content

Use one focus ring for all focusable elements in Bootstrap HTML - #15163

Draft
cwickham wants to merge 13 commits into
mainfrom
feat/c40-focus-ring
Draft

cwickham wants to merge 13 commits into
mainfrom
feat/c40-focus-ring

Conversation

@cwickham

@cwickham cwickham commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Description

Closes #15139. Supersedes #14867.

Quarto's HTML output uses four different keyboard focus indicators. Two of them fail the 3:1 contrast minimum of WCAG 1.4.11. In forced-colors mode, some controls have no focus indicator at all. The issue gives the details and measurements.

This PR replaces the four indicators with one focus ring, defined in one place.

The ring

One rule, :focus-visible in _bootstrap-rules.scss, draws the ring for every focusable element. The ring follows W3C technique C40: two tones with at least 9:1 contrast between them. As a result, one of the two tones has at least 3:1 contrast against any solid background.

  • The outer tone is a 2px outline, with outline-offset: 2px.
  • The inner tone is a box-shadow of 2px that fills the offset.

The outer tone is on the outline for two reasons. Bootswatch themes often set box-shadow on controls, but they rarely set outline. Forced-colors mode also keeps the outline and removes the box-shadow.

default:
cosmo

cosmo (e.g. used on quarto.org):
cosmo

Tone order follows the page

The two tones are white and a dark tone, derived from $primary-text-emphasis. On a light page, the dark tone is the outer ring. On a dark page, the tones change places, so the white tone is the outer ring. Thus the outer ring always contrasts with the page.

A page is dark if $body-bg has more contrast with white than with black. In the built-in themes, seven themes are dark: cyborg, darkly, quartz, slate, solar, superhero, and vapor.

cyborg:
cyborg
darkly:
darkly

Contrast between the tones

The derived pair always has at least 9:1 contrast, in all themes:

  • In the default theme and all 25 Bootswatch themes, $primary-text-emphasis already has at least 9:1 contrast against white. The lowest is minty, at 9.13:1. The highest is lux, at 19.80:1.
  • A user theme can have a lighter $primary, which gives less than 9:1. In this case, Quarto darkens the derived tone only as much as necessary to get 9:1. For example, $primary: #ffd54f goes from 7.26:1 to 9.16:1.

How authors change the ring

Users can set the two tones with $focus-ring-outer-color and $focus-ring-inner-color. If a user sets one or both variables, Quarto uses the colors as given:

  • Quarto does not change the tone order on dark pages.
  • Quarto does not adjust an explicit outer color to get 9:1.

If a user sets only the inner color, Quarto still derives the outer color and adjusts it to 9:1 against the inner color.

Users can set the size of the ring with $focus-ring-outline-width and $focus-ring-outline-offset (both 2px by default). The inner tone always fills the offset. Quarto adds padding to scroll containers from these two values, so a larger ring is not clipped. A width of less than 2px does not meet WCAG 2.4.13.

vars

Author focus rules still win over the ring:

  • The global rule has specificity (0,1,0). Author rules such as a:focus-visible (0,1,1) win over it. Many sites have rules like this one.
  • The inner tone is box-shadow: var(--quarto-focus-ring-shadow). Most author rules set only outline, so the inner tone stays inside the author ring. To remove it, add --quarto-focus-ring-shadow: none to the author rule or to :root.

Quarto no longer uses Bootstrap's $focus-ring-color, $focus-ring-box-shadow, $btn-focus-width, and $btn-focus-box-shadow. The changelog tells authors to use the new variables and the custom property instead.

Other changes

  • Bootstrap's focus halos are off at the source: $focus-ring-box-shadow, $form-select-focus-box-shadow, and $form-range-thumb-focus-box-shadow are none, and $btn-focus-width is 0.
  • The per-control focus rules are removed: the sidebar toggle rule, the outline: revert from Restore keyboard focus indicator on bare .btn buttons #14785, and the outline: none rules in quarto-search.scss.
  • accessibleContrast() accepts a number. Also, "A" now gives 3:1. Before, it gave 7:1. No caller uses "A".
  • The TOC sidebars get padding-right: $focus-ring-extent, so that they do not clip the ring.
  • The collapsed navbar menu (.navbar-nav-scroll) gets padding: $focus-ring-extent and an equal negative margin. Before, it clipped the sides of the ring.
  • The secondary nav gets position: relative, so that the top edge of the ring shows above the navbar.
  • The skip link (Add skip-to-content link to Bootstrap-themed HTML output #14685) no longer has a drop shadow. Before, it needed its own focus rule to keep both the drop shadow and the inner tone. Now it gets the standard ring.

Exceptions to "one rule"

  • Bootstrap controls. Bootstrap removes the outline with (0,2,0) rules, for example .btn:focus-visible { outline: 0 } and .form-control:focus. These rules win over the (0,1,0) global rule. A second rule includes the ring again on these classes: .btn, .btn-close, .nav-link, .navbar-toggler, .accordion-button, .carousel-control-prev, .carousel-control-next, .form-control, .form-select, .form-check-input, and .form-range. It ties with Bootstrap and comes later. Author rules on these classes come later again, so they still win.
  • MathJax. MathJax adds mjx-speech:focus { outline: none } at runtime, after the Quarto stylesheet. A (0,2,1) rule, :root mjx-speech:focus-visible, puts the ring back on each equation.
  • Search box. The visible field is .aa-Form, which holds a borderless .aa-Input. The ring goes on the form when the input has keyboard focus.
  • Pagination. vapor and zephyr give .page-link a box-shadow with a higher specificity. A (0,4,0) rule puts the ring back.

Known limits

  • pulse. The theme sets .btn:focus { outline: none }. At 800px, the sidebar toggle and the search button have no ring, before and after this PR. I will open a separate issue.
  • Theme box-shadow overrides. Five themes replace the inner tone on some controls. The outer tone stays on all of them:
    • Tabset tab: materia, morph, quartz, zephyr.
    • Sidebar toggle and search button at 800px: materia, morph.
    • Listing sort: morph.
    • Listing filter: materia, morph, pulse.

Tests and measurements" with this section

Tests and measurements

All measurements come from renders and computed styles, in Chromium and WebKit.

  • New Playwright tests in html-focus-indicator.spec.ts, with the fixtures tests/docs/playwright/website/focus-indicator/, tests/docs/playwright/html/focus-ring-author-override.qmd, and tests/docs/playwright/html/focus-ring-size.qmd. In Chromium and WebKit, 29 tests pass and 1 test skips (forced colors is Chromium-only). On release 1.11.6, the 13 tests other than the author-override and size tests all fail.
  • The author-override test gives a link an a:focus-visible outline and --quarto-focus-ring-shadow: none. The test makes sure that the link shows the author outline and no inner tone.
  • A Tab-key audit of 26 themes plus dark mode, at 1400px, 800px, and on a listing page: 3,189 Tab stops. 72 stops do not have the exact ring:
    • 54 are the search <input>. This is by design, because the ring is on its form.
    • 16 are the theme box-shadow overrides above.
    • 2 are the pulse buttons above.
  • No scroll container clips the ring: the website TOC and standalone toc-left at 1400px and 800px, and the collapsed navbar menu at 700px.
  • In forced-colors mode, the navbar link, TOC link, tab, and search box had no indicator on main. With this PR, they have one.
  • quarto call axe shows no new or changed findings, compared with main, on default, cosmo, darkly, and quartz.

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.

…HTML

Replace the four focus indicators (browser ring, Bootstrap's translucent
box-shadow halo, the sidebar toggle outline, and the search box's 1px
shadow) with one ring after W3C technique C40: a 2px outline in the
outer color, with the inner color as a box-shadow in the 2px offset.

- New Sass variables $focus-ring-outer-color and $focus-ring-inner-color.
  When the outer color is derived from $primary-text-emphasis, it is
  darkened to 9:1 against the inner color. An explicit color is used as is.
- accessibleContrast() accepts a numeric ratio, and "A" now gives 3:1.
- Bootstrap's focus halos are turned off at the source.
- The search box draws the ring on its form; pagination gets a (0,4,0)
  rule because vapor and zephyr replace its box-shadow.
- The TOC sidebars get padding so they no longer clip the ring, and the
  secondary nav paints above the navbar so the ring's top edge shows.
Rename html-focus-indicator-bare-btn.spec.ts to html-focus-indicator.spec.ts
and the bare-btn-focus fixture to focus-indicator. The fixture sets a
distinctive outer color, so each test also catches a theme or component
rule that wins the cascade. Adds the navbar, TOC, tabset, sidebar toggle,
search, and listing sort/filter/pagination controls, and a forced-colors
check.
@posit-snyk-bot

posit-snyk-bot commented Oct 22, 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.

Lower the global :focus-visible rule to (0,1,0) so author rules such as
a:focus-visible win, and re-include the ring at (0,2,0) only on the
Bootstrap classes that remove their outline. Put the inner tone in
--quarto-focus-ring-shadow so authors can remove it with one line.
Document the ignored Bootstrap focus variables in the changelog, and
test an author override.
MathJax injects mjx-speech:focus { outline: none } at runtime, after the
Quarto stylesheet. It beat the (0,1,0) global rule and left only the
white inner tone. A (0,2,1) rule puts the ring back.
Move $focus-ring-outline-width and $focus-ring-outline-offset to the
defaults layer with !default, so authors can change the size of the
ring. Test a theme that sets both.
The drop shadow needed its own focus rule to keep the ring's inner tone.
Without it, the skip link gets the standard ring, and author overrides
of the ring apply to it too.
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.

Make all focus rings consistent

2 participants