diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 00000000000000..b07d0168f4a554
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,35 @@
+# Context: Focus Ring Experiment
+
+Glossary for the focus-ring support experiment on Material UI. Definitions only — no implementation.
+
+## 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`.
+
+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.
diff --git a/docs/adr/0001-render-focus-ring-with-css-outline.md b/docs/adr/0001-render-focus-ring-with-css-outline.md
new file mode 100644
index 00000000000000..b7b957b55ae6d9
--- /dev/null
+++ b/docs/adr/0001-render-focus-ring-with-css-outline.md
@@ -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).
diff --git a/docs/adr/0002-focus-ring-model-and-auto-on-fallback.md b/docs/adr/0002-focus-ring-model-and-auto-on-fallback.md
new file mode 100644
index 00000000000000..da214ccb79be74
--- /dev/null
+++ b/docs/adr/0002-focus-ring-model-and-auto-on-fallback.md
@@ -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.
+- **`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.
diff --git a/docs/pages/experiments/focus-ring.tsx b/docs/pages/experiments/focus-ring.tsx
new file mode 100644
index 00000000000000..70d2fd613c13ab
--- /dev/null
+++ b/docs/pages/experiments/focus-ring.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Section({
+ title,
+ description,
+ theme,
+}: {
+ title: string;
+ description: string;
+ theme: ReturnType;
+}) {
+ return (
+
+ {title}
+
+ {description}
+
+
+
+
+
+ );
+}
+
+export default function FocusRing() {
+ return (
+
+
+ Focus ring
+
+
+ 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.
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.js b/packages/mui-material/src/ButtonBase/ButtonBase.js
index d92162eb51f276..7b8b0591da757b 100644
--- a/packages/mui-material/src/ButtonBase/ButtonBase.js
+++ b/packages/mui-material/src/ButtonBase/ButtonBase.js
@@ -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';
@@ -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 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 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.
diff --git a/packages/mui-material/src/styles/createTheme.spec.ts b/packages/mui-material/src/styles/createTheme.spec.ts
index 26891704ac8de7..d758179d3972ed 100644
--- a/packages/mui-material/src/styles/createTheme.spec.ts
+++ b/packages/mui-material/src/styles/createTheme.spec.ts
@@ -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();
@@ -317,3 +317,25 @@ const theme = createTheme();
},
});
}
+
+// focusRing theme key
+{
+ 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' },
+ });
+}
diff --git a/packages/mui-material/src/styles/createTheme.ts b/packages/mui-material/src/styles/createTheme.ts
index 68df408846dfed..57e722a215d159 100644
--- a/packages/mui-material/src/styles/createTheme.ts
+++ b/packages/mui-material/src/styles/createTheme.ts
@@ -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;
diff --git a/packages/mui-material/src/styles/createThemeNoVars.d.ts b/packages/mui-material/src/styles/createThemeNoVars.d.ts
index 68edc2cc3bc9b2..644fd92c9051e8 100644
--- a/packages/mui-material/src/styles/createThemeNoVars.d.ts
+++ b/packages/mui-material/src/styles/createThemeNoVars.d.ts
@@ -1,3 +1,4 @@
+import * as React from 'react';
import {
ThemeOptions as SystemThemeOptions,
Theme as SystemTheme,
@@ -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;
}
@@ -54,6 +65,7 @@ export interface ThemeOptions extends Omit, CssVar
| ((palette: Palette) => TypographyVariantsOptions)
| undefined;
zIndex?: ZIndexOptions | undefined;
+ focusRing?: FocusRing | false | undefined;
unstable_strictMode?: boolean | undefined;
unstable_sxConfig?: SxConfig | undefined;
modularCssLayers?: boolean | string | undefined;
@@ -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;
}
diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts
index d4082be45c8e66..150a235da69033 100644
--- a/packages/mui-material/src/styles/index.d.ts
+++ b/packages/mui-material/src/styles/index.d.ts
@@ -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';