Skip to content

[RFC] theme.focusRing — opt-in keyboard focus ring #48718

Description

@siriwatknp

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?

  1. One theme key renders the ring across ButtonBase and every derived component. Zero per-component code for the consumer.
  2. A curated default that is safe to turn on anywhere. No collision with existing focus styles.
  3. 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.
  4. Non-breaking. An unconfigured theme renders the same as today.
  5. Works without extra setup across components, including ones where an outset ring would clip.
  6. 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

  • Types: theme.focusRing?: boolean | React.CSSProperties on ThemeOptions and Theme (vars + non-vars d.ts)
  • Resolve in createTheme: true to the curated default, an object merged over it; vars path uses the palette var for the default color
  • Single (theme.vars || theme).focusRing rule on ButtonBase, covering all derived
  • Auto outlineOffset (inset) for overflow components, kept unless the object sets outlineOffset (Tab, MenuItem…)
  • Type tests (createTheme.spec.ts) + unit tests (ButtonBase.test.js): default on, merge over default, outlineColor: transparent removes the outline
  • Docs page customization/focus-ring + experiment demo (include a two-color C40 example)
  • Verify forced-colors, no layout shift, and zero Argos diff when unconfigured

Resources and benchmarks

Metadata

Metadata

Assignees

No one assigned

    Labels

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions