이 글은 누구를 위한 것인가
- 모달 vs 바텀시트 vs 사이드 패널 중 무엇을 써야 할지 모르는 팀
- 모달을 남발해서 "모달 안에 모달" 상황이 생긴 팀
- 모달 접근성(포커스 트랩, ESC 닫기)을 구현하고 싶은 개발자
들어가며
"모달이 불편해요"의 진짜 이유는 모달이 아니라 맥락 단절이다. 작업 흐름을 끊지 않아야 할 때 모달을 쓰면 불편하다. 오버레이 유형을 잘 선택하면 작업 흐름을 유지하면서 추가 정보를 제공할 수 있다.
이 글은 bluefoxdev.kr의 오버레이 UI 설계 가이드 를 참고하여 작성했습니다.
1. 오버레이 유형 선택 가이드
[오버레이 유형별 적합한 상황]
모달 다이얼로그:
✅ 중요한 확인/취소 (삭제 확인, 결제 확인)
✅ 사용자의 즉각적인 결정이 필요한 경우
✅ 복잡한 폼 (빠른 추가 작성)
❌ 단순 정보 표시
❌ 복잡한 멀티스텝 플로우
바텀시트 (Bottom Sheet):
✅ 모바일에서 옵션 선택 (공유, 정렬, 필터)
✅ 아래에서 올라오는 메뉴
❌ 텍스트 많은 폼 (키보드가 올라오면 복잡)
사이드 패널 (Drawer):
✅ 관련 정보 탐색 (상품 상세, 주문 상세)
✅ 설정 패널
✅ 메인 콘텐츠 맥락 유지가 필요할 때
팝오버 (Popover):
✅ 선택 도구 (컬러 피커, 날짜 선택)
✅ 참조 정보 (주석, 도움말)
툴팁:
✅ 아이콘/버튼 레이블 설명
❌ 중요한 정보 (접근성 문제)
[중첩 모달 회피 전략]
모달 안의 모달 → 대신 인라인 확장
모달 안의 선택 → 바텀시트로 분리
복잡한 플로우 → 전체 페이지로 이동
2. 접근성 모달 구현
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: "sm" | "md" | "lg";
}
function Dialog({ isOpen, onClose, title, children, size = "md" }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
// 열릴 때 이전 포커스 저장
previousFocusRef.current = document.activeElement;
// 포커스 이동
setTimeout(() => {
dialogRef.current?.focus();
}, 50);
} else {
// 닫힐 때 이전 포커스 복원
(previousFocusRef.current as HTMLElement)?.focus();
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === "Escape") {
onClose();
return;
}
// 포커스 트랩
if (e.key === "Tab") {
const focusable = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-2xl",
};
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
>
{/* 배경 오버레이 */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* 다이얼로그 패널 */}
<div
ref={dialogRef}
tabIndex={-1}
className={`relative bg-white rounded-2xl shadow-xl w-full ${sizeClasses[size]} outline-none`}
>
{/* 헤더 */}
<div className="flex items-center justify-between p-6 border-b">
<h2 id="dialog-title" className="text-lg font-semibold">
{title}
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="닫기"
>
✕
</button>
</div>
{/* 내용 */}
<div className="p-6">{children}</div>
</div>
</div>,
document.body
);
}
마무리
모달 사용의 첫 번째 질문은 "이게 정말 모달이어야 하나?"다. 삭제·결제·중요한 결정만 모달을 쓰고, 나머지는 인라인 확장·사이드 패널·바텀시트로 대체하면 사용자 경험이 훨씬 부드러워진다. 모달을 꼭 써야 한다면 ESC 닫기와 포커스 트랩은 반드시 구현해야 한다.