Skip to content

[material-ui] Add theme.focusVisible opt-in keyboard focus ring - #48743

Merged
siriwatknp merged 118 commits into
mui:masterfrom
siriwatknp:focus-ring-v1
Aug 27, 2026
Merged

siriwatknp merged 118 commits into
mui:masterfrom
siriwatknp:focus-ring-v1

Conversation

@siriwatknp

@siriwatknp siriwatknp commented Jun 30, 2026

Copy link
Copy Markdown
Member

Docs: https://deploy-preview-48743--material-ui.netlify.app/material-ui/customization/focus-visible/

Summary

Implements the opt-in, themeable keyboard focus ring from RFC #48718.

A single theme key, theme.focusVisible, styles the Mui-focusVisible state — the keyboard-focus state ButtonBase already tracks — across ButtonBase and every component that builds on it, with no per-app wiring. It's aimed at teams that turn off the Material Design ripple (disableRipple) and are otherwise left with no visible keyboard-focus indicator (a WCAG 2.4.7 gap).

createTheme({ focusVisible: true });
// or customize — merges over the curated default:
createTheme({ focusVisible: { outlineColor: '#9c27b0', outlineOffset: 3 } });
Value Behavior
undefined No ring — default, fully non-breaking (zero visual diff)
true Curated ring: 2px solid, primary.main, 2px offset
object (FocusVisible = React.CSSProperties) Merged over the curated default
false Reserved kill-switch for the deferred auto-on fallback

Rendered with CSS outline (survives Windows High Contrast / forced-colors, no layout shift, no collision with the box-shadow elevation Button/Fab already animate). Coverage:

  • Free via ButtonBase — Button, IconButton, Fab, and any custom ButtonBase consumer.
  • Clip-prone families inset the ring so a scroller/overflow ancestor can't cut it — Tab, MenuItem, ListItemButton, CardActionArea, BottomNavigationAction, and the Autocomplete option (a plain <li>).
  • Slot-drawn controls put the ring where it reads, not on the padded hit area — Checkbox/Radio (icon svg), Switch (track), Slider (thumb), Rating (active icon + empty-value label), Link (component="button").

Ships with an exported FocusVisible type and a guide at customization/focus-visible.

For Reviewers

image

Hide whitespace when review.

Color resolution lives in three places, one per theme mode. The geometry (outlineWidth/Offset/Style + inset-var wiring) is shared by resolveFocusVisible in styles/focusVisible.ts; only the default outlineColor differs:

  1. createTheme({ focusVisible: true })
    • Resolved in: createThemeNoVars.js
    • Default outlineColor: resolved hex off palette.primary.main
    • Dark mode: — (single scheme)
  2. createTheme({ focusVisible: true, colorSchemes: { light, dark } })
    • Resolved in: createThemeNoVars.js (default scheme, top-level) + createTheme.ts (per-scheme copy)
    • Default outlineColor: each scheme's own primary.main
    • Dark mode: swaps on useColorScheme mode change
  3. createTheme({ cssVariables: true, focusVisible: true })
    • Resolved in: createThemeWithVars.js
    • Default outlineColor: var(--mui-palette-primary-main)
    • Dark mode: adapts at the CSS level

Scenario 2 needs extra care: without CSS vars the provider switches schemes by shallow-merging colorSchemes[mode] onto the theme and re-rendering (no CSS var to adapt). So createTheme.ts gives each scheme its own resolved focusVisible, and that same merge swaps the outline color per mode — exactly as it does palette. Scenario 3 needs no per-scheme copy because the palette var adapts on its own.

Inset contract (private CSS vars). Clip-prone roots spread applyInsetFocusVisible, which sets --_focusVisible-offset (flips the outline-offset sign, outset→inset) and --_focusVisible-behavior (makes a user boxShadow inset). wireFocusVisibleVars bakes the resolved offset/box-shadow to read those vars, so a component never has to know the ring width — the same customized ring insets or not per component with no field mapping.

