Skip to content

[material-ui] Add theme.focusVisible opt-in keyboard focus ring - #48743

Open
siriwatknp wants to merge 101 commits into
mui:masterfrom
siriwatknp:focus-ring-v1
Open

[material-ui] Add theme.focusVisible opt-in keyboard focus ring#48743
siriwatknp wants to merge 101 commits into
mui:masterfrom
siriwatknp:focus-ring-v1

Conversation

@siriwatknp

@siriwatknp siriwatknp commented Jun 30, 2026

Copy link
Copy Markdown
Member

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 the Mui-focusVisible state — the keyboard-focus state ButtonBase already tracks — across ButtonBase and 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).

createTheme({ focusVisible: true });
// or customize — merges over the curated default:
createTheme({ focusVisible: { outlineColor: '#9c27b0', outlineOffset: 3 } });
Value Behavior
undefined No ring — default, fully non-breaking (zero visual diff)
true Curated ring: 2px solid, primary.main, 2px offset
object (FocusVisible = React.CSSProperties) Merged over the curated default
false Reserved kill-switch for the deferred auto-on fallback

Rendered with CSS outline (survives Windows High Contrast / forced-colors, no layout shift, no collision with the box-shadow elevation Button/Fab already animate). Coverage:

  • Free via ButtonBase — Button, IconButton, Fab, and any custom ButtonBase consumer.
  • Clip-prone families inset the ring so a scroller/overflow ancestor can't cut it — Tab, MenuItem, ListItemButton, CardActionArea, BottomNavigationAction, and the Autocomplete option (a plain <li>).
  • Slot-drawn controls put the ring where it reads, not on the padded hit area — Checkbox/Radio (icon svg), Switch (track), Slider (thumb), Rating (active icon + empty-value label), Link (component="button").

Ships with an exported FocusVisible type, a guide at customization/focus-visible, and the /experiments/focus-ring prototype.

For Reviewers

image

Hide whitespace when review.

