@mui/material
Version:
Material UI is an open-source React component library that implements Google's Material Design. It's comprehensive and can be used in production out of the box.
962 lines (880 loc) • 33.2 kB
Markdown
# Focus visible
Apply an outline or inset keyboard focus across Material UI components.
Starting from v9.4, Material UI provides built-in support for visual keyboard focus indicator through CSS. The demos on this page opt out of the ripple to show only the focus visible indicator.
## Usage
Set `focusVisible: true` on the theme to render a default focus indicator on every [ButtonBase](/material-ui/api/button-base/)-derived component when it receives **keyboard** focus:
```js
import { createTheme } from '@mui/material/styles';
const theme = createTheme({ focusVisible: true });
```
The default focus indicator is a two-pixel solid outline with `palette.primary.main` color, offset by two pixels:
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import DeleteIcon from '@mui/icons-material/Delete';
const theme = createTheme({
focusVisible: true,
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
export default function FocusVisibleDefault() {
return (
<ThemeProvider theme={theme}>
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Press <kbd>Tab</kbd> to move keyboard focus — the ring appears on focus.
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Button variant="outlined">Outlined</Button>
<IconButton aria-label="delete">
<DeleteIcon />
</IconButton>
</Stack>
</Stack>
</ThemeProvider>
);
}
```
:::info
Why an outline
CSS `outline` is the most common indicator found in the web standard that works in most environment including high-contrast color mode.
:::
### Inner focus indicator
Some components, for example `Tab`, render the focus indicator from the inside to avoid `overflow`-clipped container or overlapping with other elements.
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
const theme = createTheme({
focusVisible: true,
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
export default function FocusVisibleInner() {
const [value, setValue] = React.useState(0);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
return (
<ThemeProvider theme={theme}>
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Press <kbd>Tab</kbd>, then use the arrow keys — the ring insets so the Tabs
scroller cannot clip it.
</Typography>
<Tabs value={value} onChange={handleChange}>
<Tab label="One" />
<Tab label="Two" />
<Tab label="Three" />
</Tabs>
</Stack>
</ThemeProvider>
);
}
```
To see the full list of components that show inner focus indicator, check out the [full demo](#full-focus-visible-demo) below.
### Colored surface container
Components that support keyboard focus visible will show another layer of box-shadow indicator when they render within `AppBar`, `Alert`, and `SnackbarContent`. This comes by default when the focus visible feature is enabled, unless a custom box-shadow is provided.
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import SnackbarContent from '@mui/material/SnackbarContent';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
const theme = createTheme({
focusVisible: true,
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
export default function FocusVisibleColoredSurface() {
return (
<ThemeProvider theme={theme}>
<Stack spacing={3} sx={{ alignItems: 'center' }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: 'center' }}
>
Press <kbd>Tab</kbd> — a background-colored box-shadow renders behind the
outline so the ring stays visible on the colored surface.
</Typography>
<AppBar position="static" sx={{ borderRadius: 1 }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Title
</Typography>
<Button color="inherit">Login</Button>
<IconButton color="inherit" aria-label="add">
<AddIcon />
</IconButton>
</Toolbar>
</AppBar>
<Alert
variant="filled"
severity="error"
action={
<React.Fragment>
<Button color="inherit" size="small">
UNDO
</Button>
<IconButton color="inherit" size="small" aria-label="close">
<CloseIcon fontSize="inherit" />
</IconButton>
</React.Fragment>
}
>
Something went wrong
</Alert>
<SnackbarContent
message="Message sent"
action={
<Button color="inherit" size="small">
UNDO
</Button>
}
/>
</Stack>
</ThemeProvider>
);
}
```
## Customization
The `focusVisible` can be customized by passing a CSS object to merge with the default styles.
### Changing the outline color
To customize the outline, for example changing the color, pass an object with specified outline color to the `focusVisible` node to merge with the default outline styles:
```js
// Recolor only; width and offset stay at the curated 2px.
createTheme({ focusVisible: { outlineColor: '#9c27b0' } });
```
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Button from '@mui/material/Button';
// Merge over the curated default: recolor only, width and offset stay at 2px.
const theme = createTheme({
focusVisible: { outlineColor: '#9c27b0' },
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
export default function FocusVisibleRecolor() {
return (
<ThemeProvider theme={theme}>
<Button variant="outlined">Tab to me</Button>
</ThemeProvider>
);
}
```
### Use box-shadow as a second layer
A `boxShadow` can be **additive** on top of the outline. This is useful for a two-color ring (WCAG technique [C40](https://www.w3.org/WAI/WCAG21/Techniques/css/C40)) that stays visible on any background. Material UI insets the box-shadow automatically on the inner focus indicator components, so a plain single-layer value works everywhere:
```js
createTheme({
focusVisible: {
/* inner indicator */
outlineColor: '#F9F9F9',
outlineOffset: 0,
/* outer indicator */
boxShadow: '0 0 0 4px #193146',
},
});
```
:::info
Components with their own focus box-shadow compose both layers — for example, the Button and Fab keep their focus elevation and render the box-shadow above together with it.
:::
:::warning
Use a single box-shadow layer. A comma-separated value is not supported: only the first layer is inset on the inner focus indicator components, so every layer after it stays outset and is clipped away.
For a two-color ring, stack `outlineColor` and `boxShadow` as shown above rather than stacking two box-shadow layers.
:::
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
import Card from '@mui/material/Card';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
const theme = createTheme({
focusVisible: {
/* inner indicator */
outlineColor: '#193146',
outlineOffset: 2,
/* outer indicator */
boxShadow: '0 0 0 4px #FFF',
},
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
export default function FocusVisibleBoxShadow() {
const [cardTab, setCardTab] = React.useState(0);
const [appBarTab, setAppBarTab] = React.useState(0);
const handleCardChange = (event: React.SyntheticEvent, newValue: number) => {
setCardTab(newValue);
};
const handleAppBarChange = (event: React.SyntheticEvent, newValue: number) => {
setAppBarTab(newValue);
};
return (
<ThemeProvider theme={theme}>
<Stack spacing={2} sx={{ width: '100%' }}>
<Typography variant="body2" color="text.secondary">
Press <kbd>Tab</kbd> — the light outline or the dark box-shadow keeps
contrast on either background.
</Typography>
<Card
sx={{
px: 3,
minHeight: 64,
display: 'flex',
alignItems: 'center',
gap: 3,
}}
>
<Button variant="outlined">Tab to me</Button>
<Tabs value={cardTab} onChange={handleCardChange}>
<Tab label="Tab one" />
<Tab label="Tab two" />
</Tabs>
</Card>
<AppBar position="static" sx={{ borderRadius: 1 }}>
<Toolbar sx={{ gap: 3 }}>
<Button variant="outlined" color="inherit">
Tab to me
</Button>
<Tabs
value={appBarTab}
onChange={handleAppBarChange}
textColor="inherit"
indicatorColor="secondary"
>
<Tab label="Tab one" />
<Tab label="Tab two" />
</Tabs>
</Toolbar>
</AppBar>
</Stack>
</ThemeProvider>
);
}
```
### Replace outline with box-shadow
To replace the outline entirely with a box-shadow indicator, hide the outline with `outlineColor: 'transparent'`:
```js
createTheme({
focusVisible: {
outlineColor: 'transparent',
boxShadow: '0 0 0 3px #1976d2',
},
});
```
:::success
Hide the outline with `outlineColor: 'transparent'`, not `outline: 'none'`. In [forced-colors mode](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors) the browser strips the box-shadow and forces the outline to a system color, so a transparent outline reappears as the indicator. `outline: 'none'` removes it, leaving no keyboard focus indicator at all.
:::
## Full focus visible demo
The complete set of components that render the focus indicator when `focusVisible` is enabled. Use the keyboard (<kbd>Tab</kbd> and arrow keys) to move focus and reveal the ring.
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import ButtonGroup from '@mui/material/ButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Fab from '@mui/material/Fab';
import Chip from '@mui/material/Chip';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
import Switch from '@mui/material/Switch';
import Stepper from '@mui/material/Stepper';
import Step from '@mui/material/Step';
import StepButton from '@mui/material/StepButton';
import Pagination from '@mui/material/Pagination';
import ButtonBase from '@mui/material/ButtonBase';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Table from '@mui/material/Table';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableBody from '@mui/material/TableBody';
import TableRow from '@mui/material/TableRow';
import TableCell from '@mui/material/TableCell';
import TableSortLabel from '@mui/material/TableSortLabel';
import Slider from '@mui/material/Slider';
import Link from '@mui/material/Link';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import Rating from '@mui/material/Rating';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import BottomNavigation from '@mui/material/BottomNavigation';
import BottomNavigationAction from '@mui/material/BottomNavigationAction';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import AddIcon from '@mui/icons-material/Add';
import StarIcon from '@mui/icons-material/Star';
import HomeIcon from '@mui/icons-material/Home';
const theme = createTheme({
focusVisible: true,
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
const noop = () => {};
const dialogParagraphs = Array.from(
{ length: 40 },
(_, index) =>
`Paragraph ${index + 1}. Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam.`,
);
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<React.Fragment>
<Typography variant="body2" sx={{ fontWeight: 600, alignSelf: 'center' }}>
{label}
</Typography>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 1 }}
>
{children}
</Stack>
</React.Fragment>
);
}
function Bucket({
title,
hint,
children,
}: {
title: string;
hint: string;
children: React.ReactNode;
}) {
return (
<div>
<Typography variant="overline" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mb: 1.5 }}
>
{hint}
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '150px 1fr',
alignItems: 'center',
columnGap: 3,
rowGap: 2,
}}
>
{children}
</Box>
</div>
);
}
export default function FullFocusVisibleDemo() {
const [tab, setTab] = React.useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);
};
const [alignment, setAlignment] = React.useState<string | null>('left');
const handleAlignmentChange = (
event: React.MouseEvent<HTMLElement>,
newAlignment: string | null,
) => {
setAlignment(newAlignment);
};
const [orderBy, setOrderBy] = React.useState('name');
const [order, setOrder] = React.useState<'asc' | 'desc'>('asc');
const createSortHandler = (column: string) => () => {
if (orderBy === column) {
setOrder(order === 'asc' ? 'desc' : 'asc');
} else {
setOrderBy(column);
setOrder('asc');
}
};
const [activeStep, setActiveStep] = React.useState(0);
const handleStep = (step: number) => () => {
setActiveStep(step);
};
const [dialogOpen, setDialogOpen] = React.useState(false);
const handleDialogOpen = () => {
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
};
return (
<ThemeProvider theme={theme}>
<Stack spacing={3}>
<Bucket
title="outer-ring"
hint="The ring renders fully outside the component."
>
<Row label="Button">
<Button variant="text">Text</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="contained">Contained</Button>
</Row>
<Row label="IconButton">
<IconButton aria-label="star">
<StarIcon />
</IconButton>
</Row>
<Row label="ButtonGroup">
<ButtonGroup variant="outlined">
<Button>One</Button>
<Button>Two</Button>
</ButtonGroup>
</Row>
<Row label="ToggleButton">
<ToggleButtonGroup
value={alignment}
onChange={handleAlignmentChange}
exclusive
>
<ToggleButton value="left">Left</ToggleButton>
<ToggleButton value="right">Right</ToggleButton>
</ToggleButtonGroup>
</Row>
<Row label="Fab">
<Fab size="small" color="primary" aria-label="add">
<AddIcon />
</Fab>
</Row>
<Row label="Chip">
<Chip label="Clickable" onClick={noop} />
<Chip label="Deletable" onDelete={noop} />
</Row>
<Row label="Checkbox">
<FormGroup>
<FormControlLabel
control={<Checkbox defaultChecked />}
label="Checkbox A"
/>
<FormControlLabel control={<Checkbox />} label="Checkbox B" />
</FormGroup>
</Row>
<Row label="Radio">
<RadioGroup defaultValue="a">
<FormControlLabel value="a" control={<Radio />} label="Radio A" />
<FormControlLabel value="b" control={<Radio />} label="Radio B" />
</RadioGroup>
</Row>
<Row label="Switch">
<FormControlLabel control={<Switch defaultChecked />} label="Switch" />
</Row>
<Row label="Pagination">
<Pagination count={3} />
</Row>
<Row label="ButtonBase">
<ButtonBase
sx={{
px: 1.5,
py: 1,
border: '1px dashed',
borderColor: 'divider',
borderRadius: 1,
}}
>
ButtonBase
</ButtonBase>
</Row>
<Row label="AccordionSummary">
<Accordion disableGutters sx={{ width: 280 }}>
<AccordionSummary>Accordion header</AccordionSummary>
<AccordionDetails>
<Typography variant="body2">Details</Typography>
</AccordionDetails>
</Accordion>
</Row>
<Row label="TableSortLabel">
<TableContainer component={Paper} variant="outlined" sx={{ width: 280 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>
<TableSortLabel
active={orderBy === 'name'}
direction={orderBy === 'name' ? order : 'asc'}
onClick={createSortHandler('name')}
>
Name
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={orderBy === 'size'}
direction={orderBy === 'size' ? order : 'asc'}
onClick={createSortHandler('size')}
>
Size
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>file.txt</TableCell>
<TableCell>12 KB</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Row>
<Row label="Slider">
<Slider defaultValue={40} aria-label="Volume" sx={{ width: 200 }} />
</Row>
<Row label="Link">
<Link href="#">Text link</Link>
</Row>
<Row label="Breadcrumbs">
<Breadcrumbs>
<Link href="#">Home</Link>
<Link href="#">Catalog</Link>
<Typography color="text.primary">Item</Typography>
</Breadcrumbs>
</Row>
<Row label="Rating">
<Rating defaultValue={3} />
</Row>
<Row label="Stepper">
<Stepper nonLinear activeStep={activeStep} sx={{ minWidth: 260 }}>
<Step>
<StepButton onClick={handleStep(0)}>One</StepButton>
</Step>
<Step>
<StepButton onClick={handleStep(1)}>Two</StepButton>
</Step>
</Stepper>
</Row>
<Row label="Dialog">
<Button variant="outlined" onClick={handleDialogOpen}>
Open dialog
</Button>
<Dialog
open={dialogOpen}
onClose={handleDialogClose}
scroll="paper"
aria-labelledby="fv-dialog-title"
>
<DialogTitle id="fv-dialog-title">Subscribe</DialogTitle>
<DialogContent dividers>
{dialogParagraphs.map((paragraph) => (
<DialogContentText key={paragraph} sx={{ mb: 1 }}>
{paragraph}
</DialogContentText>
))}
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose}>Cancel</Button>
<Button onClick={handleDialogClose}>Subscribe</Button>
</DialogActions>
</Dialog>
</Row>
</Bucket>
<Divider />
<Bucket
title="inner-ring"
hint="Inside a scrollable or overflow-clipped container — the ring is inset (outlineOffset -2) so it cannot be clipped."
>
<Row label="Tab">
<Tabs value={tab} onChange={handleTabChange} sx={{ minHeight: 0 }}>
<Tab label="Tab one" />
<Tab label="Tab two" />
</Tabs>
</Row>
<Row label="MenuItem">
<MenuList>
<MenuItem>Profile</MenuItem>
<MenuItem>Settings</MenuItem>
</MenuList>
</Row>
<Row label="ListItemButton">
<List>
<ListItemButton>List item button 1</ListItemButton>
<ListItemButton>List item button 2</ListItemButton>
</List>
</Row>
<Row label="BottomNavigation">
<BottomNavigation showLabels value={0} sx={{ width: 320 }}>
<BottomNavigationAction label="Star" icon={<StarIcon />} />
<BottomNavigationAction label="Home" icon={<HomeIcon />} />
<BottomNavigationAction label="Add" icon={<AddIcon />} />
</BottomNavigation>
</Row>
<Row label="CardActionArea">
<Card variant="outlined" sx={{ width: 160 }}>
<CardActionArea>
<Box sx={{ p: 2 }}>
<Typography variant="body2">Card</Typography>
</Box>
</CardActionArea>
</Card>
</Row>
<Row label="Select">
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="fv-select-label">Select</InputLabel>
<Select labelId="fv-select-label" label="Select" defaultValue="a">
<MenuItem value="a">Option A</MenuItem>
<MenuItem value="b">Option B</MenuItem>
<MenuItem value="c">Option C</MenuItem>
</Select>
</FormControl>
</Row>
<Row label="Autocomplete">
<Autocomplete
options={['Apple', 'Banana', 'Cherry']}
sx={{ width: 220 }}
renderInput={(params) => (
<TextField {...params} label="Autocomplete" size="small" />
)}
/>
</Row>
</Bucket>
</Stack>
</ThemeProvider>
);
}
```
## Caveats
### Checkbox and Radio custom icons must be SVG
The Checkbox and Radio attach the focus indicator to the first `<svg>` element inside the component. When customizing them with the `icon` and `checkedIcon` props, the custom icon must render an `<svg>` element — icons rendered as other elements, such as font icons or `<img>`, do not receive the focus indicator.
The indicator hugs whatever box the svg renders at, so smaller replacement icons get a proportionally tighter ring with no extra tuning.
:::success
[`SvgIcon`](/material-ui/icons/#svgicon) is recommended to wrap custom svgs to get consistent styles.
:::
```tsx
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
const theme = createTheme({
focusVisible: true,
colorSchemes: { light: true, dark: true },
// These demos opt out of the ripple, so the focus ring is the only keyboard indicator.
components: { MuiButtonBase: { defaultProps: { disableRipple: true } } },
});
function TightSquareIcon(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 16 16" {...props} sx={{ fontSize: 16 }}>
<rect
x="0.75"
y="0.75"
width="14.5"
height="14.5"
rx="3.25"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</SvgIcon>
);
}
function TightSquareCheckedIcon(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 16 16" {...props} sx={{ fontSize: 16 }}>
<rect x="0" y="0" width="16" height="16" rx="4" fill="currentColor" />
<path
d="m4.5 8.5 2.5 2.5 4.5-5"
fill="none"
stroke="#fff"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
function TightCircleIcon(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 16 16" {...props} sx={{ fontSize: 16 }}>
<circle
cx="8"
cy="8"
r="7.25"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</SvgIcon>
);
}
function TightCircleCheckedIcon(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 16 16" {...props} sx={{ fontSize: 16 }}>
<circle
cx="8"
cy="8"
r="7.25"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<circle cx="8" cy="8" r="3.75" fill="currentColor" />
</SvgIcon>
);
}
export default function FocusVisibleCustomIcons() {
return (
<ThemeProvider theme={theme}>
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary">
Press <kbd>Tab</kbd> — the ring hugs the 16px svg icons.
</Typography>
<Stack direction="row" spacing={6}>
<FormControl component="fieldset">
<FormLabel component="legend">Settings</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
icon={<TightSquareIcon />}
checkedIcon={<TightSquareCheckedIcon />}
defaultChecked
/>
}
label="Autosave"
/>
<FormControlLabel
control={
<Checkbox
icon={<TightSquareIcon />}
checkedIcon={<TightSquareCheckedIcon />}
/>
}
label="Notifications"
/>
<FormControlLabel
control={
<Checkbox
icon={<TightSquareIcon />}
checkedIcon={<TightSquareCheckedIcon />}
/>
}
label="Public profile"
/>
</FormGroup>
</FormControl>
<FormControl>
<FormLabel id="custom-icons-density-label">Density</FormLabel>
<RadioGroup
aria-labelledby="custom-icons-density-label"
defaultValue="medium"
name="density"
>
<FormControlLabel
value="compact"
control={
<Radio
icon={<TightCircleIcon />}
checkedIcon={<TightCircleCheckedIcon />}
/>
}
label="Compact"
/>
<FormControlLabel
value="medium"
control={
<Radio
icon={<TightCircleIcon />}
checkedIcon={<TightCircleCheckedIcon />}
/>
}
label="Medium"
/>
<FormControlLabel
value="spacious"
control={
<Radio
icon={<TightCircleIcon />}
checkedIcon={<TightCircleCheckedIcon />}
/>
}
label="Spacious"
/>
</RadioGroup>
</FormControl>
</Stack>
</Stack>
</ThemeProvider>
);
}
```
### Component focus-visible styles are replaced by the theme
Some components indicate keyboard focus with a translucent background or overlay by default — the `Chip`, `MenuItem`, `ListItemButton`, `AccordionSummary`, `PaginationItem`, `CardActionArea`, `Autocomplete` options, and the `Slider` thumb. When `focusVisible` is enabled, these component focus-visible styles are removed so that the theme's indicator is the only one, consistent across all components — hover, selected, and active styles are unchanged.
### Recomposing a theme with a palette change
Spreading a created theme into `createTheme()` while changing the palette keeps the indicator color resolved from the original palette. Re-pass `focusVisible` in the same call so the color re-derives from the new palette:
```js
const base = createTheme({ focusVisible: true });
// ✅ re-pass focusVisible to re-derive the color from the new palette
createTheme({
...base,
palette: { primary: { main: '#2e7d32' } },
focusVisible: true,
});
```