Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Context: Focus Ring Experiment

Glossary for the focus-ring support experiment on Material UI. Definitions only — no implementation.

Check failure on line 3 in CONTEXT.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.MuiBrandName] Use a non-breaking space (option+space on Mac, Alt+0160 on Windows or AltGr+Space on Linux, instead of space) for brand name ('Material UI' instead of 'Material UI') Raw Output: {"message": "[MUI.MuiBrandName] Use a non-breaking space (option+space on Mac, Alt+0160 on Windows or AltGr+Space on Linux, instead of space) for brand name ('Material UI' instead of 'Material UI')", "location": {"path": "CONTEXT.md", "range": {"start": {"line": 3, "column": 51}}}, "severity": "ERROR"}

## Glossary

### Focus ring

A visible focus indicator rendered when a focusable component is keyboard-focused (`Mui-focusVisible`). Distinct from the **ripple**, which is separate, ripple-based feedback.

There are **two independent features** that each render a focus ring. They have separate triggers but share **one renderer**: the a11y fallback (feature 1) is the themed ring (feature 2) with default values. When both fire, **feature 2 wins** (explicit config beats defaults). Experiment scope: **ButtonBase and its derived components**.

### A11y focus-ring fallback (feature 1)

Automatic ring shown when the ripple is disabled. The trigger is the **resolved** `disableRipple` value (`ownerState.disableRipple === true`), regardless of source — theme `defaultProps` (any level) or a per-instance prop. It does **not** key off `focusRipple` (a button that never had a focus pulse is not auto-ringed). A defensive accessibility safety-net: when a component's keyboard-focus indicator was the ripple pulse, disabling the ripple leaves nothing, so MUI supplies a ring. Fires regardless of whether the focus-ring theme is configured. With no theme config, its color defaults to `theme.palette.primary.main`.

Check warning on line 15 in CONTEXT.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.NoCompanyName] We avoid referencing the company name 'MUI supplies'. Instead you can reference a product or the team. Raw Output: {"message": "[MUI.NoCompanyName] We avoid referencing the company name 'MUI supplies'. Instead you can reference a product or the team.", "location": {"path": "CONTEXT.md", "range": {"start": {"line": 15, "column": 466}}}, "severity": "WARNING"}

For components whose focus indicator is a background tint (`palette.action.focus`) rather than the ripple, the fallback ring is **additive** — ring on top of the existing tint.

### Focus-ring theme (feature 2)

An opt-in styling feature: configuring the focus-ring theme key makes a focus ring appear. A deliberate design-system choice. Works **regardless of ripple state** — the ring appears even while the ripple is enabled (ring and ripple coexist on focus).

The key is `theme.focusRing`, which is **tri-state**:

- **`undefined`** (default) — feature 2 off (no ring while ripple is on); feature 1 still active (a11y fallback rings on `disableRipple`, using the default values below).
- **object** — feature 2 on: ring appears regardless of ripple state. Object *presence* is the opt-in (not any specific field). Feature 1 also adopts these values.
- **`false`** — hard kill-switch: no ring ever, **including** the a11y fallback. An explicit, discoverable way to opt out of the safety-net.

The object is typed as the outline subset of `React.CSSProperties` — `outlineColor`, `outlineWidth`, `outlineOffset`, `outlineStyle` — and is **spread directly** onto the `Mui-focusVisible` styles over the defaults (`outlineStyle: 'solid'`, `outlineColor: palette.primary.main`, `outlineWidth: 2`, `outlineOffset: 2`). No field-name mapping. Numbers mean px.

The ring is baked into **ButtonBase core** (the safety-net only works as default behavior). Customization beyond the theme key is free via `MuiButtonBase`/`MuiButton…` `styleOverrides`, which apply after the root and win.

### Ripple

The existing `TouchRipple` feedback on `ButtonBase`. Today it is the only focus indicator on button-like components, and only when `focusRipple` is enabled and `disableRipple` is not set.
19 changes: 19 additions & 0 deletions docs/adr/0001-render-focus-ring-with-css-outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
status: accepted
---

# Render the focus ring with CSS `outline`

The focus-ring experiment draws its ring with `outline` + `outline-offset`, not `box-shadow` or an `::after` pseudo-element.