Multi-layer box-shadow is not supported. The behavior var is prepended once, in front of the whole value. A comma-separated boxShadow is a list of independent layers, so only the first one insets on clip-prone components. The rest stay outset and get clipped.

Supporting it would need a depth-aware parser in createTheme. Splitting on comma is not safe, because commas also appear in var() fallbacks and rgb() colors. Even with a parser it stays incomplete, since var(--my-ring) can expand to several layers at computed time.

I think this case is rare. Not worth a partial CSS parser in the theme factory. Outline + a single box-shadow already covers the WCAG C40 two-color ring, and styleOverrides handles a multi-layer ring on one component. Called out in the customization guide.

CSS variables. focusVisible is skipped from var generation (shouldSkipGeneratingVar) and kept inline: hoisting it to :root would resolve the per-component private vars where they're unset, breaking the inset. Inline + palette var keeps both the inset and the scheme-reactive color working.

ButtonBase gate. The root ring is gated by a private internalDisabledThemeFocusVisible prop (default false); the whole variant is a no-op when theme.focusVisible is unset. SwitchBase sets it true so Checkbox/Radio/Switch suppress the root ring and draw on their slot instead.

styles/focusVisible.ts. One module holding the shared resolver and the inset contract. Named exports: resolveFocusVisible / extractFocusVisibleInput (feed the three resolution sites), wireFocusVisibleVars, outsetFocusRing, applyInsetFocusVisible, and applyChildrenFocusVisible (colored surfaces set the ring's shadow slot through it) — the private var names stay module-internal.

Tests. createTheme.test.js (normalization + per-scheme + vars) and createTheme.spec.ts (types); computed-style tests across ButtonBase, Tab, Checkbox, Radio, Switch, Slider, Rating, Link, Autocomplete, Fab, Button; ThemeProvider.test.tsx drives setMode('dark') and asserts the outline color follows the active scheme. Visual-regression fixtures under test/regressions/fixtures/FocusVisible/ cover the ring across the inset families, selection controls, the Autocomplete option, and forced-colors mode. The fixtures render already focus-visible (they force the Mui-focusVisible class on mount — faithful, since the ring is class-driven, not :focus-visible-driven), so the standard screenshot loop captures each in one shot with no redundant un-focused baseline.

Colored surfaces (in scope). Saturated containers (color-variant AppBar, filled Alert, SnackbarContent) set a private --_focusVisible-shadow var (0 0 0 4px background.default); the curated ring's box-shadow slot (var(--_focusVisible-shadow, 0 0)) consumes it, drawing a background-colored halo behind the outline so the indicator keeps contrast there. A custom boxShadow in theme.focusVisible replaces that slot — surface contrast is then the author's call.

RFC: #48718

Render an outline focus ring on Mui-focusVisible:
- auto fallback when disableRipple removes the ripple focus indicator
- opt-in via theme.focusRing (outline CSSProperties), ripple-independent
- theme.focusRing: false hard-disables the ring

Experiment: design in CONTEXT.md + docs/adr, demo at
docs/pages/experiments/focus-ring.tsx.
- Normalize focusRing at theme creation (true -> curated object, object merges over)
- Vars theme: curated color = palette var (scheme-reactive); numeric -> px
- Single Mui-focusVisible rule on ButtonBase; drop auto-on variants block
- Widen type to boolean | React.CSSProperties; update createTheme type tests
Replace old auto-on/fallback demo with the must-tier playground (M1-M5):
preset switcher, light/dark, all ButtonBase-derived + bare ButtonBase, keyboard
journey + focused readout, elevation/disabled edge callouts.
… controls + gallery)

Rework to match the agreed ASCII: header band (title, keyboard hint, live
focused readout, light/dark top-right); sticky left CONTROLS (preset radios);
right GALLERY. Layout-only — gallery + theme logic unchanged.
…ring)

