[material-ui] Add theme.focusVisible opt-in keyboard focus ring - #48743
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
silviuaavram
left a comment
There was a problem hiding this comment.
One last comment related to the controlled components. The rest looks good to me, great effort here!
…leDemo, matching FocusVisibleInner
…dimmed under the ring
# Conflicts: # packages/mui-material/src/Fab/Fab.test.js # packages/mui-material/src/Radio/Radio.test.js
LukasTy
left a comment
There was a problem hiding this comment.
Looks awesome! 💯
Great work, thanks for the massive continued effort on this! 👍
A few non-blocking nitpicks:
-
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. -
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:
-
Selected Autocomplete options still retain the legacy focus tint. With
theme.focusVisible, keyboard focus changes the selected background from0.12to0.20, contradicting the docs that the theme ring replaces built-in focus-visible styles. Gate the selected.focusVisiblerule behind!theme.focusVisible. -
Wider outlines are not fully inset. With
{ outlineWidth: 4 }, MenuItem computesoutlineOffset: -2px, leaving half the outline outside the clipped container. If the fixed offset remains intentional, document that widths above 2 require a matchingoutlineOffset; otherwise ensure the inset magnitude is at least the width. -
The full demo still places Stepper in the inner-ring bucket, which promises
outlineOffset: -2px. StepButton now outlines StepLabel with the normal outset+2pxgeometry, matching the final RFC. Move it to the outer-ring bucket. -
The linked prototype contains stale guidance:
- Its JSON placeholder exposes the private
--_focusVisible-behaviorvariable instead of accepting a plainboxShadow. - It says contained Button may override a custom focus shadow, but Button now composes it like Fab.
- It describes a
disabled.focusVisibleoutline that does not exist; ButtonBase clears focus-visible when disabled.
- Its JSON placeholder exposes the private
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.
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.
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.
theme.focusVisible opt-in keyboard focus ring
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
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
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 and a guide atcustomization/focus-visible.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