포커스 표시
포커스 표시 (Focus visible)
Material UI 컴포넌트 전반에 시각적 아웃라인 또는 인셋 키보드 포커스 표시를 적용할 수 있어요.
출처: 문서
본문
Material UI 컴포넌트 전반에 아웃라인 또는 인셋 키보드 포커스 표시를 적용해 보세요.
v9.4부터 Material UI는 CSS를 통한 시각적 키보드 포커스 표시를 기본 제공해요. 이 페이지의 데모들은 ripple을 끄고 포커스 표시만 보여주도록 되어 있어요.
사용법 (Usage)
테마에 focusVisible: true를 설정하면, 모든 ButtonBase 파생 컴포넌트가 키보드 포커스를 받을 때 기본 포커스 표시를 렌더링해요:
import { createTheme } from '@mui/material/styles';
const theme = createTheme({ focusVisible: true });
기본 포커스 표시는 palette.primary.main 색상의 2픽셀 실선 아웃라인에 2픽셀 오프셋이 적용된 형태예요:
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은 고대비 색상 모드를 포함해 대부분의 환경에서 작동하는 웹 표준에서 가장 흔한 표시 방식이에요.
:::
내부 포커스 표시 (Inner focus indicator)
일부 컴포넌트, 예를 들어 Tab은 overflow로 잘리는 컨테이너나 다른 요소와의 겹침을 피하기 위해 포커스 표시를 안쪽에서 렌더링해요.
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>
);
}
내부 포커스 표시를 보여주는 전체 컴포넌트 목록을 보려면 아래의 전체 데모를 확인하세요.
색상이 있는 표면 컨테이너 (Colored surface container)
키보드 포커스 표시를 지원하는 컴포넌트들은 AppBar, Alert, SnackbarContent 안에서 렌더링될 때 또 한 겹의 box-shadow 표시가 나타나요. 커스텀 box-shadow를 제공하지 않는 한, 포커스 표시 기능이 활성화되면 기본으로 적용돼요.
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)
focusVisible는 기본 스타일과 병합할 CSS 객체를 전달해 커스터마이즈할 수 있어요.
아웃라인 색상 바꾸기 (Changing the outline color)
아웃라인을 커스터마이즈하려면, 예를 들어 색상을 바꾸려면 기본 아웃라인 스타일과 병합될 아웃라인 색상이 지정된 객체를 focusVisible 노드에 전달하세요:
// Recolor only; width and offset stay at the curated 2px.
createTheme({ focusVisible: { outlineColor: '#9c27b0' } });
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>
);
}
두 번째 레이어로 box-shadow 사용하기 (Use box-shadow as a second layer)
boxShadow는 아웃라인 위에 추가로 더해질 수 있어요. 이는 어떤 배경에서도 보이는 두 색상 링(두 색 링, WCAG 기법 C40)에 유용해요. Material UI는 내부 포커스 표시 컴포넌트에 box-shadow를 자동으로 인셋하므로, 단순한 단일 레이어 값이 어디서든 잘 동작해요:
createTheme({
focusVisible: {
/* inner indicator */
outlineColor: '#F9F9F9',
outlineOffset: 0,
/* outer indicator */
boxShadow: '0 0 0 4px #193146',
},
});
:::info 자체 포커스 box-shadow가 있는 컴포넌트는 두 레이어를 구성해요 — 예를 들어 Button과 Fab는 자신의 포커스 고도(elevation)를 유지하면서 그 위에 box-shadow를 함께 렌더링해요. :::
:::warning 단일 box-shadow 레이어를 사용하세요. 쉼표로 구분된 값은 지원되지 않아요: 내부 포커스 표시 컴포넌트에서는 첫 번째 레이어만 인셋되기 때문에, 이후의 모든 레이어는 아웃셋 상태로 남아 잘려나가요.
두 색상 링을 원한다면 두 개의 box-shadow 레이어를 쌓는 대신, 위에 보인 대로 outlineColor와 boxShadow를 쌓으세요.
:::
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>
);
}
아웃라인을 box-shadow로 대체하기 (Replace outline with box-shadow)
아웃라인을 완전히 box-shadow 표시로 대체하려면, outlineColor: 'transparent'로 아웃라인을 숨기세요:
createTheme({
focusVisible: {
outlineColor: 'transparent',
boxShadow: '0 0 0 3px #1976d2',
},
});
:::success
outline: 'none'이 아니라 outlineColor: 'transparent'로 아웃라인을 숨기세요. forced-colors 모드에서는 브라우저가 box-shadow를 제거하고 아웃라인을 시스템 색상으로 강제하기 때문에, 투명한 아웃라인도 표시로 다시 나타나요. outline: 'none'은 아웃라인을 제거해 키보드 포커스 표시가 전혀 남지 않게 해요.
:::
전체 포커스 표시 데모 (Full focus visible demo)
focusVisible이 활성화되었을 때 포커스 표시를 렌더링하는 컴포넌트의 전체 집합이에요. 키보드(Tab과 화살표 키)를 사용해 포커스를 이동하고 링을 확인해 보세요.
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 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 = () => {};
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);
};
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>
</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와 Radio의 커스텀 아이콘은 반드시 SVG여야 해요
Checkbox와 Radio는 포커스 표시를 컴포넌트 내부의 첫 번째 <svg> 요소에 부착해요. icon과 checkedIcon props로 커스터마이즈할 때, 커스텀 아이콘은 <svg> 요소를 렌더링해야 해요 — 폰트 아이콘이나 <img>처럼 다른 요소로 렌더링된 아이콘은 포커스 표시를 받지 못해요.
표시는 svg가 렌더링하는 박스를 감싸기 때문에, 더 작은 대체 아이콘은 별도의 조정 없이 비례적으로 더 빡빡한 링을 받게 돼요.
:::success
커스텀 svg를 감싸 일관된 스타일을 얻으려면 SvgIcon을 권장해요.
:::
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)
일부 컴포넌트는 기본적으로 반투명 배경이나 오버레이로 키보드 포커스를 표시해요 — Chip, MenuItem, ListItemButton, AccordionSummary, PaginationItem, CardActionArea, Autocomplete 옵션, Slider thumb 등이 그래요. focusVisible이 활성화되면 이러한 컴포넌트 포커스 표시 스타일이 제거되어, 테마의 표시만 모든 컴포넌트에 걸쳐 일관되게 적용돼요 — hover, selected, active 스타일은 그대로 유지돼요.
팔레트 변경으로 테마를 재구성할 때 (Recomposing a theme with a palette change)
생성된 테마를 createTheme()에 펼쳐 넣으면서 팔레트를 변경하면, 표시 색상이 원래 팔레트에서 해석된 상태로 유지돼요. 같은 호출에서 focusVisible을 다시 전달해 새 팔레트에서 색상을 다시 유도하세요:
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,
});