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.focusRing. 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.focusRing: 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 |
createTheme({ focusRing: true }); // curated outline ring
createTheme({ focusRing: { boxShadow: '0 0 0 4px #fff' } }); // outline + box-shadow (two-color, WCAG C40)
createTheme({ focusRing: { boxShadow: '0 0 0 4px #fff', outlineColor: 'transparent' } }); // box-shadow only
Curated default: { outlineStyle: 'solid', outlineColor: palette.primary.main, outlineWidth: 2, outlineOffset: 2 }.
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.vars || theme).focusRing rule on ButtonBase reaches all derived components, since they are styled(ButtonBase). Under CSS theme variables, theme.vars.focusRing is generated automatically. It gives a --<prefix>-focusRing-* override surface. The default color points at --<prefix>-palette-primary-main, so it stays scheme-reactive.
Material UI ships the correct outlineOffset per component, inset where an outset ring would clip (Tab, MenuItem…). An object that does not set outlineOffset keeps these per-component values; setting outlineOffset overrides them everywhere.
Coexistence. The outline is always present as the baseline, and does not collide, since it is a different property from the elevation box-shadow. A box-shadow part is additive, and may be overridden on components that animate their own focus box-shadow (contained Button, Fab) — but the outline baseline still shows. In forced-colors, the box-shadow is stripped while the outline remains.
Scope. ButtonBase and derived. Out of scope: auto-show the ring when the ripple is disabled (a follow-up RFC), and non-ButtonBase inputs like TextField and Checkbox.
Prototype — TODOs
Resources and benchmarks
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.focusRing. 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.focusRing: boolean | React.CSSProperties, applied onMui-focusVisible:undefinedtruefalseCurated default:
{ outlineStyle: 'solid', outlineColor: palette.primary.main, outlineWidth: 2, outlineOffset: 2 }.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). One(theme.vars || theme).focusRingrule on ButtonBase reaches all derived components, since they arestyled(ButtonBase). Under CSS theme variables,theme.vars.focusRingis generated automatically. It gives a--<prefix>-focusRing-*override surface. The default color points at--<prefix>-palette-primary-main, so it stays scheme-reactive.Material UI ships the correct
outlineOffsetper component, inset where an outset ring would clip (Tab, MenuItem…). An object that does not setoutlineOffsetkeeps these per-component values; settingoutlineOffsetoverrides them everywhere.Coexistence. The outline is always present as the baseline, and does not collide, since it is a different property from the elevation box-shadow. A box-shadow part is additive, and may be overridden on components that animate their own focus box-shadow (contained Button, Fab) — but the outline baseline still shows. In forced-colors, the box-shadow is stripped while the outline remains.
Scope. ButtonBase and derived. Out of scope: auto-show the ring when the ripple is disabled (a follow-up RFC), and non-ButtonBase inputs like TextField and Checkbox.
Prototype — TODOs
theme.focusRing?: boolean | React.CSSPropertieson ThemeOptions and Theme (vars + non-vars d.ts)trueto the curated default, an object merged over it; vars path uses the palette var for the default color(theme.vars || theme).focusRingrule on ButtonBase, covering all derivedoutlineOffset(inset) for overflow components, kept unless the object setsoutlineOffset(Tab, MenuItem…)createTheme.spec.ts) + unit tests (ButtonBase.test.js): default on, merge over default,outlineColor: transparentremoves the outlinecustomization/focus-ring+ experiment demo (include a two-color C40 example)Resources and benchmarks