Skip to content

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

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

cwickham wants to merge 17 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

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-light-dark.qmd. There is one test per rule that puts the ring back where Bootstrap or Quarto removed the outline (.nav-link, .form-select, .form-control, bare .btn, the sidebar toggle, the search form), plus tests for forced colors, author overrides, and the paired light/dark theme. In Chromium and WebKit, 21 tests pass and 1 test skips (forced colors is Chromium-only). On release 1.11.6, every test except the author-override test fails.
  • The website fixture sets $focus-ring-outer-color, $focus-ring-outline-width, and $focus-ring-outline-offset to values that differ from the defaults. The same assertions on every control show that the three Sass variables reach the ring.
  • 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.
  • The light/dark test asserts only that the tones swap between the two stylesheets, not the exact color each Bootswatch theme derives, so a Bootswatch update does not break it.
  • 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):

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.
Drop the inline link, TOC link, tabset tab, and listing pagination rows:
they exercise rules that other rows already cover, or (pagination) a rule
the default theme cannot exercise. Fold the size fixture into the website
fixture, whose shared assertion now checks the width and offset on every
control. Assert only the tone swap on the light/dark pair, not the exact
colors each Bootswatch theme derives.
The toggle is the last focusable element, and Firefox does not wrap Tab
back to the top of the page, so tabUntilFocused never reached the link
on CI.
@cwickham
cwickham marked this pull request as ready for review October 23, 2026 17:21
@cwickham
cwickham requested a review from cderv October 23, 2026 17:21
@cwickham

Copy link
Copy Markdown
Member Author

@cderv this is a pretty big visual change that will effect all Quarto websites, although it's only visible once people start Tabbing through. It's not particularly fancy, or subtle, but it makes all of our focus treatment consistent, and with good contrast for accessibility. I think that's the right approach for us to have for default sites.
I tried to keep it easily overridable by authors who want to tweak focus, but then the accessibility of those rings is on them.

@cwickham

cwickham commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

@mcanouil You might have some thoughts on how authors override our focus rings and whether this PR
will break things. I did explore what happens on https://m.canouil.dev/quarto-wizard/.

You'd need this to the rules in docs/assets/stylesheets/theme.scss:

:root {
  --quarto-focus-ring-shadow: none;
}

Although even without it, the Quarto inner rings are not super noticeable.

@mcanouil

Copy link
Copy Markdown
Collaborator

I'll need to get back to it as I fixed this by hand for one single case few months ago, so almost certain it was not exhaustive.

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

3 participants