Row-by-row CSS Grid (label | component), two labelled buckets. Inner-ring
components (Tab, MenuItem, ListItemButton) get an inset ring (outlineOffset -2)
via their own ThemeProvider, so a scrollable container can't clip them.
Add every ring-bearing family to the right bucket (verified offsets):
outer (+2) — ButtonGroup, Chip, Checkbox, Radio, Switch, Stepper, Pagination;
inner (-2) — AccordionSummary, BottomNavigation, TableSortLabel.
…flow clip)

Visual verify caught it: CardActionArea sits in a Card with overflow:hidden, so an
outer ring (+2) is clipped to nothing. Inset (-2) draws inside the card -> visible.
- add utils/toPx (number->px, pass-through for strings/vars)
- Tab, MenuItem, ListItemButton, BottomNavigationAction, CardActionArea:
  inset focus ring on Mui-focusVisible (outlineOffset calc(-1 * focusRing.outlineWidth)),
  so one app-level theme.focusRing renders correctly inside scroll/overflow-clipped containers
- Switch: SwitchRoot overflow -> visible when focusRing set (else hidden) to un-clip the ring
- docs experiment: single ThemeProvider; inset now from component source;
  move AccordionSummary/TableSortLabel to outer-ring (verified no clip)
- createTheme.test.js: focusRing normalization (true/object/transparent/boxShadow/
  false/undefined) + vars theme (palette var, numeric->px fallback)
- ButtonBase.test.js: ring on/off, recolor merge, transparent opt-out (browser-gated)
- Tab.test.js: inset outlineOffset -2px on focus-visible (browser-gated)
- Switch.test.js: root overflow visible when focusRing set, else hidden (browser-gated)
- utils/toPx.test.ts
- docs/data/material/customization/focus-ring/: focus-ring.md + demos
  FocusRingDefault, FocusRingCustomization (js + tsx)
- route docs/pages/material-ui/customization/focus-ring.js
- pages.ts: nav entry under Customization (newFeature)
- N1 pointer walk (Prev/Next + n/total) via .Mui-focusVisible shim; real-Tab drops it (no double-ring)
- N2 custom focusRing JSON editor (overrides preset; invalid -> inline error)
- N3 CSS variables on/off toggle
- N4 resolved theme.focusRing panel
- N5 edge callouts: overflow:hidden clip + forced-colors
…led)

- resolve the ring root via closest('.MuiButtonBase-root') so Checkbox/Radio/Switch
  get .Mui-focusVisible on the SwitchBase root, not the inner input
- skip disabled targets in the walk (isRingDisabled: Mui-disabled / aria-disabled / input.disabled)
- collect targets from document (data-ring-target lives only in the gallery) instead of a
  ref that resolved null; drop the dead galleryRef
