[material-ui] Add theme.focusVisible opt-in keyboard focus ring - #48743
[material-ui] Add theme.focusVisible opt-in keyboard focus ring#48743siriwatknp wants to merge 86 commits into
Conversation
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
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
…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
…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
Agreed, I think more spacing looks way better. 👍 |
|
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
…s constants instead of literal
…ract test (true case), key-level asserts elsewhere
…o longer conditional)
Signed-off-by: Siriwat K <siriwatkunaporn@gmail.com>
Latest changes
Ready for another review |
|
Nice work, the Tabs look way better now. Below is a curated Claude Opus 5 review Fixed since the last round
Still broken
Follow-ups
TestsNo test asserts the ring's computed outline. I grepped |
…ackDisabled (0.12, was switchTrack 0.38); add default-color checked 0.5 rule before disabled so the combo dims
… disabled/checked+disabled 0.12
…o playgrounds" This reverts commit aed11d7.
…rge wrapper), resolve against merged palette, recompose re-derives per-scheme color via baked-default check; TDD tests
2f227ad to
4a5ddc0
Compare
Both are fixed now.
One known limit: recomposing a theme AND changing its color source (palette or |
…-aware shadows[6] compose (Button/Fab), gate applyChildrenFocusVisible on theme.focusVisible, outset behavior var uses initial, docs compose note
|
On the follow-ups — 5, 6, 7, 8, 10 are addressed in d469618.
|
…ppBar rows with matched geometry, secondary indicator
|
Awesome work on fine tuning edge-cases. 👌 One note regarding the Switch component states VRT: on first check I got confused with same labels and different visuals. Remaining
Nits
|
… the focus indicator
Indeed, for the same color outline it wouldn't, but it would resolve the issue itself if someone decides to go with customized color. |
Screen.Recording.2569-08-11.at.15.56.48.movAdded z-index looks better for all of the colors. |

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 theMui-focusVisiblestate — the keyboard-focus stateButtonBasealready tracks — acrossButtonBaseand 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).undefinedtrue2px solid,primary.main,2pxoffsetFocusVisible=React.CSSProperties)falseRendered with CSS
outline(survives Windows High Contrast /forced-colors, no layout shift, no collision with thebox-shadowelevation Button/Fab already animate). Coverage:ButtonBase— Button, IconButton, Fab, and any customButtonBaseconsumer.<li>).svg), Switch (track), Slider (thumb), Rating (active icon + empty-value label), Link (component="button").Ships with an exported
FocusVisibletype, a guide atcustomization/focus-visible, and the/experiments/focus-ringprototype.For Reviewers
Hide whitespace when review.
Color resolution lives in three places, one per theme mode. The geometry (
outlineWidth/Offset/Style+ inset-var wiring) is shared byresolveFocusVisibleinstyles/focusVisible.ts; only the defaultoutlineColordiffers:createTheme({ focusVisible: true })createThemeNoVars.jsoutlineColor: resolved hex offpalette.primary.maincreateTheme({ focusVisible: true, colorSchemes: { light, dark } })createThemeNoVars.js(default scheme, top-level) +createTheme.ts(per-scheme copy)outlineColor: each scheme's ownprimary.mainuseColorSchememode changecreateTheme({ cssVariables: true, focusVisible: true })createThemeWithVars.jsoutlineColor:var(--mui-palette-primary-main)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). SocreateTheme.tsgives each scheme its own resolvedfocusVisible, and that same merge swaps the outline color per mode — exactly as it doespalette. 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 userboxShadowinset).wireFocusVisibleVarsbakes 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.
focusVisibleis skipped from var generation (shouldSkipGeneratingVar) and kept inline: hoisting it to:rootwould 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.ButtonBasegate. The root ring is gated by a privateinternalDisabledThemeFocusVisibleprop (defaultfalse); the whole variant is a no-op whentheme.focusVisibleis unset.SwitchBasesets ittrueso 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, andapplyChildrenFocusVisible(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) andcreateTheme.spec.ts(types); computed-style tests acrossButtonBase,Tab,Checkbox,Radio,Switch,Slider,Rating,Link,Autocomplete,Fab,Button;ThemeProvider.test.tsxdrivessetMode('dark')and asserts the outline color follows the active scheme. Visual-regression fixtures undertest/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 theMui-focusVisibleclass 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-shadowvar (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 customboxShadowintheme.focusVisiblereplaces that slot — surface contrast is then the author's call.RFC: #48718