Skip to content

[WIP][Prototype] Add theme.focusVisible for a themeable keyboard focus ring#48743

Draft
siriwatknp wants to merge 35 commits into
mui:masterfrom
siriwatknp:focus-ring-v1
Draft

[WIP][Prototype] Add theme.focusVisible for a themeable keyboard focus ring#48743
siriwatknp wants to merge 35 commits into
mui:masterfrom
siriwatknp:focus-ring-v1

Conversation

@siriwatknp

@siriwatknp siriwatknp commented Jun 30, 2026

Copy link
Copy Markdown
Member

Preview: https://deploy-preview-48743--material-ui.netlify.app/experiments/focus-ring/

Part of #48718 (RFC). Continues the experiment in #48647 (closed) as a fresh, non-breaking v1 — the old auto-on / partial-merge code is dropped.

Summary

Opt-in theme.focusVisible renders a curated keyboard focus ring on Mui-focusVisible across ButtonBase and its derived components. Non-breaking: nothing renders differently unless a theme sets focusVisible.

theme.focusVisible Behavior
undefined / false No ring (no change vs today; false reserved as a future kill-switch)
true Curated ring — 2px solid primary.main, outline-offset: 2
object (React.CSSProperties) Merged over the curated default (recolor, two-color WCAG C40, …)
createTheme({ focusVisible: true });
createTheme({ focusVisible: { outlineColor: '#9c27b0' } });             // recolor (merge)
createTheme({ focusVisible: { boxShadow: '0 0 0 4px #fff' } });         // outline + box-shadow (WCAG C40)
createTheme({ focusVisible: { outlineColor: 'transparent', boxShadow: '0 0 0 3px #1976d2' } }); // box-shadow only

CSS-variables theme: the curated color is emitted as a scheme-reactive palette var (var(--mui-palette-primary-main)), correct in dark mode.

Why outline is the default (not box-shadow)

outline survives forced-colors (where box-shadow is stripped), has zero layout shift, and follows border-radius. A box-shadow is additive over the outline via an object (two-color WCAG C40). Components that animate their own focus box-shadow — contained Button, Fab, and Slider's halo — re-assert theme.focusVisible in the same rule, so a custom box-shadow wins over their default focus shadow rather than being overridden by source order.

Per-component inset (private vars, not per-component offset)

Components rendered inside a MUI-owned overflow: hidden (Tab, MenuItem, ListItemButton, CardActionArea, BottomNavigationAction, and the Autocomplete listbox option) inset the ring by spreading a shared insetFocusRing on their root, which sets two private custom properties the curated ring reads:

  • --_focusVisible-offset: -1 — flips the outline-offset sign (the curated offset is calc(var(--_focusVisible-offset, 1) * <width>), so the width still tracks a customized outlineWidth).
  • --_focusVisible-behavior: inset — insets a user-provided box-shadow, so a two-color (C40) ring no longer clips on these components.

This replaces the earlier per-component outline-offset: calc(-1 * outlineWidth) overrides (one helper instead of six) and unifies the ButtonBase family with the non-ButtonBase carriers. A user-provided outlineOffset still replaces the calc (reaches non-clipped components; changing the inset on a clip-prone one is a styleOverrides escape hatch). Switch sets its root overflow: visible when the ring is enabled.

Customizing while keeping the inset behavior — focusVisibleVars

The private vars are emitted regardless of the cssVariables option, so a consumer customizing outlineOffset/box-shadow must reference them or the ring clips on the inset components. They're exposed as a stable public handle from @mui/material/styles:

import { createTheme, focusVisibleVars } from '@mui/material/styles';

// two-color C40 ring that insets on Tab / MenuItem / … automatically
createTheme({ focusVisible: { boxShadow: `${focusVisibleVars.behavior} 0 0 0 4px #9c27b0` } });

focusVisibleVars = { offset: 'var(--_focusVisible-offset, 1)', behavior: 'var(--_focusVisible-behavior, )' }. insetFocusRing and the curated offset calc reuse it, so the var names are single-source.

Docs

  • New page: Customization → Focus visible (/material-ui/customization/focus-ring/)
  • Experiment playground: /experiments/focus-ring

Tests

createTheme normalization (incl. CSS-vars + numeric→px, the sign-flip offset calc, and the focusVisibleVars public contract), ButtonBase ring on/off/merge/transparent, Tab/Autocomplete inset + box-shadow inset via the behavior var, Button/Fab/Slider custom box-shadow winning over the component's own focus shadow, Rating (active icon + empty-value label), Switch overflow, and the toPx util.

Status / scope

v1 is opt-in only. The automatic-on-disableRipple a11y fallback is a separate follow-up RFC. RFC sign-off (curated color, scope boundary, merge semantics) is pending in #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 🔺+2.26KB(+0.43%) 🔺+507B(+0.33%)
@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
siriwatknp added 10 commits July 9, 2026 17:47
Replace the per-component outlineOffset recompute (6 files) with a shared
insetFocusRing spread. The curated offset is now a sign-flip calc
`calc(var(--_focusVisible-offset, 1) * <width>)`; clip-prone components set
the var to -1. --_focusVisible-behavior lets a user box-shadow inset too, so a
two-color (C40) ring no longer clips on Tab/MenuItem/etc.
Both declare their own focusVisible box-shadow after the ButtonBase/root ring
(Fab's focus elevation, Slider's per-color halo), so a customized
theme.focusVisible box-shadow lost the cascade. Re-assert theme.focusVisible in
the same block so it wins. Curated (outline-only) is unaffected — no box-shadow
key, so the elevation/halo stays.
- Button contained variant re-asserts theme.focusVisible after its focus
  elevation (same fix as Fab/Slider), so a custom ring wins. disableElevation
  keeps its explicit no-shadow.
- Expose focusVisibleVars from @mui/material/styles: { offset, behavior } — the
  --_focusVisible-* handles, emitted regardless of cssVariables. Consumers
  reference them to customize outlineOffset/box-shadow while keeping the
  per-component inset behavior. insetFocusRing + the offset calc reuse them so
  the var names are single-source.
…e under cssVariables

- Checkbox/Radio spread insetFocusRing on own root (not SwitchBase → Switch stays outer); prototype buckets them inner-ring
- add focusVisible to shouldSkipGeneratingVar: hoisting to a :root var freezes the embedded --_focusVisible-offset at :root (unset→+2), clipping the inset; inline it resolves per-component
- flip all ring consumers (theme.vars || theme).focusVisible → theme.focusVisible (undefined in vars mode after skip)
- tests: Checkbox/Radio inset in both cssVariables modes; createTheme vars asserts skip+inline
…ttonBase root

The 3 SwitchBase form controls are outer-ring exceptions to the shared ButtonBase rule:
- Checkbox/Radio draw the ring on the icon svg; Switch on the track slot (more common placements)
- opt the ButtonBase root out via internalDisabledThemeFocusVisible so there is no double ring
- prototype moves Checkbox/Radio back to the outer-ring bucket (Switch already there)
- drop Radio's redundant internalDisabledThemeFocusVisible (SwitchBase already sets it) which also leaked to the DOM on root-slot override; strip it in the Switch slot-override conformance helper
- tests: ring lands on the icon svg in both cssVariables modes, root ring stays off
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RFC Request For Comments.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant