모달 컴포넌트 해부

모달 컴포넌트에 대해 분석 및 해부

2

#React#Typescript#modal
모달 컴포넌트 해부

모달 컴포넌트 UI/UX 설계 가이드

모달(Modal)은 사용자와의 상호작용을 잠시 중단시키고 중요한 정보 또는 결정을 요구하는 UI 요소입니다. 단순히 "레이어 팝업"이 아닌, 사용자 경험과 접근성 측면에서 아주 민감한 컴포넌트입니다.

이 문서에서는 모달을 설계할 때 고려해야 할 UI/UX 요소, 디자인 원칙, 접근성 지침(A11y), 그리고 실제 설계에 도움이 되는 수치적 기준까지 다룹니다.

💡 참고: 본 문서에 사용된 수치는 대부분 Material Design 및 Apple HIG에서 정의한 dp(density-independent pixel) 또는 pt(point)를 웹 상에서 일반적으로 사용하는 px 기준으로 환산한 값입니다. 논리 해상도 기준에서 1dp ≈ 1px로 간주하고 작성하였습니다.


✅ 모달이란?

사용자와의 상호작용 흐름을 일시적으로 차단하고 중요한 정보나 동작을 요구하는 UI 컴포넌트입니다. UX 상 매우 강한 개입(interruption)을 동반합니다.

✅ 모달의 UX적 역할

역할예시
안내시스템 메시지, 알림 등
결정 유도확인 / 취소
입력로그인, 회원가입, 검색 필터 등
흐름 분기예약 상세 보기, 이미지 미리보기 등

잘 만든 모달은 사용자의 흐름을 안전하게 일시정지시켜야 합니다.
나쁜 모달은 이탈을 유도하거나 피로도를 높입니다.


✅ 모달의 구성 요소 분석

요소설명
Dimmed 영역배경을 어둡게 하여 포커스를 집중시킴
Content Box실제 내용을 표시하는 중심 레이어
Close 방식ESC, X 버튼, 배경 클릭 등
Focus Trap키보드 탐색이 모달 내에서만 가능해야 함
스크롤 방지배경 스크롤 금지 (body { overflow: hidden })
애니메이션열리고 닫힐 때 부드러운 전환
접근성ARIA 속성, 포커스 제어, 배경 비활성화 등 포함

📐 모달을 어떻게 설계할 것인가?

1. Portal 구조 도입

모달은 DOM의 하위 구조에 묻히면 z-index, overflow, focus 제어가 어렵습니다.
React Portal을 활용해 #modal-root 등 최상위 DOM 노드에 렌더링하는 것이 필수입니다.

import { createPortal } from "react-dom";
 
export default function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.getElementById("modal-root"),
  );
}

2. 포커스 트랩 (Focus Trap)

키보드 사용자를 위해 Tab 키 탐색이 모달 내부에서만 순환해야 합니다. 이를 위해 focus-trap-react 같은 라이브러리나 직접 구현이 필요합니다.

import { useEffect } from "react";
 
export default function useFocusTrap(
  containerRef: React.RefObject<HTMLElement>,
  isActive: boolean,
) {
  useEffect(() => {
    if (!isActive || !containerRef.current) return;
 
    const container = containerRef.current;
    const selectors = [
      "a[href]",
      "area[href]",
      "input:not([disabled])",
      "select:not([disabled])",
      "textarea:not([disabled])",
      "button:not([disabled])",
      "iframe",
      "object",
      "embed",
      '[tabindex]:not([tabindex="-1"])',
      "[contenteditable]",
    ];
 
    const getFocusable = () =>
      Array.from(
        container.querySelectorAll<HTMLElement>(selectors.join(",")),
      ).filter(
        (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
      );
 
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;
 
      const elements = getFocusable();
      if (!elements.length) return;
 
      const first = elements[0];
      const last = elements[elements.length - 1];
      const active = document.activeElement;
      const outside = !container.contains(active);
 
      if (e.shiftKey) {
        if (active === first || outside) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (active === last || outside) {
          e.preventDefault();
          first.focus();
        }
      }
    };
 
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [containerRef, isActive]);
}

3. 스크롤 잠금

import { useLayoutEffect, useState } from "react";
 
const useScrollDisable = (isOpen: boolean) => {
  const [scrollY, setScrollY] = useState<number | null>(null);
 
  useLayoutEffect(() => {
    const isFirstOpen = isOpen && scrollY === null;
 
    if (isFirstOpen) {
      const currentScrollY = window.scrollY;
      setScrollY(currentScrollY);
 
      document.body.style.position = "fixed";
      document.body.style.top = `-${currentScrollY}px`;
      document.body.style.width = "100%";
      document.body.setAttribute("data-modal-open", "true");
    }
 
    if (!isOpen && scrollY !== null) {
      document.body.removeAttribute("data-modal-open");
      document.body.style.position = "";
      document.body.style.top = "";
      document.body.style.width = "";
 
      requestAnimationFrame(() => {
        window.scrollTo(0, scrollY);
        setScrollY(null);
      });
    }
 
    return () => {
      if (scrollY !== null) {
        document.body.removeAttribute("data-modal-open");
        document.body.style.position = "";
        document.body.style.top = "";
        document.body.style.width = "";
 
        requestAnimationFrame(() => {
          window.scrollTo(0, scrollY);
        });
      }
    };
  }, [isOpen, scrollY]);
 
  return scrollY;
};
 
export default useScrollDisable;

디자인 가이드라인

모바일

모바일 모달 디자인 가이드라인

PC

pc 모달 디자인 가이드라인

📚 참고 자료


✅ 이 가이드는 실무 중심의 시각으로 모달을 해부하며 UI/UX와 접근성, 코드 구현까지 아우릅니다. 블로그 또는 팀 내 가이드로도 활용할 수 있습니다.