Decisive reasons: (1) the ring is an **accessibility** feature, and in Windows High Contrast / `forced-colors` mode the OS discards `box-shadow` and pseudo-element backgrounds but preserves `outline` — the indicator must not vanish in the highest-need a11y mode; (2) `Button` and `Fab` already animate `box-shadow: shadows[6]` on `Mui-focusVisible` (Button.js:133, Fab.js:81), so a box-shadow ring would collide with that elevation, whereas `outline` sits orthogonally. `outline` also has zero layout shift, follows `border-radius` in all evergreen browsers, and maps 1:1 onto the theme key (`color`→`outline-color`, `width`→`outline-width`, `offset`→`outline-offset`).

## Considered Options

- **`box-shadow` spread** — rejected: stripped in forced-colors mode; collides with existing elevation box-shadows on Button/Fab.
- **`::after` pseudo-element** — rejected: stripped in forced-colors mode; requires `position: relative` on the host and bespoke geometry.

## Consequences

- Overrides the existing `outline: 0` on `ButtonBase` (ButtonBase.js:47).
- An ancestor `overflow: hidden` can clip the outline (true of all three options; accepted).
24 changes: 24 additions & 0 deletions docs/adr/0002-focus-ring-model-and-auto-on-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
status: accepted
---

# Focus-ring model: auto-on a11y fallback + tri-state `theme.focusRing`

The focus ring is exposed as **two features sharing one renderer**, controlled by `theme.focusRing` plus the resolved `disableRipple`:

- **Feature 1 — a11y fallback (on by default).** When `disableRipple` is true (any source: theme `defaultProps` or per-instance), ButtonBase renders a focus ring. This is a **default behavior change**: today `disableRipple` removes the only keyboard-focus indicator for ripple-pulse components (Button, IconButton, Fab, Tab, …), failing WCAG 2.4.7. The fallback uses default ring values (`outlineColor: palette.primary.main`, `outlineWidth: 2`, `outlineOffset: 2`, `outlineStyle: 'solid'`). It does not key off `focusRipple`.
- **Feature 2 — opt-in theme.** `theme.focusRing` is tri-state: `undefined` ⇒ feature 1 only; an **object** ⇒ ring appears regardless of ripple state (object presence is the opt-in, fields fill from defaults); **`false`** ⇒ hard kill-switch, no ring at all including the fallback. The object is typed as the outline subset of `React.CSSProperties` (`outlineColor`/`outlineWidth`/`outlineOffset`/`outlineStyle`) and is spread directly onto the `Mui-focusVisible` styles — no field-name mapping.

When both apply, feature 2's values win (the fallback is just the themed ring with defaults).

## Why this shape

- **Auto-on, not opt-in, for feature 1:** a safety-net that must be explicitly enabled would not be a safety-net — most teams that set `disableRipple` would never know they removed their focus indicator.
- **Object-presence opt-in for feature 2:** lets us distinguish "user wants rings (even with ripple on)" from "user inherited defaults," which a always-present defaulted key could not.

Check warning on line 17 in docs/adr/0002-focus-ring-model-and-auto-on-fallback.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [Google.We] Try to avoid using first-person plural like 'us'. Raw Output: {"message": "[Google.We] Try to avoid using first-person plural like 'us'.", "location": {"path": "docs/adr/0002-focus-ring-model-and-auto-on-fallback.md", "range": {"start": {"line": 17, "column": 50}}}, "severity": "WARNING"}
- **`false` kill-switch:** an honest, discoverable escape for teams with their own focus design who want neither ripple nor the auto ring. (Per-style overrides via `styleOverrides` remain available for "different ring, not no ring.")

## Consequences

- Shipping this changes default rendering for any app that sets `disableRipple` — they gain a visible outline on keyboard focus. Intended, but a visual change to call out in release notes.
- Components whose focus indicator is a background tint (`palette.action.focus`: MenuItem, Chip, ListItemButton, …) get the ring **additively** on top of the tint when `disableRipple` is set app-wide.
- Scope is ButtonBase and its derived components; other focusable components (TextField, Checkbox, …) are out of scope for the experiment.
103 changes: 103 additions & 0 deletions docs/pages/experiments/focus-ring.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
import DeleteIcon from '@mui/icons-material/Delete';
import { createTheme, ThemeProvider } from '@mui/material/styles';

// 1. A11y fallback: ripple disabled via defaultProps -> ring appears automatically,
// no `focusRing` configured (default color = palette.text.primary).
const fallbackTheme = createTheme({
components: {
MuiButtonBase: { defaultProps: { disableRipple: true } },
},
});

// 2. Opt-in: `theme.focusRing` set -> ring on every keyboard focus, even with ripple on.
const optInTheme = createTheme({
focusRing: { outlineColor: '#9c27b0', outlineWidth: 2, outlineOffset: 3 },
});

// 3. Kill-switch: `false` removes the ring entirely, including the disableRipple fallback.
const offTheme = createTheme({
focusRing: false,
components: {
MuiButtonBase: { defaultProps: { disableRipple: true } },
},
});

function Sample() {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Button variant="contained">Contained</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="text">Text</Button>
<IconButton aria-label="delete">
<DeleteIcon />
</IconButton>
<MenuList sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<MenuItem>Profile</MenuItem>
<MenuItem>Settings</MenuItem>
</MenuList>
</Stack>
);
}

function Section({
title,
description,
theme,
}: {
title: string;
description: string;
theme: ReturnType<typeof createTheme>;
}) {
return (
<section>
<Typography variant="h6">{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
<ThemeProvider theme={theme}>
<Sample />
</ThemeProvider>
</section>
);
}

export default function FocusRing() {
return (
<Box sx={{ p: 6, maxWidth: 900 }}>
<Typography variant="h4" gutterBottom>
Focus ring
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Tab through each group with the keyboard (the ring is keyboard-only — mouse clicks never
show it). In Windows High Contrast / forced-colors mode the outline is preserved, unlike the
ripple or background-tint indicators.
</Typography>
<Stack spacing={5}>
<Section
title="1. Accessibility fallback (automatic)"
description="disableRipple via MuiButtonBase.defaultProps and no focusRing config. The ring replaces the missing ripple focus indicator, defaulting to palette.primary.main."
theme={fallbackTheme}
/>
<Section
title="2. Opt-in theme (ripple stays on)"
description="theme.focusRing = { outlineColor: '#9c27b0', outlineWidth: 2, outlineOffset: 3 }. The ring shows on every keyboard focus regardless of ripple — ring and ripple coexist."
theme={optInTheme}
/>
<Section
title="3. Kill-switch"
description="theme.focusRing = false with disableRipple set. No ring at all, including the accessibility fallback — an explicit opt-out."
theme={offTheme}
/>
</Stack>
</Box>
);
}
85 changes: 52 additions & 33 deletions packages/mui-material/src/ButtonBase/ButtonBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef';
import composeClasses from '@mui/utils/composeClasses';
import isFocusVisible from '@mui/utils/isFocusVisible';
import { styled } from '../zero-styled';
import memoTheme from '../utils/memoTheme';
import { useDefaultProps } from '../DefaultPropsProvider';
import useForkRef from '../utils/useForkRef';
import useEventCallback from '../utils/useEventCallback';
Expand Down Expand Up @@ -35,39 +36,57 @@ const useUtilityClasses = (ownerState) => {
export const ButtonBaseRoot = styled('button', {
name: 'MuiButtonBase',
slot: 'Root',
})({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxSizing: 'border-box',
WebkitTapHighlightColor: 'transparent',
backgroundColor: 'transparent', // Reset default value
// We disable the focus ring for mouse, touch and keyboard users.
outline: 0,
border: 0,
margin: 0, // Remove the margin in Safari
borderRadius: 0,
padding: 0, // Remove the padding in Firefox
cursor: 'pointer',
userSelect: 'none',
verticalAlign: 'middle',
MozAppearance: 'none', // Reset
WebkitAppearance: 'none', // Reset
textDecoration: 'none',
// So we take precedent over the style of a native <a /> element.
color: 'inherit',
'&::-moz-focus-inner': {
borderStyle: 'none', // Remove Firefox dotted outline.
},
[`&.${buttonBaseClasses.disabled}`]: {
pointerEvents: 'none', // Disable link interactions
cursor: 'default',
},
'@media print': {
colorAdjust: 'exact',
},
});
})(
memoTheme(({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxSizing: 'border-box',
WebkitTapHighlightColor: 'transparent',
backgroundColor: 'transparent', // Reset default value

// We disable the focus ring for mouse, touch and keyboard users.
outline: 0,
border: 0,
margin: 0, // Remove the margin in Safari
borderRadius: 0,
padding: 0, // Remove the padding in Firefox
cursor: 'pointer',
userSelect: 'none',
verticalAlign: 'middle',
MozAppearance: 'none', // Reset
WebkitAppearance: 'none', // Reset
textDecoration: 'none',
// So we take precedent over the style of a native <a /> element.
color: 'inherit',
'&::-moz-focus-inner': {
borderStyle: 'none', // Remove Firefox dotted outline.
},
[`&.${buttonBaseClasses.disabled}`]: {
pointerEvents: 'none', // Disable link interactions
cursor: 'default',
},
'@media print': {
colorAdjust: 'exact',
},
variants: [
{
props: ({ ownerState }) =>
(ownerState.disableRipple && theme.focusRing !== false) || !!theme.focusRing,
style: {
[`&.${buttonBaseClasses.focusVisible}`]: {
outlineStyle: 'solid',
outlineColor: (theme.vars || theme).palette.primary.main,
outlineWidth: 2,
outlineOffset: 2,
...theme.focusRing,
},
},
},
],
})),
);

