diff --git a/docs/data/material/customization/palette/HighContrastShowcase.js b/docs/data/material/customization/palette/HighContrastShowcase.js new file mode 100644 index 00000000000000..e568864df77330 --- /dev/null +++ b/docs/data/material/customization/palette/HighContrastShowcase.js @@ -0,0 +1,325 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import LinearProgress from '@mui/material/LinearProgress'; +import ListItemButton, { listItemButtonClasses } from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import MenuItem, { menuItemClasses } from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import NativeSelect from '@mui/material/NativeSelect'; +import Radio from '@mui/material/Radio'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import InboxIcon from '@mui/icons-material/Inbox'; +import StarIcon from '@mui/icons-material/Star'; +import { + ThemeProvider, + createTheme, + enhanceHighContrast, + useColorScheme, +} from '@mui/material/styles'; + +const autocompleteOptions = [ + { label: 'Disabled option', disabled: true }, + { label: 'Selected option' }, + { label: 'Focused option' }, +]; + +function Section(props) { + const { title, children } = props; + + return ( + + + {title} + + {children} + + ); +} + +Section.propTypes = { + children: PropTypes.node, + title: PropTypes.string.isRequired, +}; + +export default function HighContrastShowcase() { + const [enhanced, setEnhanced] = React.useState(false); + const { mode, systemMode } = useColorScheme(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { + noSsr: true, + }); + const forcedColorsActive = useMediaQuery('(forced-colors: active)', { + noSsr: true, + }); + const docsMode = mode === 'system' ? systemMode : mode; + let paletteMode = prefersDarkMode ? 'dark' : 'light'; + + if (docsMode === 'dark' || docsMode === 'light') { + paletteMode = docsMode; + } + + const baseTheme = React.useMemo( + () => + createTheme({ + palette: { + mode: paletteMode, + }, + }), + [paletteMode], + ); + + const theme = React.useMemo(() => { + return enhanced ? enhanceHighContrast(baseTheme) : baseTheme; + }, [baseTheme, enhanced]); + + return ( + + + +
+ + Forced colors component showcase + + + In Chrome, open DevTools > More tools > Rendering, then set + forced-colors to active and prefers-color-scheme to + light or dark. Then toggle the enhancer to compare the fix. + +
+ + setEnhanced(event.target.checked)} + /> + } + label="Enhance high contrast" + /> + + Forced colors: {forcedColorsActive ? 'active' : 'inactive'} + + + Color scheme: {paletteMode} + + +
+ + +
+ + + + + + + Native select + + + + + + Disabled icon and label + + +
+ +
+ + + Selected item + + Focus-visible item + + + Selected + focus + + Disabled item + + + + + + + + + + + + + + + + + +
+ +
+ + } + label="Checkbox" + /> + } + label="Radio" + /> + } + label="Switch" + /> + + + + Disabled slider + + + + + Selected + Other + +
+ +
+ +
+ + Linear progress buffer + + +
+ + + + + + + }> + Disabled accordion + + + Disabled summary opacity should stay readable in forced colors. + + +
+
+ +
+ Boolean(option.disabled)} + renderInput={(params) => ( + + )} + sx={{ maxWidth: 320 }} + /> + + Open the menu to compare the fixed disabled and selected option styles. + +
+ +
+ + + + + + + Use keyboard navigation to compare the visible focus outline. + +
+
+
+
+ ); +} diff --git a/docs/data/material/customization/palette/HighContrastShowcase.tsx b/docs/data/material/customization/palette/HighContrastShowcase.tsx new file mode 100644 index 00000000000000..d56d68802a3069 --- /dev/null +++ b/docs/data/material/customization/palette/HighContrastShowcase.tsx @@ -0,0 +1,319 @@ +import * as React from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import LinearProgress from '@mui/material/LinearProgress'; +import ListItemButton, { listItemButtonClasses } from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import MenuItem, { menuItemClasses } from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import NativeSelect from '@mui/material/NativeSelect'; +import Radio from '@mui/material/Radio'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import InboxIcon from '@mui/icons-material/Inbox'; +import StarIcon from '@mui/icons-material/Star'; +import { + ThemeProvider, + createTheme, + enhanceHighContrast, + useColorScheme, +} from '@mui/material/styles'; + +const autocompleteOptions = [ + { label: 'Disabled option', disabled: true }, + { label: 'Selected option' }, + { label: 'Focused option' }, +]; + +function Section(props: { title: string; children: React.ReactNode }) { + const { title, children } = props; + + return ( + + + {title} + + {children} + + ); +} + +export default function HighContrastShowcase() { + const [enhanced, setEnhanced] = React.useState(false); + const { mode, systemMode } = useColorScheme(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { + noSsr: true, + }); + const forcedColorsActive = useMediaQuery('(forced-colors: active)', { + noSsr: true, + }); + const docsMode = mode === 'system' ? systemMode : mode; + let paletteMode: 'light' | 'dark' = prefersDarkMode ? 'dark' : 'light'; + + if (docsMode === 'dark' || docsMode === 'light') { + paletteMode = docsMode; + } + + const baseTheme = React.useMemo( + () => + createTheme({ + palette: { + mode: paletteMode, + }, + }), + [paletteMode], + ); + + const theme = React.useMemo(() => { + return enhanced ? enhanceHighContrast(baseTheme) : baseTheme; + }, [baseTheme, enhanced]); + + return ( + + + +
+ + Forced colors component showcase + + + In Chrome, open DevTools > More tools > Rendering, then set + forced-colors to active and prefers-color-scheme to + light or dark. Then toggle the enhancer to compare the fix. + +
+ + setEnhanced(event.target.checked)} + /> + } + label="Enhance high contrast" + /> + + Forced colors: {forcedColorsActive ? 'active' : 'inactive'} + + + Color scheme: {paletteMode} + + +
+ + +
+ + + + + + + Native select + + + + + + Disabled icon and label + + +
+ +
+ + + Selected item + + Focus-visible item + + + Selected + focus + + Disabled item + + + + + + + + + + + + + + + + + +
+ +
+ + } + label="Checkbox" + /> + } + label="Radio" + /> + } + label="Switch" + /> + + + + Disabled slider + + + + + Selected + Other + +
+ +
+ +
+ + Linear progress buffer + + +
+ + + + + + + }> + Disabled accordion + + + Disabled summary opacity should stay readable in forced colors. + + +
+
+ +
+ Boolean(option.disabled)} + renderInput={(params) => ( + + )} + sx={{ maxWidth: 320 }} + /> + + Open the menu to compare the fixed disabled and selected option styles. + +
+ +
+ + + + + + + Use keyboard navigation to compare the visible focus outline. + +
+
+
+
+ ); +} diff --git a/docs/data/material/customization/palette/palette.md b/docs/data/material/customization/palette/palette.md index 41c355636b06c9..cdb53d22b82a94 100644 --- a/docs/data/material/customization/palette/palette.md +++ b/docs/data/material/customization/palette/palette.md @@ -324,6 +324,12 @@ import { createTheme, enhanceHighContrast } from '@mui/material/styles'; const theme = enhanceHighContrast(createTheme()); ``` +Use the following demo to compare the affected component states with and without the theme enhancer. +In Chrome, open DevTools > More tools > Rendering, then set `forced-colors` to `active` and `prefers-color-scheme` to `light` or `dark`. +You can also use your operating system's high contrast or contrast theme settings. + +{{"demo": "HighContrastShowcase.js", "defaultCodeOpen": false}} + By default, it uses the [CSS system color keywords](https://www.w3.org/TR/css-color-4/#css-system-colors) (`Highlight`, `HighlightText`, `ButtonBorder`, etc.). You can override individual tokens with other [system colors](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/system-color) to match your brand or design requirements. When overriding paired tokens, choose values that the browser guarantees to contrast with each other — for example `SelectedItem` is always paired with `SelectedItemText`, and `Highlight` with `HighlightText`: diff --git a/docs/lib/sourcing.ts b/docs/lib/sourcing.ts index 5b53918949c8c4..8b51baed6ecf79 100644 --- a/docs/lib/sourcing.ts +++ b/docs/lib/sourcing.ts @@ -68,6 +68,8 @@ const ALLOWED_TAGS = [ 'Joy UI', 'MUI X', 'Toolpad', + // Subject tags + 'Accessibility', ]; export const getAllBlogPosts = () => { diff --git a/docs/pages/blog/designing-for-forced-colors-in-material-ui.js b/docs/pages/blog/designing-for-forced-colors-in-material-ui.js new file mode 100644 index 00000000000000..729581db054e0e --- /dev/null +++ b/docs/pages/blog/designing-for-forced-colors-in-material-ui.js @@ -0,0 +1,6 @@ +import TopLayoutBlog from 'docs/src/modules/components/TopLayoutBlog'; +import * as pageProps from './designing-for-forced-colors-in-material-ui.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/docs/pages/blog/designing-for-forced-colors-in-material-ui.md b/docs/pages/blog/designing-for-forced-colors-in-material-ui.md new file mode 100644 index 00000000000000..1254af31a21a0b --- /dev/null +++ b/docs/pages/blog/designing-for-forced-colors-in-material-ui.md @@ -0,0 +1,248 @@ +--- +title: Windows High Contrast mode support in Material UI +description: Learn how Windows High Contrast maps to CSS system colors and why Material UI uses an opt-in theme enhancer for forced-colors support. +date: 2026-07-01T00:00:00.000Z +authors: ['silviuaavram'] +tags: ['Material UI', 'Accessibility', 'Guide'] +manualCard: false +--- + +This post walks through how Windows High Contrast mode maps to the CSS `forced-colors` model, what gaps we found in Material UI components, which implementation paths we considered, and why we chose an opt-in theme enhancer. + +## Windows High Contrast mode + +As described in the [Microsoft blog](https://blogs.windows.com/msedgedev/2020/09/17/styling-for-windows-high-contrast-with-new-standards-for-forced-colors/), Windows High Contrast mode is an accessibility feature designed to increase text legibility and improve readability. In modern Windows versions, users configure this through High Contrast or Contrast themes, depending on the version. + +The user can select a limited palette of system colors that the operating system applies across the UI. Web developers need to make sure their apps integrate with those colors instead of fighting them. + +## CSS System Colors + +When Windows High Contrast mode is turned on, browsers expose it to web content through the `forced-colors` media feature. In this mode, complex color gradients, shadows, and some background images are removed or simplified. Text, borders, backgrounds, controls, and selections are mapped to a small set of colors that are expected to contrast with each other. + +These overrides are expressed with [CSS system colors](https://www.w3.org/TR/css-color-4/#css-system-colors). They have browser and operating system defaults, but they can also reflect the user's chosen contrast theme. For example, `SelectedItem` and `SelectedItemText` represent the background and text colors for selected items. `Canvas` and `CanvasText` represent the page background and regular text. `GrayText` represents disabled content. + +Browsers force a defined set of color-related properties at paint time. That detail matters: the CSS cascade can still contain author colors, but the browser paints a forced system color instead. System color keywords give authors a way to make the rest of the component fit that same palette. + +## CSS Features + +CSS provides a media query that detects whenever forced colors are active: + +### @media (forced-colors: $value) + +```css +@media (forced-colors: active) { + .highlighted-menu-item { + color: HighlightText; + background-color: Highlight; + } +} +``` + +Here, we're using a pair of system colors to style a menu item's selected state. Native HTML elements get a lot of this behavior from the browser for free. Custom-built components do not always have that same semantic mapping, so component libraries need to fill the gap. + +### forced-color-adjust: $value + +Another useful CSS feature is the [forced-color-adjust](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/forced-color-adjust) property. It can opt an element out of automatic forced-color adjustment. + +This is a sharp tool. When `forced-color-adjust: none` is used, the browser stops protecting that element's colors, so the author becomes responsible for the contrast of the element and its children. In Material UI, that made sense for selected and active states where we wanted to pair colors like `SelectedItem` with `SelectedItemText` or `Highlight` with `HighlightText`. + +## Chrome DevTools Emulation + +Some useful Chrome DevTools features for testing forced colors are found in the Rendering tab: + +- `forced-colors`: when set to `active`, it emulates forced colors inside the browser. +- `prefers-color-scheme`: forces either `light` or `dark`, which is useful because forced colors should work in both color schemes. + +## Forced Colors with Material UI Components + +### The problem + +Material UI components generally behaved well in forced colors, but there were some corner cases that we missed. + +The fixes fall into a few groups: + +- Form states: `FilledInput`, `Input`, `OutlinedInput`, `FormLabel`, `FormHelperText`, `FormControlLabel`, placeholders, and disabled native select icons. +- Selection and navigation states: `Autocomplete`, `MenuItem`, `ListItemButton`, and `ListItemIcon`. +- Disabled controls: `Checkbox`, `Radio`, `Slider`, and `Switch`. +- Feedback, focus, and overlays: `LinearProgress`, `ButtonBase`, `Tooltip`, `AccordionSummary`, and `ToggleButton`. + +If these cases are not visible in forced colors, users can lose access to state: disabled, selected, focused, invalid, or in progress. The tricky part was not finding a way to style them. Material UI has many styling entry points. The real challenge was choosing the right level of the system. + +The following demo shows these affected states side by side. +In Chrome, open DevTools > More tools > Rendering, then set `forced-colors` to `active` and `prefers-color-scheme` to `light` or `dark`. +You can also use your operating system's high contrast or contrast theme settings. + +{{"demo": "../../data/material/customization/palette/HighContrastShowcase.js", "defaultCodeOpen": false}} + +### The options + +We considered the options along three dimensions: scope, user effort, and update risk. + +#### The `sx` prop + +```jsx + +``` + +This is the quickest way to patch a local styling issue. It's great for app-specific edge cases, but it would push the responsibility to every Material UI user and every component instance. That is not enough for a library-level accessibility fix. + +#### The `styled()` wrappers + +```jsx +const AppButton = styled(Button)(() => ({ + '@media (forced-colors: active)': { + borderColor: 'ButtonBorder', + }, + '&:focus-visible': { + '@media (forced-colors: active)': { + outline: '5px auto Highlight', + }, + }, +})); +``` + +This is a step up from `sx` because the fix can be reused between component instances. But it still creates a userland wrapper that app teams need to adopt everywhere. It also fragments the fix across applications instead of solving it in Material UI. + +#### Global CSS / `GlobalStyles` + +```jsx + +``` + +Global CSS can cover a broad surface area, but it is selector-based. That makes it more brittle around class names, slots, specificity, and composition. It can be useful as a temporary app-level patch, but it is not the most maintainable way for Material UI to encode component-aware state fixes. + +#### Theme `components.styleOverrides` + +```jsx +const theme = createTheme({ + components: { + MuiMenuItem: { + styleOverrides: { + root: { + [`&.${menuItemClasses.selected}`]: { + '@media (forced-colors: active)': { + forcedColorAdjust: 'none', + color: 'SelectedItemText', + backgroundColor: 'SelectedItem', + }, + }, + [`&.${menuItemClasses.focusVisible}, &:hover`]: { + '@media (forced-colors: active)': { + forcedColorAdjust: 'none', + color: 'HighlightText', + backgroundColor: 'Highlight', + }, + }, + }, + }, + }, + }, +}); +``` + +For application teams, this is probably the best centralized option. It is component-aware, lives in the theme, and avoids repeated `sx` or wrapper code. It also matches the mechanism Material UI already uses for component customization. + +#### Built-in styles inside components + +From the library side, the most direct option would be to put the forced-colors styles inside each component. + +```js +const ButtonRoot = styled(ButtonBase)(({ theme }) => ({ + // regular Button styles... + + '@media (forced-colors: active)': { + '&:focus-visible': { + outline: '5px auto Highlight', + }, + + [`&.${buttonClasses.disabled}`]: { + color: 'GrayText', + opacity: 1, + }, + }, +})); +``` + +This would make the fix automatic, which is attractive for accessibility. But there is a trade-off: some users have already patched these states in their own themes. Shipping built-in styles could change their UI on upgrade, even if the change is well intentioned. We wanted to provide the fix from our side without surprising existing applications. + +#### Theme Enhancer + +To avoid breaking changes and still deliver a Material UI-owned fix, we considered a fully opt-in theme enhancer. The proposed API was intentionally small: + +```js +const userTheme = createTheme({ + // user theme options +}); +const enhancedTheme = enhance(userTheme, { + // system color value overrides +}); +``` + +This gives Material UI a central place to provide the required `styleOverrides`, while giving users explicit control over when those overrides are added. + +### The solution + +```js +import { createTheme, enhanceHighContrast } from '@mui/material/styles'; + +const theme = enhanceHighContrast(createTheme(), { + activeBackground: 'SelectedItem', + activeText: 'SelectedItemText', +}); +``` + +Finally, we decided to go ahead with the [theme enhancer](https://mui.com/material-ui/customization/palette/#windows-high-contrast-mode). The [enhanceHighContrast](https://mui.com/material-ui/customization/theming/#enhancehighcontrast-theme-tokens-theme) function takes the user's theme as its first argument, accepts optional system color token overrides, and layers forced-colors fixes on top of the theme. + +This approach requires minimal user code, remains completely opt-in, preserves existing `components.styleOverrides`, and keeps the forced-colors behavior centralized in the theme. It also gives teams a gradual path: they can adopt the enhancer, compare it with their existing customizations, and override individual system color tokens when needed. + +For example, here's the theme overrides fix for the FormLabel's error and disabled states, using tokens: + +```js +MuiFormLabel: { + ...c?.MuiFormLabel, + styleOverrides: { + ...c?.MuiFormLabel?.styleOverrides, + root: [ + c?.MuiFormLabel?.styleOverrides?.root, + { + [`&.${formLabelClasses.error}`]: { + [HCM]: { + color: hcTokens.error, + }, + }, + [`&.${formLabelClasses.disabled}`]: { + [HCM]: { + color: hcTokens.disabled, + }, + }, + }, + ], + }, +}, +``` + +## Takeaways + +Forced colors are not just another dark mode. They are a user-controlled accessibility mode where the browser and operating system intentionally reduce the color palette. Supporting that mode well means respecting system colors, using `forced-color-adjust` carefully, and making component states visible without asking every app team to rediscover the same fixes. + +The `enhanceHighContrast` feature is part of Material UI's effort to improve accessibility and encourage accessible UX in web apps. We look forward to feedback as we continue improving support for Windows High Contrast mode and forced colors. diff --git a/docs/src/modules/components/TopLayoutBlog.js b/docs/src/modules/components/TopLayoutBlog.js index 464fb50109e838..49e4f75a17de8a 100644 --- a/docs/src/modules/components/TopLayoutBlog.js +++ b/docs/src/modules/components/TopLayoutBlog.js @@ -169,6 +169,11 @@ export const authors = { avatar: 'https://avatars.githubusercontent.com/u/12778398', github: 'bernardobelchior', }, + silviuaavram: { + name: 'Silviu Alexandru Avram', + avatar: 'https://avatars.githubusercontent.com/u/11275392', + github: 'silviuaavram', + }, }; const classes = {