[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 101 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
|
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. |
…cade tie — contained keeps ring slot not shadows[6] (order-dependent on master), zIndex 1 paints ring above siblings (ButtonGroup + ToggleButtonGroup); VRT fixture
…ble two-column groups, SvgIcon callout
… on — ring is the only focus indicator (hover/drag overlays unchanged); caveat doc
|
Recent changes:
|
…eature is on — theme indicator is the only one (Chip, MenuItem, ListItemButton, AccordionSummary, PaginationItem, CardActionArea, Autocomplete, Slider caveat broadened)
… the theme indicator
…cusVisible) — root, colorSchemes, merge-arg paths; caveat doc with snippet
beeb5ee to
6a25af9
Compare
…cked-emoji comment
| <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> | ||
| Title | ||
| </Typography> | ||
| <Button color="inherit">Login</Button> |
There was a problem hiding this comment.
Do we expect this flicker on blur?
Screen.Recording.2026-08-13.at.09.00.41.mov
There was a problem hiding this comment.
I'd say yes. It's because of the transition property on the Button (at least, I don't see a good workaround).
Setting to none on focus visible would not work because the transition kicks in when the focus visible leave, not on enter.
| <Button variant="outlined">Tab to me</Button> | ||
| <Tabs value={0}> | ||
| <Tab label="Tab one" /> | ||
| <Tab label="Tab two" /> |
There was a problem hiding this comment.
Updated, what do you think of this?
Screen.Recording.2569-08-13.at.13.47.31.mov
| transitions: Transitions; | ||
| typography: TypographyVariants; | ||
| zIndex: ZIndex; | ||
| focusVisible?: FocusVisible | false | undefined; |
There was a problem hiding this comment.
so there's no true possibility?
There was a problem hiding this comment.
the true is on the ThemeOptions, in the Theme interface it should be object.
no-vars colorSchemes path decided "was outlineColor defaulted?" by comparing it to palette.primary.main, so pinning the ring to that exact color read as unset and dark regenerated its own. Take provenance from the raw input instead; fall back to value equality only for a re-composed ring, whose baked color is ours.
Guide promised a custom box-shadow insets automatically. Only the first layer of a comma-separated value does; the rest stay outset and get clipped on the inner focus indicator components.
Three derived booleans collapse to one: the middle term was redundant, since a resolved ring always carries an outlineColor. Move the resolved-ring test into focusVisible.ts so the private var name stays in the module that owns it.
FocusVisible/SelectionControls already renders both controls focus-visible in no-vars mode, so the loop only added a duplicate. Vars mode stays — nothing else covers shouldSkipGeneratingVar for the svg recipe.
| Press <kbd>Tab</kbd>, then use the arrow keys — the ring insets so the Tabs | ||
| scroller cannot clip it. | ||
| </Typography> | ||
| <Tabs value={0}> |
There was a problem hiding this comment.
any reason we're setting a fixed value?
| import Tab from '@mui/material/Tab'; | ||
|
|
||
| const theme = createTheme({ | ||
| focusVisible: { |



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.Multi-layer box-shadow is not supported. The behavior var is prepended once, in front of the whole value. A comma-separated
boxShadowis 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 invar()fallbacks andrgb()colors. Even with a parser it stays incomplete, sincevar(--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
styleOverrideshandles a multi-layer ring on one component. Called out in the customization guide.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