Modal Patterns
모달 및 다이얼로그 구현 패턴입니다.
개요
Modal Patterns는 사용자의 주의를 집중시키고, 중요한 작업을 완료하도록 유도하며, 접근 가능하고 사용하기 쉬운 오버레이 UI를 제공하는 검증된 구현 방법입니다. 기본 모달, 중첩 모달, 확인 다이얼로그, 드로어, 포커스 트랩 등 프로덕션 환경에서 필요한 모든 모달 시나리오를 다룹니다.
사용 사례:
- 폼 입력 (회원가입, 로그인)
- 확인 다이얼로그 (삭제, 취소)
- 상세 정보 표시
- 이미지/비디오 뷰어
- 다단계 플로우 (마법사 UI)
사용하지 말아야 할 때:
- 긴 콘텐츠 (별도 페이지 사용)
- 복잡한 워크플로 (페이지 분할)
- 자주 사용하는 기능 (인라인 표시)
기본 패턴
1. 기본 모달 구현
간단한 모달 다이얼로그 구현 패턴입니다.
"use client";
import { useState } from "react";
import { Button } from "@vortex/ui-foundation";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Content */}
<div className="relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 z-10">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 id="modal-title" className="text-xl font-bold">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
aria-label="닫기"
>
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Body */}
<div className="mb-6">{children}</div>
{/* Footer */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
취소
</Button>
<Button onClick={onClose}>확인</Button>
</div>
</div>
</div>
);
}
// 사용 예시
export default function BasicModalExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>모달 열기</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="알림">
<p>이것은 기본 모달입니다.</p>
</Modal>
</>
);
}2. 확인 다이얼로그
사용자의 확인이 필요한 작업을 처리하는 패턴입니다.
"use client";
import { useState } from "react";
import { Button, Alert } from "@vortex/ui-foundation";
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "danger" | "warning" | "info";
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "warning",
}: ConfirmDialogProps) {
if (!isOpen) return null;
const handleConfirm = () => {
onConfirm();
onClose();
};
const variantColors = {
danger: "bg-red-600 hover:bg-red-700",
warning: "bg-orange-600 hover:bg-orange-700",
info: "bg-blue-600 hover:bg-blue-700",
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 z-10">
<Alert
variant={variant === "danger" ? "destructive" : "default"}
className="mb-4"
>
<h3 className="font-bold text-lg">{title}</h3>
<p className="mt-2">{message}</p>
</Alert>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{cancelText}
</Button>
<Button className={variantColors[variant]} onClick={handleConfirm}>
{confirmText}
</Button>
</div>
</div>
</div>
);
}
// 사용 예시
export default function ConfirmDialogExample() {
const [isOpen, setIsOpen] = useState(false);
const handleDelete = () => {
console.log("항목이 삭제되었습니다");
};
return (
<>
<Button variant="destructive" onClick={() => setIsOpen(true)}>
삭제
</Button>
<ConfirmDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onConfirm={handleDelete}
title="정말 삭제하시겠습니까?"
message="이 작업은 되돌릴 수 없습니다."
confirmText="삭제"
cancelText="취소"
variant="danger"
/>
</>
);
}고급 패턴
3. 포커스 트랩 구현
모달 내에서 키보드 포커스를 가두는 접근성 패턴입니다.
"use client";
import { useEffect, useRef } from "react";
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
// 포커스 가능한 모든 요소 선택
const focusableElements = container.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
// 첫 번째 요소에 포커스
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
// Shift + Tab (역방향)
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
}
// Tab (정방향)
else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// ESC로 모달 닫기 (부모 컴포넌트에서 처리)
const event = new CustomEvent("modal-escape");
container.dispatchEvent(event);
}
};
container.addEventListener("keydown", handleKeyDown);
container.addEventListener("keydown", handleEscape);
return () => {
container.removeEventListener("keydown", handleKeyDown);
container.removeEventListener("keydown", handleEscape);
};
}, [isActive]);
return containerRef;
}
// 사용 예시
export function AccessibleModal({ isOpen, onClose, children }) {
const containerRef = useFocusTrap(isOpen);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleEscape = () => onClose();
container.addEventListener("modal-escape", handleEscape);
return () => container.removeEventListener("modal-escape", handleEscape);
}, [onClose]);
if (!isOpen) return null;
return (
<div
ref={containerRef}
className="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
>
{/* Modal content */}
{children}
</div>
);
}4. 중첩 모달 관리
여러 모달을 동시에 관리하는 패턴입니다.
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
interface ModalContextType {
modals: string[];
openModal: (id: string) => void;
closeModal: (id: string) => void;
closeAllModals: () => void;
}
const ModalContext = createContext<ModalContextType | undefined>(undefined);
export function ModalProvider({ children }: { children: ReactNode }) {
const [modals, setModals] = useState<string[]>([]);
const openModal = (id: string) => {
setModals((prev) => [...prev, id]);
};
const closeModal = (id: string) => {
setModals((prev) => prev.filter((m) => m !== id));
};
const closeAllModals = () => {
setModals([]);
};
return (
<ModalContext.Provider
value={{ modals, openModal, closeModal, closeAllModals }}
>
{children}
</ModalContext.Provider>
);
}
export function useModal(id: string) {
const context = useContext(ModalContext);
if (!context) throw new Error("useModal must be used within ModalProvider");
const isOpen = context.modals.includes(id);
const zIndex = 50 + context.modals.indexOf(id);
return {
isOpen,
zIndex,
open: () => context.openModal(id),
close: () => context.closeModal(id),
};
}
// 사용 예시
export function NestedModalExample() {
const modal1 = useModal("modal-1");
const modal2 = useModal("modal-2");
return (
<>
<button onClick={modal1.open}>첫 번째 모달 열기</button>
{modal1.isOpen && (
<div className="fixed inset-0" style={{ zIndex: modal1.zIndex }}>
<div className="fixed inset-0 bg-black/50" onClick={modal1.close} />
<div className="relative bg-white p-6 rounded-lg m-auto mt-20 max-w-md">
<h2>첫 번째 모달</h2>
<button onClick={modal2.open}>두 번째 모달 열기</button>
</div>
</div>
)}
{modal2.isOpen && (
<div className="fixed inset-0" style={{ zIndex: modal2.zIndex }}>
<div className="fixed inset-0 bg-black/50" onClick={modal2.close} />
<div className="relative bg-white p-6 rounded-lg m-auto mt-32 max-w-md">
<h2>두 번째 모달</h2>
<p>중첩 모달 예제</p>
</div>
</div>
)}
</>
);
}5. 드로어 (Drawer) 패턴
측면에서 슬라이드되는 패널 구현 패턴입니다.
"use client";
import { useState } from "react";
import { Button } from "@vortex/ui-foundation";
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
position?: "left" | "right" | "top" | "bottom";
children: React.ReactNode;
}
export function Drawer({
isOpen,
onClose,
position = "right",
children,
}: DrawerProps) {
const positions = {
left: "left-0 top-0 bottom-0 w-80",
right: "right-0 top-0 bottom-0 w-80",
top: "top-0 left-0 right-0 h-80",
bottom: "bottom-0 left-0 right-0 h-80",
};
const slideAnimations = {
left: isOpen ? "translate-x-0" : "-translate-x-full",
right: isOpen ? "translate-x-0" : "translate-x-full",
top: isOpen ? "translate-y-0" : "-translate-y-full",
bottom: isOpen ? "translate-y-0" : "translate-y-full",
};
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 bg-black/50 transition-opacity z-40 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Drawer Panel */}
<div
className={`fixed ${positions[position]} bg-white shadow-xl z-50 transition-transform duration-300 ${slideAnimations[position]}`}
role="dialog"
aria-modal="true"
>
<div className="p-6 h-full overflow-y-auto">
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
aria-label="닫기"
>
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{children}
</div>
</div>
</>
);
}
// 사용 예시
export default function DrawerExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>메뉴 열기</Button>
<Drawer isOpen={isOpen} onClose={() => setIsOpen(false)} position="right">
<h2 className="text-2xl font-bold mb-4">메뉴</h2>
<nav className="space-y-2">
<a href="#" className="block p-2 hover:bg-gray-100 rounded">
홈
</a>
<a href="#" className="block p-2 hover:bg-gray-100 rounded">
설정
</a>
<a href="#" className="block p-2 hover:bg-gray-100 rounded">
도움말
</a>
</nav>
</Drawer>
</>
);
}Best Practices
✅ 권장 사항
-
접근성 우선
role="dialog",aria-modal="true"필수aria-labelledby로 제목 연결- ESC 키로 닫기 지원
- 포커스 트랩 구현
-
사용자 경험
- 배경 클릭으로 닫기 (확인 다이얼로그 제외)
- 부드러운 애니메이션 (300ms 권장)
- 모바일 반응형 (전체 화면 또는 하단 시트)
-
성능
- Portal 사용 (React Portal)
- 모달 미사용 시 unmount
- 중첩 모달은 최대 2-3개로 제한
-
스타일
- backdrop은 반투명 어둡게 (50% opacity)
- z-index 체계적 관리 (50, 51, 52…)
- 최대 너비 제한 (max-w-md ~ max-w-4xl)
-
상태 관리
- Context API로 전역 모달 관리
- URL 상태와 동기화 (딥 링크 지원)
- 뒤로가기 시 모달 닫기
⚠️ 피해야 할 것
-
접근성 문제
- 포커스 트랩 없음
- 키보드 네비게이션 미지원
- 스크린 리더 미지원
-
UX 문제
- 모달 안에 너무 많은 콘텐츠
- 모달 닫기 방법 부족
- 갑작스러운 등장/사라짐 (애니메이션 없음)
-
성능 문제
- body 스크롤 미차단
- 과도한 중첩 모달
- 모달 내 무거운 컴포넌트
보안 고려사항
XSS 방어
// ❌ 나쁜 예: HTML 직접 삽입
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ✅ 좋은 예: React가 자동 escape
<div>{userInput}</div>Foundation 예제
범용 확인 다이얼로그
Foundation 컴포넌트로 구현한 중립적인 확인 다이얼로그입니다.
import { Button, Alert } from "@vortex/ui-foundation";
export function FoundationDialog({ isOpen, onClose, onConfirm }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md">
<Alert>
<h3 className="font-bold">확인 필요</h3>
<p>이 작업을 계속하시겠습니까?</p>
</Alert>
<div className="flex gap-2 mt-4">
<Button variant="outline" onClick={onClose}>
취소
</Button>
<Button onClick={onConfirm}>확인</Button>
</div>
</div>
</div>
);
}iCignal 예제
데이터 세부정보 모달
iCignal Blue 브랜드를 적용한 데이터 상세 모달입니다.
import "@vortex/ui-icignal/theme";
import { Button, Card } from "@vortex/ui-icignal";
export function ISignalModal({ isOpen, onClose, data }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<Card className="relative p-6 max-w-2xl border-blue-500 z-10">
<h2 className="text-xl font-bold text-blue-600 mb-4">데이터 상세</h2>
<div className="space-y-2">
<p>지표: {data.metric}</p>
<p>값: {data.value}</p>
</div>
<Button
variant="primary"
className="mt-4 bg-blue-500"
onClick={onClose}
>
닫기
</Button>
</Card>
</div>
);
}Cals 예제
예약 확인 다이얼로그
Cals Pink 브랜드를 적용한 예약 확인 다이얼로그입니다.
import "@vortex/ui-cals/theme";
import { Button, Alert, Badge } from "@vortex/ui-cals";
export function CalsConfirm({ isOpen, onClose, onConfirm, booking }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md border-2 border-pink-500">
<h2 className="text-xl font-bold text-pink-600 mb-4">예약 확인</h2>
<Alert className="mb-4 border-pink-200">
<p>고객: {booking.customer}</p>
<p>일시: {booking.datetime}</p>
<Badge variant="confirmed">확정 예정</Badge>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
취소
</Button>
<Button variant="primary" className="bg-pink-500" onClick={onConfirm}>
예약 확정
</Button>
</div>
</div>
</div>
);
}CodeSandbox
CodeSandbox 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-modal-project --template vite-react cd my-modal-project -
컴포넌트 추가
# Foundation npx @vortex/cli add button alert --package foundation # iCignal npx @vortex/cli add button card --package icignal # Cals npx @vortex/cli add button alert badge --package cals -
코드 복사 및 실행
pnpm dev
관련 패턴
- Form Validation - 모달 폼 검증
- Accessibility Patterns - 접근 가능한 모달
- Responsive Design - 모바일 모달
Last updated on