/**
* `ButtonBase` contains as few styles as possible.
Expand Down
24 changes: 23 additions & 1 deletion packages/mui-material/src/styles/createTheme.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
import { createTheme, ThemeOptions, FocusRing } from '@mui/material/styles';
import { buttonClasses } from '@mui/material/Button';

const theme = createTheme();
Expand Down Expand Up @@ -317,3 +317,25 @@ const theme = createTheme();
},
});
}

// focusRing theme key

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 "focus ring" the best name for this? Alternatives could be "focus outline" or "focus indicator"

{
createTheme({ focusRing: { outlineColor: 'red', outlineWidth: 2, outlineOffset: 3 } });
createTheme({ focusRing: { outlineColor: 'red' } });
createTheme({ focusRing: { outlineStyle: 'dashed' } });
createTheme({ focusRing: false });
createTheme({ focusRing: undefined });

const focusRing: FocusRing | false | undefined = theme.focusRing;
createTheme({ focusRing });

createTheme({
// @ts-expect-error outlineWidth must be a number or string
focusRing: { outlineWidth: true },
});

createTheme({
// @ts-expect-error color is not a valid outline property
focusRing: { color: 'red' },
});
}
2 changes: 1 addition & 1 deletion packages/mui-material/src/styles/createTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import createThemeNoVars, {
ThemeOptions as ThemeNoVarsOptions,
} from './createThemeNoVars';

export type { Theme, CssThemeVariables } from './createThemeNoVars';
export type { Theme, CssThemeVariables, FocusRing } from './createThemeNoVars';

type CssVarsOptions = CssThemeVariables extends {
enabled: true;
Expand Down
13 changes: 13 additions & 0 deletions packages/mui-material/src/styles/createThemeNoVars.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import {
ThemeOptions as SystemThemeOptions,
Theme as SystemTheme,
Expand Down Expand Up @@ -35,6 +36,16 @@ import {
*/
export interface CssThemeVariables {}

/**
* Outline properties of the keyboard focus ring, spread onto the `Mui-focusVisible` state.
* Omitted properties fall back to the defaults: `solid` style, `palette.primary.main` color,
* `2px` width, `2px` offset.
*/
export type FocusRing = Pick<
React.CSSProperties,
'outlineColor' | 'outlineOffset' | 'outlineStyle' | 'outlineWidth'
>;

type CssVarsOptions = CssThemeVariables extends {
enabled: true;
}
Expand All @@ -54,6 +65,7 @@ export interface ThemeOptions extends Omit<SystemThemeOptions, 'zIndex'>, CssVar
| ((palette: Palette) => TypographyVariantsOptions)
| undefined;
zIndex?: ZIndexOptions | undefined;
focusRing?: FocusRing | false | undefined;
unstable_strictMode?: boolean | undefined;
unstable_sxConfig?: SxConfig | undefined;
modularCssLayers?: boolean | string | undefined;
Expand All @@ -68,6 +80,7 @@ export interface BaseTheme extends SystemTheme {
transitions: Transitions;
typography: TypographyVariants;
zIndex: ZIndex;
focusRing?: FocusRing | false | undefined;
unstable_strictMode?: boolean | undefined;
applyStyles: ApplyStyles<SupportedColorScheme>;
}
Expand Down
1 change: 1 addition & 0 deletions packages/mui-material/src/styles/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
ThemeOptions,
Theme,
CssThemeVariables,
FocusRing,
} from './createTheme';
export { default as enhanceHighContrast, HighContrastTokens } from './enhanceHighContrast';
export { default as adaptV4Theme, DeprecatedThemeOptions } from './adaptV4Theme';
Expand Down
Loading