Modal

Modal 컴포넌트는 다이얼로그, 팝오버, 라이트박스 등 무엇이든 만들 수 있는 견고한 기반을 제공해요. 화면 위에 콘텐츠를 겹쳐 보여주고 싶을 때 가장 기본이 되는 컴포넌트랍니다.

출처: 문서

본문

Modal 컴포넌트는 다이얼로그, 팝오버, 라이트박스 등 무엇이든 만들 수 있는 견고한 기반을 제공해요.

이 컴포넌트는 children 노드를 백드롭(backdrop) 컴포넌트 앞에 렌더링해요. Modal은 중요한 기능들을 제공합니다:

  • 💄 한 번에 하나만으로는 부족할 때 모달 스태킹(stacking)을 관리해요.
  • 🔐 백드롭을 만들어서 모달 아래에서의 상호작용을 비활성화해요.
  • 🔐 열려 있는 동안 페이지 콘텐츠의 스크롤을 비활성화해요.
  • ♿️ 포커스를 제대로 관리해요. 모달 콘텐츠로 이동시키고, 모달이 닫힐 때까지 그 안에 유지해요.
  • ♿️ 적절한 ARIA 역할을 자동으로 추가해요.

:::info "modal"이라는 용어는 때때로 "dialog"를 의미하는 데 쓰이기도 하지만, 이것은 잘못된 명칭이에요. 모달 창(modal window)은 UI의 일부를 설명합니다. 어떤 요소가 애플리케이션 나머지 부분과의 상호작용을 차단한다면 modal로 간주돼요. :::

모달 다이얼로그를 만들고 있다면, Modal을 직접 사용하기보다 Dialog 컴포넌트를 사용하고 싶을 거예요. Modal은 다음 컴포넌트들이 활용하는 더 저수준(lower-level)의 구조물이에요:

기본 modal (Basic modal)

import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import Modal from '@mui/material/Modal';

const style = {
  position: 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4,
};

export default function BasicModal() {
  const [open, setOpen] = React.useState(false);
  const handleOpen = () => setOpen(true);
  const handleClose = () => setOpen(false);

  return (
    <div>
      <Button onClick={handleOpen}>Open modal</Button>
      <Modal
        open={open}
        onClose={handleClose}
        aria-labelledby="modal-modal-title"
        aria-describedby="modal-modal-description"
      >
        <Box sx={style}>
          <Typography id="modal-modal-title" variant="h6" component="h2">
            Text in a modal
          </Typography>
          <Typography id="modal-modal-description" sx={{ mt: 2 }}>
            Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
          </Typography>
        </Box>
      </Modal>
    </div>
  );
}

outline: 0 CSS 속성으로 (종종 파란색이나 금색인) 아웃라인을 비활성화할 수 있다는 점을 알아두세요.

중첩 modal (Nested modal)

Modal은 중첩될 수 있어요. 예를 들어 다이얼로그 안의 select 같은 경우죠. 하지만 두 개 이상의 modal을 쌓는 것, 또는 백드롭이 있는 두 개의 modal을 쌓는 것은 권장되지 않아요.

modal을 중첩해야 한다면, 중첩된 Modal에 hideBackdrop prop을 사용해서 여러 백드롭이 쌓여 활성 modal을 가리거나 상호작용에 영향을 주는 것을 피하세요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Button from '@mui/material/Button';

const style = {
  position: 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  pt: 2,
  px: 4,
  pb: 3,
};

function ChildModal() {
  const [open, setOpen] = React.useState(false);
  const handleOpen = () => {
    setOpen(true);
  };
  const handleClose = () => {
    setOpen(false);
  };

  return (
    <React.Fragment>
      <Button onClick={handleOpen}>Open Child Modal</Button>
      <Modal
        open={open}
        onClose={handleClose}
        aria-labelledby="child-modal-title"
        aria-describedby="child-modal-description"
      >
        <Box sx={{ ...style, width: 200 }}>
          <h2 id="child-modal-title">Text in a child modal</h2>
          <p id="child-modal-description">
            Lorem ipsum, dolor sit amet consectetur adipisicing elit.
          </p>
          <Button onClick={handleClose}>Close Child Modal</Button>
        </Box>
      </Modal>
    </React.Fragment>
  );
}

export default function NestedModal() {
  const [open, setOpen] = React.useState(false);
  const handleOpen = () => {
    setOpen(true);
  };
  const handleClose = () => {
    setOpen(false);
  };

  return (
    <div>
      <Button onClick={handleOpen}>Open modal</Button>
      <Modal
        open={open}
        onClose={handleClose}
        aria-labelledby="parent-modal-title"
        aria-describedby="parent-modal-description"
      >
        <Box sx={{ ...style, width: 400 }}>
          <h2 id="parent-modal-title">Text in a modal</h2>
          <p id="parent-modal-description">
            Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
          </p>
          <ChildModal />
        </Box>
      </Modal>
    </div>
  );
}

트랜지션 (Transitions)

modal의 열림/닫힘 상태는 트랜지션 컴포넌트로 애니메이션할 수 있어요. 이 컴포넌트는 다음 조건을 지켜야 해요:

  • Modal의 직접적인 자식 하위 요소여야 한다.
  • in prop을 가져야 한다. 이는 열림/닫힘 상태에 해당해요.
  • enter 트랜지션이 시작될 때 onEnter 콜백 prop을 호출해야 한다.
  • exit 트랜지션이 완료될 때 onExited 콜백 prop을 호출해야 한다. 이 두 콜백 덕분에 modal은 닫히고 완전히 트랜지션된 후에 자식 콘텐츠를 unmount할 수 있어요.

Modal은 react-transition-group에 대한 기본 지원을 가지고 있어요.

import * as React from 'react';
import Backdrop from '@mui/material/Backdrop';
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Fade from '@mui/material/Fade';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';

const style = {
  position: 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4,
};

export default function TransitionsModal() {
  const [open, setOpen] = React.useState(false);
  const handleOpen = () => setOpen(true);
  const handleClose = () => setOpen(false);

  return (
    <div>
      <Button onClick={handleOpen}>Open modal</Button>
      <Modal
        aria-labelledby="transition-modal-title"
        aria-describedby="transition-modal-description"
        open={open}
        onClose={handleClose}
        closeAfterTransition
        slots={{ backdrop: Backdrop }}
        slotProps={{
          backdrop: {
            timeout: 500,
          },
        }}
      >
        <Fade in={open}>
          <Box sx={style}>
            <Typography id="transition-modal-title" variant="h6" component="h2">
              Text in a modal
            </Typography>
            <Typography id="transition-modal-description" sx={{ mt: 2 }}>
              Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
            </Typography>
          </Box>
        </Fade>
      </Modal>
    </div>
  );
}

또는 react-spring을 사용할 수도 있어요.

import * as React from 'react';
import Backdrop from '@mui/material/Backdrop';
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { useSpring, animated } from '@react-spring/web';

interface FadeProps {
  children: React.ReactElement<any>;
  in?: boolean;
  onClick?: any;
  onEnter?: (node: HTMLElement, isAppearing: boolean) => void;
  onExited?: (node: HTMLElement, isAppearing: boolean) => void;
  ownerState?: any;
}

const Fade = React.forwardRef<HTMLDivElement, FadeProps>(function Fade(props, ref) {
  const {
    children,
    in: open,
    onClick,
    onEnter,
    onExited,
    ownerState,
    ...other
  } = props;
  const style = useSpring({
    from: { opacity: 0 },
    to: { opacity: open ? 1 : 0 },
    onStart: () => {
      if (open && onEnter) {
        onEnter(null as any, true);
      }
    },
    onRest: () => {
      if (!open && onExited) {
        onExited(null as any, true);
      }
    },
  });

  return (
    <animated.div ref={ref} style={style} {...other}>
      {React.cloneElement(children, { onClick })}
    </animated.div>
  );
});

const style = {
  position: 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4,
};

export default function SpringModal() {
  const [open, setOpen] = React.useState(false);
  const handleOpen = () => setOpen(true);
  const handleClose = () => setOpen(false);

  return (
    <div>
      <Button onClick={handleOpen}>Open modal</Button>
      <Modal
        aria-labelledby="spring-modal-title"
        aria-describedby="spring-modal-description"
        open={open}
        onClose={handleClose}
        closeAfterTransition
        slots={{ backdrop: Backdrop }}
        slotProps={{
          backdrop: { slots: { transition: Fade } },
        }}
      >
        <Fade in={open}>
          <Box sx={style}>
            <Typography id="spring-modal-title" variant="h6" component="h2">
              Text in a modal
            </Typography>
            <Typography id="spring-modal-description" sx={{ mt: 2 }}>
              Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
            </Typography>
          </Box>
        </Fade>
      </Modal>
    </div>
  );
}

성능 (Performance)

modal의 콘텐츠는 닫히면 unmount돼요. 콘텐츠를 검색 엔진에서 사용할 수 있게 만들거나, 상호작용 반응성을 최적화하면서 modal 안에서 비싼 컴포넌트 트리를 렌더링해야 한다면 keepMounted prop을 활성화해서 이 기본 동작을 바꾸는 것이 좋을 수 있어요:

<Modal keepMounted />
import * as React from 'react';
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';

const style = {
  position: 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4,
};

export default function KeepMountedModal() {
  const [open, setOpen] = React.useState(false);
  const handleOpen = () => setOpen(true);
  const handleClose = () => setOpen(false);

  return (
    <div>
      <Button onClick={handleOpen}>Open modal</Button>
      <Modal
        keepMounted
        open={open}
        onClose={handleClose}
        aria-labelledby="keep-mounted-modal-title"
        aria-describedby="keep-mounted-modal-description"
      >
        <Box sx={style}>
          <Typography id="keep-mounted-modal-title" variant="h6" component="h2">
            Text in a modal
          </Typography>
          <Typography id="keep-mounted-modal-description" sx={{ mt: 2 }}>
            Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
          </Typography>
        </Box>
      </Modal>
    </div>
  );
}