Color resolution lives in three places, one per theme mode. The geometry (outlineWidth/Offset/Style + inset-var wiring) is shared by resolveFocusVisible in styles/focusVisible.ts; only the default outlineColor differs:

  1. createTheme({ focusVisible: true })
    • Resolved in: createThemeNoVars.js
    • Default outlineColor: resolved hex off palette.primary.main
    • Dark mode: — (single scheme)
  2. createTheme({ focusVisible: true, colorSchemes: { light, dark } })
    • Resolved in: createThemeNoVars.js (default scheme, top-level) + createTheme.ts (per-scheme copy)
    • Default outlineColor: each scheme's own primary.main
    • Dark mode: swaps on useColorScheme mode change
  3. createTheme({ cssVariables: true, focusVisible: true })
    • Resolved in: createThemeWithVars.js
    • Default outlineColor: var(--mui-palette-primary-main)
    • Dark mode: adapts at the CSS level

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). So createTheme.ts gives each scheme its own resolved focusVisible, and that same merge swaps the outline color per mode — exactly as it does palette. 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 user boxShadow inset). wireFocusVisibleVars bakes 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 boxShadow is 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 in var() fallbacks and rgb() colors. Even with a parser it stays incomplete, since var(--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 styleOverrides handles a multi-layer ring on one component. Called out in the customization guide.

CSS variables. focusVisible is skipped from var generation (shouldSkipGeneratingVar) and kept inline: hoisting it to :root would 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.

ButtonBase gate. The root ring is gated by a private internalDisabledThemeFocusVisible prop (default false); the whole variant is a no-op when theme.focusVisible is unset. SwitchBase sets it true so 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, and applyChildrenFocusVisible (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) and createTheme.spec.ts (types); computed-style tests across ButtonBase, Tab, Checkbox, Radio, Switch, Slider, Rating, Link, Autocomplete, Fab, Button; ThemeProvider.test.tsx drives setMode('dark') and asserts the outline color follows the active scheme. Visual-regression fixtures under test/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 the Mui-focusVisible class 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-shadow var (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 custom boxShadow in theme.focusVisible replaces that slot — surface contrast is then the author's call.

RFC: #48718

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
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 30, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+5.06KB(+0.96%) 🔺+1.05KB(+0.69%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@siriwatknp siriwatknp added the RFC Request For Comments. label Jun 30, 2026
@siriwatknp siriwatknp changed the title [ButtonBase] Add theme.focusRing for a themeable keyboard focus ring [WIP][Prototype] Add theme.focusRing for a themeable keyboard focus ring Jun 30, 2026
…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
@siriwatknp siriwatknp changed the title [WIP][Prototype] Add theme.focusRing for a themeable keyboard focus ring [WIP][Prototype] Add theme.focusVisible for a themeable keyboard focus ring Jul 2, 2026
…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
@LukasTy

LukasTy commented Aug 10, 2026

Copy link
Copy Markdown
Member

Awesome work on fine tuning edge-cases. 👌
Everything checks out.

One note regarding the Switch component states VRT: on first check I got confused with same labels and different visuals.
Nitpick, but could be worth adding an extra word/label for the default colored states to differentiate from the actual "default" primary color.

Remaining

  • ButtonGroup variant="contained" hides one edge of the ring. It's not a dealbreaker, just a bit suboptimal result.
    This case essentially seems similar to the other colored surfaces, so, we could consider similar approach. However, it might be a bit too extreme.
    Have you considered applying z-index: 1 on the focused item?
  • Checkbox and Radio with a non-svg icon. Your selector reasoning is correct: *:first-of-type rings the hidden input. One selector does work: & > *:not(input):not(.MuiTouchRipple-root). I'm fine keeping it as is for the initial release, but maybe we could document this possible "gotcha"?

Nits

  • The known theme limit needs to reach the docs. A recompose plus a palette change in one call keeps the old ring color. I reproduced it. That limit lives only in a PR comment today, so a user meets a wrong-colored ring with no warning.
  • Two reviewer asks are still open in the docs. Silviu asked for a section about components that need manual changes; the page has zero mentions of the focus tint. Marija asked to show how to remove the Slider overlay; the demo still renders a plain Slider.

@siriwatknp

Copy link
Copy Markdown
Member Author

For ButtonGroup variant="contained", z-index would not help because the color (primary.main) is the same.

1786436448-83612-bg-ring-fixed-zoom

@LukasTy

LukasTy commented Aug 11, 2026

Copy link
Copy Markdown
Member

For ButtonGroup variant="contained", z-index would not help because the color (primary.main) is the same.

Indeed, for the same color outline it wouldn't, but it would resolve the issue itself if someone decides to go with customized color.
P.S. The same issue is present in the ToggleGroup.

@siriwatknp

siriwatknp commented Aug 11, 2026

Copy link
Copy Markdown
Member Author
Screen.Recording.2569-08-11.at.15.56.48.mov

Added 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
… on — ring is the only focus indicator (hover/drag overlays unchanged); caveat doc
@siriwatknp

siriwatknp commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Recent changes:

  • Added "Caveat" section to doc
  • Addressed @mnajdova feedback. The slider won't show the overlay on focus visible if the theme.focusVisible enabled (docs added).
    image
  • Addressed @silviuaavram feedback on Isn't the focus ring enough for the focus state? Some controls like Pagination or Chip seem to also include the hover state when focusing them.. All of the components no longer emits their focus visible styles if theme.focusVisible feature enabled. I think this is a good reset to produce less CSS bundle and easier to customize.
  • Make the Switch visual regression test more obvious

…eature is on — theme indicator is the only one (Chip, MenuItem, ListItemButton, AccordionSummary, PaginationItem, CardActionArea, Autocomplete, Slider caveat broadened)
…cusVisible) — root, colorSchemes, merge-arg paths; caveat doc with snippet
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Title
</Typography>
<Button color="inherit">Login</Button>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we expect this flicker on blur?

Screen.Recording.2026-08-13.at.09.00.41.mov

@siriwatknp siriwatknp Aug 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

maybe we should leave a bit of space between the focus style and the selected style? in this case, it's not clear if the focused tab is selected.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so there's no true possibility?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the true is on the ThemeOptions, in the Theme interface it should be object.

@siriwatknp siriwatknp added type: new feature Expand the scope of the product to solve a new problem. customization: theme Higher level theming customizability. package: material-ui Specific to Material UI. and removed RFC Request For Comments. labels Aug 14, 2026
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.

@silviuaavram silviuaavram left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two more questions

Press <kbd>Tab</kbd>, then use the arrow keys — the ring insets so the Tabs
scroller cannot clip it.
</Typography>
<Tabs value={0}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason we're setting a fixed value?

import Tab from '@mui/material/Tab';

const theme = createTheme({
focusVisible: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it expected for these colors to actually get inverted between tab and button?

Image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

customization: theme Higher level theming customizability. package: material-ui Specific to Material UI. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants