Modal
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
정말 삭제하시겠어요?
이 작업은 되돌릴 수 없습니다. 삭제된 데이터는 복구할 수 없으니 한 번 더 확인해 주세요.
취소삭제
Modal
실무에서 자주 사용하는 범용 모달 컴포넌트입니다.
- isOpen(on/off), title/description, size, 액션 버튼 등을 props로 제어하고
- ESC/배경 클릭으로 닫기, 포털 렌더링, 스크롤 잠금까지 한 번에 처리합니다.
기본 모달
제목/설명/본문과 확인/취소 버튼이 있는 가장 기본적인 모달 예시
Source Code
Live Preview
폼 모달
간단한 입력 폼을 포함하는 모달 예시
Source Code
Live Preview
사이즈 변경 및 ESC/배경 클릭 제어
size, closeOnBackdrop, closeOnEsc 같은 동작 관련 props 제어 예시
Small
Live Preview
Medium
Live Preview
Large
Live Preview
Full
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
Modaltypescript
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/cn';
import { Button, type ButtonVariant } from '@/components/Button/Button';
export type ModalSize = 'sm' | 'md' | 'lg' | 'full';
export interface ModalAction {
label: string;
onClick: () => void | Promise<void>;
variant?: ButtonVariant;
}
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
size?: ModalSize;
showCloseIcon?: boolean;
closeOnBackdrop?: boolean;
closeOnEsc?: boolean;
bodyClass?: string;
footerClass?: string;
children?: ReactNode;
footerNode?: ReactNode;
}
/* mobile-first: 기본=모바일(≤768 풀폭), md:=≥768px부터 카드 폭 */
const sizeClassMap: Record<ModalSize, string> = {
sm: 'max-w-full md:max-w-md',
md: 'max-w-full md:max-w-lg',
lg: 'max-w-full md:max-w-2xl',
full: 'max-w-full max-h-full md:max-w-5xl md:max-h-[90vh]',
};
export function Modal({
open,
onClose,
title,
size = 'md',
showCloseIcon = true,
closeOnBackdrop = true,
closeOnEsc = true,
bodyClass,
footerClass,
children,
footerNode,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
// ESC 로 닫기 & body 스크롤 잠금
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && closeOnEsc) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = originalOverflow;
};
}, [open, closeOnEsc, onClose]);
if (!open) return null;
if (typeof window === 'undefined') return null;
const sizeClass = sizeClassMap[size];
const handleBackdropClick = () => {
if (!closeOnBackdrop) return;
onClose();
};
const handleDialogClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
const handlePanelMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
const Dialog = (
<div
className="fixed inset-0 z-110 flex items-center justify-center bg-slate-900/50 p-0 backdrop-blur-sm md:p-4"
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{/* Backdrop */}
<div
className="absolute inset-0 cursor-pointer"
onClick={handleBackdropClick}
aria-hidden="true"
/>
{/* Panel: 기본(모바일 ≤768px)=전체 화면, md 이상=콘텐츠 높이에 fit + max-h 카드 */}
<div
ref={dialogRef}
className={cn(
'absolute inset-0 z-10 flex w-full flex-col overflow-hidden bg-white',
'h-full min-h-0 max-h-none rounded-none border-0 shadow-none ring-0',
'md:relative md:inset-auto md:h-auto md:max-h-[90vh] md:rounded-2xl md:border md:border-slate-200 md:shadow-2xl md:ring-1 md:ring-slate-200/80',
sizeClass,
)}
onClick={handleDialogClick}
onMouseDown={handlePanelMouseDown}
>
<div className="flex h-full min-h-0 flex-col">
{/* Header */}
{(title || showCloseIcon) && (
<div className="flex shrink-0 items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-5 py-4">
<div className="min-w-0 flex-1">
{title && (
<h2
id="modal-title"
className="text-base font-bold tracking-tight text-slate-900"
>
{title}
</h2>
)}
</div>
{showCloseIcon && (
<button
type="button"
onClick={onClose}
className="ml-3 flex size-9 shrink-0 items-center justify-center rounded-xl text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:ring-offset-2 cursor-pointer"
aria-label="닫기"
>
<svg
className="size-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18" />
<path d="M6 6L18 18" />
</svg>
</button>
)}
</div>
)}
{/* Body */}
<div
className={cn(
'flex min-h-0 flex-1 overflow-y-auto px-5 py-5 text-sm leading-relaxed text-slate-600',
bodyClass,
)}
>
{children}
</div>
{/* Footer */}
{footerNode && (
<div className={cn("shrink-0 border-t border-slate-200 bg-slate-50/80 px-5 py-3.5", footerClass)}>
{footerNode}
</div>
)}
</div>
</div>
</div>
);
return createPortal(Dialog, document.body);
}