다른 성능 최적화와 마찬가지로, 이것은 만능 해결책(silver bullet)이 아니에요. 먼저 병목 지점(bottleneck)을 확실히 파악한 다음, 이런 최적화 전략을 시도해 보세요.

서버 사이드 modal (Server-side modal)

React는 서버에서 createPortal() API를 지원하지 않아요. modal을 표시하려면 disablePortal prop으로 포털(portal) 기능을 비활성화해야 해요:

import * as React from 'react';
import Modal from '@mui/material/Modal';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';

export default function ServerModal() {
  const rootRef = React.useRef<HTMLDivElement>(null);

  return (
    <Box
      sx={{
        height: 300,
        flexGrow: 1,
        minWidth: 300,
        transform: 'translateZ(0)',
      }}
      ref={rootRef}
    >
      <Modal
        disablePortal
        disableEnforceFocus
        disableAutoFocus
        open
        aria-labelledby="server-modal-title"
        aria-describedby="server-modal-description"
        sx={{
          display: 'flex',
          p: 1,
          alignItems: 'center',
          justifyContent: 'center',
        }}
        container={() => rootRef.current!}
      >
        <Box
          sx={(theme) => ({
            position: 'relative',
            width: 400,
            bgcolor: 'background.paper',
            border: '2px solid #000',
            boxShadow: theme.shadows[5],
            p: 4,
          })}
        >
          <Typography id="server-modal-title" variant="h6" component="h2">
            Server-side modal
          </Typography>
          <Typography id="server-modal-description" sx={{ pt: 2 }}>
            If you disable JavaScript, you will still see me.
          </Typography>
        </Box>
      </Modal>
    </Box>
  );
}

제한 사항 (Limitations)

포커스 트랩 (Focus trap)

modal은 포커스가 빠져나가려 하면 포커스를 컴포넌트의 본문으로 되돌려 보내요.

이것은 접근성을 위한 것입니다. 다만 문제를 만들 수도 있어요. 사용자가 페이지의 다른 부분, 예를 들어 채팅봇 창과 상호작용해야 하는 경우 이 동작을 비활성화할 수 있어요:

<Modal disableEnforceFocus />

접근성 (Accessibility)

(WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)

  • Modal에 모달 제목을 참조하는 aria-labelledby="id..."를 반드시 추가하세요. 또한 Modal에 aria-describedby="id..." prop을 사용해서 모달에 대한 설명을 제공할 수도 있어요.

    <Modal aria-labelledby="modal-title" aria-describedby="modal-description">
      <h2 id="modal-title">My Title</h2>
      <p id="modal-description">My Description</p>
    </Modal>
    
  • WAI-ARIA Authoring Practices는 모달 콘텐츠에 따라 가장 관련 있는 요소에 초기 포커스를 설정하는 데 도움을 줄 수 있어요.

  • "모달 창(modal window)"은 기본 창(primary window) 또는 다른 모달 창 위에 겹쳐진다는 것을 명심하세요. 모달 아래의 창들은 inert(반응 없음)해요. 즉 사용자는 활성 모달 창 밖의 콘텐츠와 상호작용할 수 없어요. 이것은 충돌하는 동작을 만들 수도 있어요.

데모 (Demos)

이 React 컴포넌트의 사용 예시와 세부 내용은 컴포넌트 데모 페이지에서 확인하세요:

Import

import Modal from '@mui/material/Modal';
// or
import { Modal } from '@mui/material';

Props

Name Type Default Required Description
children element - Yes
open bool - Yes
classes object - No Override or extend the styles applied to the component.
closeAfterTransition bool false No
component elementType - No
container HTML element | func - No
disableAutoFocus bool false No
disableEnforceFocus bool false No
disablePortal bool false No
disableRestoreFocus bool false No
disableScrollLock bool false No
hideBackdrop bool false No
keepMounted bool false No
onClose function(event: object, reason: string) => void - No
onTransitionEnter func - No
onTransitionExited func - No
slotProps { backdrop?: func | object, root?: func | object } {} No
slots { backdrop?: elementType, root?: elementType } {} No
sx Array<func | object | bool> | func | object - No The system prop that allows defining system overrides as well as additional CSS styles.

Note: The ref is forwarded to the root element (HTMLDivElement).

Any other props supplied will be provided to the root element (native element).

슬롯 (Slots)

Name Default Class Description
root 'div' .MuiModal-root The component that renders the root.
backdrop undefined .MuiModal-backdrop The component that renders the backdrop.

CSS

규칙 이름 (Rule name)

Global class Rule name Description
- hidden Class name applied to the root element if the Modal has exited.

소스 코드 (Source code)

이 페이지에서 원하는 정보를 찾지 못했다면, 더 자세한 내용을 위해 컴포넌트 구현을 살펴보는 것을 고려해보세요.

더 알아보기 (Learn more)