Prototype: #48743
What's the problem?
Material UI's focus indicator is the ripple (Material Design).
Some teams opt out of Material Design. Flat look, no ripple. After that, no keyboard-focus indicator is left.
So they build a focus ring by hand, on every interactive component. There is no built-in, themeable focus ring today.
By hand is painful in two ways:
- No single switch. Must wire it on Button, IconButton, Tab, MenuItem, and every other interactive component.
- Per-component geometry. An outset outline is clipped on components inside
overflow: hidden (Tab, MenuItem…). Each one needs an inset ring with a flipped outlineOffset.
This RFC proposes a built-in, opt-in theme.focusVisible. One theme key turns the ring on everywhere. Material UI handles outlineOffset for components where the ring would clip, so the consumer does not tune geometry.
It also prepares for auto-restoring the indicator when the ripple is off (WCAG 2.4.7). That automatic part is a separate follow-up.
What are the requirements?
- One theme key renders the ring across ButtonBase and every derived component. Zero per-component code for the consumer.
- A curated default that is safe to turn on anywhere. No collision with existing focus styles.
- Customization is additive. A provided object merges over the safe outline default. So adding a box-shadow gives a two-color focus indicator (WCAG C40), and removing the outline is explicit.
- Non-breaking. An unconfigured theme renders the same as today.
- Works without extra setup across components, including ones where an outset ring would clip.
- Survives forced-colors. No layout shift. Color stays scheme-reactive under CSS theme variables.
What are our options?
Ring technique. outline is the default. It survives forced-colors. It does not collide with the box-shadow elevation that Button and Fab animate on focus. It follows border-radius. box-shadow is additive on top, but on its own it is stripped in forced-colors and can collide with elevation. ::after is rejected.
API shape. Full React.CSSProperties over an outline-only subset, so any technique works. Partial override over verbatim. A provided object merges over the curated default, so the safe outline stays unless the consumer removes it. This supports a two-color indicator (outline plus box-shadow, WCAG C40), and avoids the case where a partial outline renders nothing. A true flag for the curated default.
Placement. A single rule on ButtonBase, over per-component rules or GlobalStyles.
Proposed solution
theme.focusVisible: boolean | React.CSSProperties, applied on Mui-focusVisible:
| Value |
Behavior |
undefined |
No ring (default) |
true |
Curated outline default, safe against existing styles |
| object |
Merged over the curated default (partial override) |
false |
Reserved kill-switch (see Reserved false) |
createTheme({ focusVisible: true }); // curated outline ring
createTheme({ focusVisible: { boxShadow: '0 0 0 4px #fff' } }); // outline + box-shadow (two-color, WCAG C40)
createTheme({ focusVisible: { boxShadow: '0 0 0 4px #fff', outlineColor: 'transparent' } }); // box-shadow only
Curated default: { outlineStyle: 'solid', outlineColor: palette.primary.main, outlineWidth: 2, outlineOffset: 2 }. The outlineOffset is a fixed 2 — it does not scale with a custom outlineWidth; a custom outlineOffset replaces it.
A provided object merges over this default. So { outlineColor: '#f00' } keeps the solid 2px ring and only changes the color. To drop the outline part, set outlineColor: 'transparent' (invisible, keeps geometry) or outlineWidth: 0 (full removal). This follows WCAG C40, where an outline and a box-shadow combine into one indicator. See the two-color focus example.
Implementation, zero per-component code for the consumer. The value is resolved to the curated object at createTheme time (true is the default; an object merges over it). One theme.focusVisible rule on ButtonBase reaches all derived components, since they are styled(ButtonBase). The default color points at the primary.main palette variable, so it stays scheme-reactive under CSS theme variables (correct in dark mode).
Inner ring components
if the ring is outer:

Some components render inside a clipping container — a Tabs scroller, a scrolling menu or list, a Card's overflow: hidden. An outset ring is clipped there, so Material UI insets the ring automatically on those components. One theme key looks right everywhere, with no geometry tuning by the consumer:
createTheme({ focusVisible: true });
// outset by default; inset on Tab, MenuItem, ListItemButton, CardActionArea,
// and BottomNavigationAction — where an outset ring would clip.
Each inner-ring component sets a private CSS variable on its root that the shared ring reads and flips the outline inward:
Customization rides the same var, wired internally: createTheme prepends it to a custom boxShadow and wraps a custom outlineOffset, so a customized ring insets here too. The consumer writes plain CSS and never references a var (the var handles are not public API).
A custom outlineOffset is a positive magnitude; the inset direction is managed by the sign var, so a negative offset is unsupported (it would flip to outset on clip-prone components). For a literal signed offset, override the specific component's .Mui-focusVisible via styleOverrides. The sign var is a multiplier: most inset families mirror the offset 1:1, but Tab insets deeper (3×, −6px with the curated 2px offset) so the ring does not overlap the selected-tab underline indicator.
Multi-layer box-shadow rings (comma-separated, e.g. a double C40) are out of scope for the automatic inset — only the first layer insets on clip-prone components; use styleOverrides for a multi-layer ring inside a clip-prone component.
Precedence. An explicit theme.focusVisible box-shadow wins over a component's own focus box-shadow (contained Button/Fab elevation, Slider's color halo) — each re-asserts theme.focusVisible after its own shadow in the same rule. The curated outline-only default has no box-shadow, so those elevations/halos stay intact.
Scope
Scope is defined by mechanism: every component that renders ButtonBase, plus the .Mui-focusVisible carriers wired explicitly.
- Automatic via the ButtonBase rule: Button, IconButton, Fab, ToggleButton, and bare
ButtonBase.
- Inner ring (inset, clip-prone): Tab, MenuItem, ListItemButton, BottomNavigationAction, CardActionArea.
- SwitchBase controls — ring on a slot, not the ButtonBase root: Checkbox and Radio outline the icon
svg; Switch outlines the track (see Switch). The root ring would land on the padded hit area (or the thumb), which is uncommon for these, so they opt the root out and draw the ring on the relevant slot instead — no double ring.
- ButtonBase when interactive: Chip (clickable or deletable).
- Non-ButtonBase, wired explicitly because they own
.Mui-focusVisible: Slider (thumb), Link, Autocomplete (listbox option — inset, the listbox scrolls), and Rating (active icon + empty-value label).
- Out of scope: inputs that do not carry
.Mui-focusVisible through these paths — TextField / InputBase and friends keep their own indicators.
In scope — ring contrast on colored surfaces. The curated ring is primary.main, tuned for standard paper/background surfaces. On a saturated container — an AppBar with a palette color, a filled Alert, an inverted SnackbarContent — a primary outline alone can lack contrast. Those containers therefore set a private --_focusVisible-shadow var (0 0 0 4px background.default) that the curated ring's box-shadow slot (var(--_focusVisible-shadow, 0 0)) consumes: a background-colored halo renders behind the outline, keeping the indicator visible on the saturated surface. A custom boxShadow in theme.focusVisible replaces that slot (normal merge semantics), so ring contrast on those surfaces becomes the author's responsibility — e.g. a contrasting outlineColor scoped to the surface. The prototype's "on a colored surface" section shows the case.
Reserved false and the auto-on follow-up
Today undefined and false both render no ring. They diverge when the deferred auto-on fallback lands: at that point undefined will mean "ring whenever disableRipple removes the only indicator", which is a visual change for apps that already hand-rolled a ring (they would get a double ring). false is the escape those apps set now to stay opted out through that transition. Reserving it in v1 keeps the follow-up non-breaking.
Prototype
Implemented in #48743. Live prototype — every ButtonBase-derived component, with a preset switcher (curated / recolor / two-color C40 / box-shadow-only), light/dark, and a CSS-variables toggle:
https://deploy-preview-48743--material-ui.netlify.app/experiments/focus-ring/
Decision need
✅ Naming — resolved: focusVisible
Naming of the field. Candidates were focusRing, focusIndicator, focusVisible. Decision: focusVisible.
The key styles the .Mui-focusVisible state, wherever that state appears — not only a ButtonBase artifact. The prototype proves this: it wires the same value into Slider's thumb and Link, which are not ButtonBase but carry .Mui-focusVisible. Naming the key after the state it targets (focusVisible) is therefore more accurate than an artifact name (focusRing), maps 1:1 to the .Mui-focusVisible class a developer sees in devtools, and reuses Material UI's own established vocabulary (.Mui-focusVisible, focusVisibleClassName) instead of importing a term from other libraries. It also reads as keyboard-only (:focus-visible), which focusIndicator would blur against :focus. "Focus ring" stays as the informal feature name in prose/docs; the API key is focusVisible.
✅ Switch
Which appearance should be? Option 2 (on the track slot)
- With default focus on ButtonBase, the outline shown on the thumb which is not common:
- show outline on the track slot (need to adjust force opacity to 1 because prior styles uses opacity to dim the background color).
when customized, it looks like this:
✅ Radio & Checkbox (option 2, on svg)
- the default focus visible on ButtonBase
- targeting on svg:
Resources and benchmarks
Prototype: #48743
What's the problem?
Material UI's focus indicator is the ripple (Material Design).
Some teams opt out of Material Design. Flat look, no ripple. After that, no keyboard-focus indicator is left.
So they build a focus ring by hand, on every interactive component. There is no built-in, themeable focus ring today.
By hand is painful in two ways:
overflow: hidden(Tab, MenuItem…). Each one needs an inset ring with a flippedoutlineOffset.This RFC proposes a built-in, opt-in
theme.focusVisible. One theme key turns the ring on everywhere. Material UI handlesoutlineOffsetfor components where the ring would clip, so the consumer does not tune geometry.It also prepares for auto-restoring the indicator when the ripple is off (WCAG 2.4.7). That automatic part is a separate follow-up.
What are the requirements?
What are our options?
Ring technique.
outlineis the default. It survives forced-colors. It does not collide with the box-shadow elevation that Button and Fab animate on focus. It follows border-radius. box-shadow is additive on top, but on its own it is stripped in forced-colors and can collide with elevation.::afteris rejected.API shape. Full
React.CSSPropertiesover an outline-only subset, so any technique works. Partial override over verbatim. A provided object merges over the curated default, so the safe outline stays unless the consumer removes it. This supports a two-color indicator (outline plus box-shadow, WCAG C40), and avoids the case where a partial outline renders nothing. Atrueflag for the curated default.Placement. A single rule on ButtonBase, over per-component rules or GlobalStyles.
Proposed solution
theme.focusVisible: boolean | React.CSSProperties, applied onMui-focusVisible:undefinedtruefalsefalse)Curated default:
{ outlineStyle: 'solid', outlineColor: palette.primary.main, outlineWidth: 2, outlineOffset: 2 }. TheoutlineOffsetis a fixed2— it does not scale with a customoutlineWidth; a customoutlineOffsetreplaces it.A provided object merges over this default. So
{ outlineColor: '#f00' }keeps the solid 2px ring and only changes the color. To drop the outline part, setoutlineColor: 'transparent'(invisible, keeps geometry) oroutlineWidth: 0(full removal). This follows WCAG C40, where an outline and a box-shadow combine into one indicator. See the two-color focus example.Implementation, zero per-component code for the consumer. The value is resolved to the curated object at createTheme time (
trueis the default; an object merges over it). Onetheme.focusVisiblerule on ButtonBase reaches all derived components, since they arestyled(ButtonBase). The default color points at theprimary.mainpalette variable, so it stays scheme-reactive under CSS theme variables (correct in dark mode).Inner ring components
if the ring is outer:

Some components render inside a clipping container — a Tabs scroller, a scrolling menu or list, a Card's
overflow: hidden. An outset ring is clipped there, so Material UI insets the ring automatically on those components. One theme key looks right everywhere, with no geometry tuning by the consumer:Each inner-ring component sets a private CSS variable on its root that the shared ring reads and flips the outline inward:
Customization rides the same var, wired internally:
createThemeprepends it to a customboxShadowand wraps a customoutlineOffset, so a customized ring insets here too. The consumer writes plain CSS and never references a var (the var handles are not public API).A custom
outlineOffsetis a positive magnitude; the inset direction is managed by the sign var, so a negative offset is unsupported (it would flip to outset on clip-prone components). For a literal signed offset, override the specific component's.Mui-focusVisibleviastyleOverrides. The sign var is a multiplier: most inset families mirror the offset 1:1, but Tab insets deeper (3×, −6px with the curated 2px offset) so the ring does not overlap the selected-tab underline indicator.Multi-layer box-shadow rings (comma-separated, e.g. a double C40) are out of scope for the automatic inset — only the first layer insets on clip-prone components; use
styleOverridesfor a multi-layer ring inside a clip-prone component.Precedence. An explicit
theme.focusVisiblebox-shadow wins over a component's own focus box-shadow (contained Button/Fab elevation, Slider's color halo) — each re-assertstheme.focusVisibleafter its own shadow in the same rule. The curated outline-only default has no box-shadow, so those elevations/halos stay intact.Scope
Scope is defined by mechanism: every component that renders
ButtonBase, plus the.Mui-focusVisiblecarriers wired explicitly.ButtonBase.svg; Switch outlines thetrack(see Switch). The root ring would land on the padded hit area (or the thumb), which is uncommon for these, so they opt the root out and draw the ring on the relevant slot instead — no double ring..Mui-focusVisible: Slider (thumb), Link, Autocomplete (listbox option — inset, the listbox scrolls), and Rating (active icon + empty-value label)..Mui-focusVisiblethrough these paths — TextField / InputBase and friends keep their own indicators.Reserved
falseand the auto-on follow-upToday
undefinedandfalseboth render no ring. They diverge when the deferred auto-on fallback lands: at that pointundefinedwill mean "ring wheneverdisableRippleremoves the only indicator", which is a visual change for apps that already hand-rolled a ring (they would get a double ring).falseis the escape those apps set now to stay opted out through that transition. Reserving it in v1 keeps the follow-up non-breaking.Prototype
Implemented in #48743. Live prototype — every ButtonBase-derived component, with a preset switcher (curated / recolor / two-color C40 / box-shadow-only), light/dark, and a CSS-variables toggle:
https://deploy-preview-48743--material-ui.netlify.app/experiments/focus-ring/
Decision need
✅ Naming — resolved:
focusVisibleNaming of the field. Candidates were
focusRing,focusIndicator,focusVisible. Decision:focusVisible.The key styles the
.Mui-focusVisiblestate, wherever that state appears — not only a ButtonBase artifact. The prototype proves this: it wires the same value into Slider's thumb and Link, which are not ButtonBase but carry.Mui-focusVisible. Naming the key after the state it targets (focusVisible) is therefore more accurate than an artifact name (focusRing), maps 1:1 to the.Mui-focusVisibleclass a developer sees in devtools, and reuses Material UI's own established vocabulary (.Mui-focusVisible,focusVisibleClassName) instead of importing a term from other libraries. It also reads as keyboard-only (:focus-visible), whichfocusIndicatorwould blur against:focus. "Focus ring" stays as the informal feature name in prose/docs; the API key isfocusVisible.✅ Switch
Which appearance should be? Option 2 (on the track slot)
when customized, it looks like this:
✅ Radio & Checkbox (option 2, on svg)
Resources and benchmarks