포커스 표시

포커스 표시 (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,
});

더 알아보기 (Learn more)