- remove CONTEXT.md (experiment-only glossary, not for upstream)
- prettier format experiment page + Switch test
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 30, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+5.31KB(+1.00%) 🔺+1.15KB(+0.75%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@siriwatknp siriwatknp added the RFC Request For Comments. label Jun 30, 2026
@siriwatknp siriwatknp changed the title [ButtonBase] Add theme.focusRing for a themeable keyboard focus ring [WIP][Prototype] Add theme.focusRing for a themeable keyboard focus ring Jun 30, 2026
…ing to non-ButtonBase controls

- API rename: theme.focusRing -> theme.focusVisible (key, --mui-focusVisible-* vars, FocusVisible type, docs page, demos, experiment page)
- Extend curated ring beyond ButtonBase: Slider (thumb), Link (covers Breadcrumbs links), Autocomplete option (inset). Select items already covered via MenuItem.
- Experiment page: add 'own focus' bucket (Slider/Link/Breadcrumbs) + Select/Autocomplete in inner-ring
- Tests: browser-gated focus-visible tests for Link/Slider/Autocomplete
@siriwatknp siriwatknp changed the title [WIP][Prototype] Add theme.focusRing for a themeable keyboard focus ring [WIP][Prototype] Add theme.focusVisible for a themeable keyboard focus ring Jul 2, 2026
…0002 to v1 opt-in

- createThemeWithVars: resolve focusVisible from options+merge args (mirrors
  createThemeNoVars) so createTheme({cssVariables:true},{focusVisible:true})
  normalizes instead of leaving a raw boolean
- rewrite adr/0002: v1 is opt-in only, auto-on fallback deferred; document
  reserved false + scope-by-mechanism
…her components

Use the root-level ...(theme.focusVisible && {...}) pattern like Slider/Tab instead
of a props:()=>Boolean variant. Gate the component=button variant outline:auto to the
non-themed case so the curated ring no longer relies on variant source order. Add a
button-Link regression test.
Switch applies components.MuiButtonBase.defaultProps.disableRipple app-wide to the
preview theme. Demonstrates WCAG 2.4.7: ripple off + ring preset off leaves keyboard
focus with no indicator; the curated ring restores it.
…onGroup

- drop helper text + wrapper div so the switch aligns with the CSS-variables one
- also set MuiButtonGroup defaultProps: ButtonGroup re-broadcasts disableRipple
  (default false) via context, shadowing the MuiButtonBase default

@silviuaavram silviuaavram 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.

One last comment related to the controlled components. The rest looks good to me, great effort here!

Comment thread docs/data/material/customization/focus-visible/focus-visible.md
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 19, 2026
# Conflicts:
#	packages/mui-material/src/Fab/Fab.test.js
#	packages/mui-material/src/Radio/Radio.test.js
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 21, 2026

@LukasTy LukasTy 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.

Looks awesome! 💯
Great work, thanks for the massive continued effort on this! 👍

A few non-blocking nitpicks:

  1. Have you considered a bigger offset on the Switch outline or having a separate demo rebuilding a switch from a different design system?
    The current variant feels a bit too "crammed", where the outline is glued together with the thumb.

  2. Opening Autocomplete does not apply the focus outline to the selected item.
    Is it a result of the Autocomplete component behavior or a slight bug?


Some additional AI Nitpicks

Claude

Autocomplete.js:398 is the only ungated focus tint left. I scanned every component that carries a .Mui-focusVisible background rule. Chip (5 rules), PaginationItem (4), AccordionSummary, MenuItem (2), ListItemButton (2), and CardActionArea are all gated. The [aria-selected="true"] combined rule in Autocomplete is not.

Measured on a listbox option with the ring enabled:

  • unselected: bg=rgba(0, 0, 0, 0) -- tint suppressed, ring solid 2px
  • selected: bg=rgba(25, 118, 210, 0.2) -- the selected-plus-focus tint still applies, ring solid 2px

So when a user moves through the list with the arrow keys, the highlight jumps in intensity on the selected option. MenuItem gates the equivalent rule at line 88. The fix is one guard. The effect is cosmetic, not an accessibility problem.

One note for the record. The isResolvedFocusVisible heuristic carries a documented edge in its own comment: a theme that pins outlineColor to exactly the light primary.main and is then recomposed loses that pin and re-derives per scheme. The trade-off is reasonable and the comment states it.

Codex

One final pass found four material issues:

  1. Selected Autocomplete options still retain the legacy focus tint. With theme.focusVisible, keyboard focus changes the selected background from 0.12 to 0.20, contradicting the docs that the theme ring replaces built-in focus-visible styles. Gate the selected .focusVisible rule behind !theme.focusVisible.

  2. Wider outlines are not fully inset. With { outlineWidth: 4 }, MenuItem computes outlineOffset: -2px, leaving half the outline outside the clipped container. If the fixed offset remains intentional, document that widths above 2 require a matching outlineOffset; otherwise ensure the inset magnitude is at least the width.

  3. The full demo still places Stepper in the inner-ring bucket, which promises outlineOffset: -2px. StepButton now outlines StepLabel with the normal outset +2px geometry, matching the final RFC. Move it to the outer-ring bucket.

  4. The linked prototype contains stale guidance:

    • Its JSON placeholder exposes the private --_focusVisible-behavior variable instead of accepting a plain boxShadow.
    • It says contained Button may override a custom focus shadow, but Button now composes it like Fab.
    • It describes a disabled.focusVisible outline that does not exist; ButtonBase clears focus-visible when disabled.

The RFC precedence wording should also say the curated default has “no visible box-shadow on ordinary surfaces,” since it always carries the private colored-surface shadow slot.

Comment thread docs/data/material/customization/focus-visible/focus-visible.md Outdated
Comment thread docs/data/material/customization/focus-visible/focus-visible.md Outdated
Comment thread docs/data/material/customization/focus-visible/focus-visible.md Outdated
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 25, 2026
Selected option kept the legacy focusOpacity bump when the theme ring is on,
so the highlight jumped 0.12 -> 0.20 on arrow-key walk. Docs already state the
theme ring replaces built-in focus-visible styles. Mirrors MenuItem gating.
Superseded by the customization/focus-visible guide; prototype carried stale
guidance (private var placeholder, contained Button shadow note, nonexistent
disabled focus-visible state).
StepButton rings StepLabel with the normal outset +2px geometry; the inner-ring
bucket hint promises an inset -2px ring.
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 26, 2026
@siriwatknp
siriwatknp requested a review from LukasTy August 26, 2026 10:17
Simplify the description, pin the version to v9.4, and backtick the component
names in the replaced-styles section.
Firefox on CI reports no hover support, so the highlighted option falls back to
action.selected instead of the blended selected+hover value. Assert the focus
bump is absent rather than pinning one environment's color.
@siriwatknp siriwatknp changed the title [material-ui] Add theme.focusVisible opt-in keyboard focus ring [material-ui] Add theme.focusVisible opt-in keyboard focus ring Aug 27, 2026
@siriwatknp
siriwatknp merged commit ba231e3 into mui:master Aug 27, 2026
22 checks passed
siriwatknp added a commit to mj12albert/material-ui that referenced this pull request Aug 31, 2026
Conflicts: ButtonBase.js (styled root moved to memoTheme + variants),
Button/IconButton tests (vitest globals), Snackbar.d.ts.

mui#48743 landed theme.focusVisible, so the ad-hoc ring here is redundant:

- drop --Button-focusRingColor / --IconButton-focusRingColor and their
  outline variants; ButtonBase draws the ring on Mui-focusVisible
- pointer-events: auto moves to a ButtonBase variant
- drop the dead shouldForwardProp on ButtonBaseRoot
- guard IconButton hover with :not(.Mui-disabled), like Button
- document focusableWhenDisabled in its own section, recommending
  theme.focusVisible as the paired focus indicator
siriwatknp added a commit to siriwatknp/material-ui-x that referenced this pull request Sep 17, 2026
Re-read mui/material-ui#48743 under packages/mui-material/src and matched the
shapes it settled on.

Split grouped `&:hover, &:focus-visible` selectors. Focus borrowed the hover
affordance only because there was nothing else to show; with a themed ring the
tint muddies it. Core does the same split in Chip and Slider.

Replaced `x || { legacy }` with `theme.focusVisible ? {} : { legacy }`. Reads as
one choice instead of a fallback chain, and matches CardActionArea, ListItemButton
and MenuItem.

Suppress the focus tint rather than stacking it under the ring: month/year buttons
scope their `:focus` background to `:focus:not(:focus-visible)` so click focus is
untouched, and TreeItemContent drops `[data-focused]` and the selected+focused
combo the way ListItemButton drops its focusVisible backgrounds.

Pin outset rings with `outsetFocusRing` on the month button and clock wrapper. The
inset vars inherit, so a clip-prone ancestor would otherwise inset them — the
reason core spreads it in Link, Slider and Rating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VicTc72owEDrY3VN3UnEE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

customization: theme Higher level theming customizability. package: material-ui Specific to Material UI. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants