Skip to content

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

Open
siriwatknp wants to merge 86 commits into
mui:masterfrom
siriwatknp:focus-ring-v1
Open

[material-ui] Add theme.focusVisible opt-in keyboard focus ring#48743
siriwatknp wants to merge 86 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, a guide at customization/focus-visible, and the /experiments/focus-ring prototype.

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.

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 🔺+4.35KB(+0.83%) 🔺+962B(+0.63%)
@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
@LukasTy

LukasTy commented Aug 6, 2026

Copy link
Copy Markdown
Member

I lean to the latter.

Agreed, I think more spacing looks way better. 👍

@silviuaavram

Copy link
Copy Markdown
Member

I also like the second better.

…s unclickable; ring fits root padding) + hit-area regression test; skip behavior-var prefix on standalone box-shadow keywords; drop redundant focusVisible guard in Button/Fab; assert Mui-focusVisible class in Checkbox/Fab tests
…ract test (true case), key-level asserts elsewhere
Comment thread packages/mui-material/src/ButtonBase/ButtonBase.js Outdated
@siriwatknp

siriwatknp commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Latest changes

  • Complete colored background surface support with the proposed solution, docs added.
  • Update Tab to have 3x inset
  • Fix Switch overflow issue (revert back to original implementation)

Ready for another review

@LukasTy

LukasTy commented Aug 6, 2026

Copy link
Copy Markdown
Member

Nice work, the Tabs look way better now.

Below is a curated Claude Opus 5 review

Fixed since the last round

  • Switch hit area. I probed the bare Switch again. No dead points in any state.
  • Standalone box-shadow keywords. boxShadow: 'none' stays none.
  • The curated default keeps the Button elevation and the Slider halo. Button, Fab and Slider compose both shadows.
  • Colored surfaces are a good addition. They work for outset and inset components. A Tab inside an AppBar mirrors the halo inward and still forms a two-color indicator.

Still broken

  1. Switch track colors. All three states are unchanged. color="default" checked renders 0.38; it rendered 0.5 before, so a checked switch now matches an unchecked one. Vars mode disabled renders 0.38 against an expected 0.12. Vars mode checked plus disabled renders 0.38 against an expected 0.12.
  2. The theme resolution reads back its own output. Recompose a colorSchemes theme: the dark ring becomes #1976d2 while the dark palette stays #90caf9. A palette in a merge argument does not change the ring. extractFocusVisibleInput takes the last argument instead of a deep merge, so vars mode drops outlineStyle: 'dashed' and no-vars mode keeps it. The same input gives a different ring for each mode.

Follow-ups

  1. ButtonGroup with variant="contained" hides one edge of the ring. The next sibling paints over it.
  2. Checkbox and Radio with a custom icon that is not an svg show no ring. The selector svg:first-of-type matches nothing.
  3. disableElevation plus the documented box-shadow-only recipe leaves no indicator. The outline is transparent in that recipe and Button.js:292 sets box-shadow: none. This is narrow and needs one line.
  4. The docs info box still says the ring shadow replaces the Button elevation. Button and Fab now compose both. Update that note.
  5. Button.js:136 and Fab.js:84 use theme.shadows[6]. The line below uses (theme.vars || theme).shadows[6]. The curated default always sets a boxShadow, so the first branch always runs and the second line is dead.
  6. applyChildrenFocusVisible is not gated on theme.focusVisible. Every applyInsetFocusVisible call site is gated.
  7. The Tab inset multiplier scales with a custom offset. applyInsetFocusVisible(3) gives -6px by default and -12px with outlineOffset: 4. Confirm that this depth is intended.
  8. outsetFocusRing still uses 'var(--__,)'. initial is the correct idiom.

Tests

No test asserts the ring's computed outline. I grepped outlineStyle and outlineWidth across the package and found one unrelated Dialog hit. Switch and Button contain zero focusVisible tests, and those two components hold three of the confirmed defects. The VRT fixtures cover no disabled state, no color="default", and no colored surface. Every defect above therefore passes CI.

…ackDisabled (0.12, was switchTrack 0.38); add default-color checked 0.5 rule before disabled so the combo dims
…rge wrapper), resolve against merged palette, recompose re-derives per-scheme color via baked-default check; TDD tests
@siriwatknp

siriwatknp commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Still broken

Both are fixed now.

  1. Switch track colors — fixed in 3186084. Used the wrong variable, opacity.switchTrack instead of opacity.switchTrackDisabled, and the default color missed a dim rule for checked. Added 2 VRT fixtures with all six track states so each one has a golden screenshot now (db5c775).

  2. Theme resolution — fixed in 4a5ddc0. All three cases had the same cause: focusVisible was not merged the same way as the rest of the theme. Now it deep-merges like every other theme key, and the ring color reads the final palette — so a palette in a merge argument recolors the ring, and createTheme(theme, overrides) keeps the ring color per scheme.

One known limit: recomposing a theme AND changing its color source (palette or cssVarPrefix) in the same call keeps the previous ring color. Workaround: pass focusVisible: true again in that call. I think fixing it properly is not worth it for v1 — happy to revisit if you disagree.

…-aware shadows[6] compose (Button/Fab), gate applyChildrenFocusVisible on theme.focusVisible, outset behavior var uses initial, docs compose note
@siriwatknp

siriwatknp commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

On the follow-ups — 5, 6, 7, 8, 10 are addressed in d469618.

  1. Open to ideas, but I accept this limitation for now.

  2. Widening the selector is risky — *:first-of-type rings the hidden input (it is the first child), and a type list like :is(svg, div, span) rings the ripple too. So svg only for v1; a non-svg icon can add the ring with one styleOverrides line. Open to a different direction if you see one.

  3. Confirmed a bug — disableElevation reset the focus box-shadow to none, which killed the box-shadow-only recipe. Now it keeps the ring shadow.

  4. Docs note updated — Button/Fab compose both shadows, not replace.

  5. Half right — the compose branch is now vars-aware. The second line is not dead though, it runs when focusVisible is unset.

  6. Gated all three call sites on theme.focusVisible.

  7. Yes, intended — the inset depth follows the ring geometry.

  8. Done — initial works, verified computed styles in the browser runner.

…ppBar rows with matched geometry, secondary indicator
@LukasTy

LukasTy commented Aug 10, 2026

Copy link
Copy Markdown
Member

Awesome work on fine tuning edge-cases. 👌
Everything checks out.

One note regarding the Switch component states VRT: on first check I got confused with same labels and different visuals.
Nitpick, but could be worth adding an extra word/label for the default colored states to differentiate from the actual "default" primary color.

Remaining

  • ButtonGroup variant="contained" hides one edge of the ring. It's not a dealbreaker, just a bit suboptimal result.
    This case essentially seems similar to the other colored surfaces, so, we could consider similar approach. However, it might be a bit too extreme.
    Have you considered applying z-index: 1 on the focused item?
  • Checkbox and Radio with a non-svg icon. Your selector reasoning is correct: *:first-of-type rings the hidden input. One selector does work: & > *:not(input):not(.MuiTouchRipple-root). I'm fine keeping it as is for the initial release, but maybe we could document this possible "gotcha"?

Nits

  • The known theme limit needs to reach the docs. A recompose plus a palette change in one call keeps the old ring color. I reproduced it. That limit lives only in a PR comment today, so a user meets a wrong-colored ring with no warning.
  • Two reviewer asks are still open in the docs. Silviu asked for a section about components that need manual changes; the page has zero mentions of the focus tint. Marija asked to show how to remove the Slider overlay; the demo still renders a plain Slider.

@siriwatknp

Copy link
Copy Markdown
Member Author

For ButtonGroup variant="contained", z-index would not help because the color (primary.main) is the same.

1786436448-83612-bg-ring-fixed-zoom

@LukasTy

LukasTy commented Aug 11, 2026

Copy link
Copy Markdown
Member

For ButtonGroup variant="contained", z-index would not help because the color (primary.main) is the same.

Indeed, for the same color outline it wouldn't, but it would resolve the issue itself if someone decides to go with customized color.
P.S. The same issue is present in the ToggleGroup.

@siriwatknp

siriwatknp commented Aug 11, 2026

Copy link
Copy Markdown
Member Author
Screen.Recording.2569-08-11.at.15.56.48.mov

Added z-index looks better for all of the colors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RFC Request For Comments.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants