Modal

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

정말 삭제하시겠어요?

이 작업은 되돌릴 수 없습니다. 삭제된 데이터는 복구할 수 없으니 한 번 더 확인해 주세요.
취소삭제

Modal

실무에서 자주 사용하는 범용 모달 컴포넌트입니다.
- isOpen(on/off), title/description, size, 액션 버튼 등을 props로 제어하고
- ESC/배경 클릭으로 닫기, 포털 렌더링, 스크롤 잠금까지 한 번에 처리합니다.

기본 모달

제목/설명/본문과 확인/취소 버튼이 있는 가장 기본적인 모달 예시

Source Code
const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>모달 열기</Button>
<Modal
  open={open}
  onClose={() => setOpen(false)}
  title="기본 모달"
  description="실무에서 가장 많이 쓰는 형태의 모달입니다."
  primaryAction={{
    label: '확인',
    onClick: () => setOpen(false),
  }}
  secondaryAction={{
    label: '취소',
    onClick: () => setOpen(false),
    variant: 'outline',
  }}
>
  모달 본문에 원하는 컴포넌트를 자유롭게 배치할 수 있습니다.
</Modal>
        
Live Preview

폼 모달

간단한 입력 폼을 포함하는 모달 예시

Source Code
const [open, setOpen] = useState(false);

<Modal
  open={open}
  onClose={() => setOpen(false)}
  title="프로필 수정"
  description="사용자 정보를 변경한 뒤 저장 버튼을 눌러주세요."
  primaryAction={{
    label: '저장',
    onClick: handleSave,
  }}
  secondaryAction={{
    label: '취소',
    onClick: () => setOpen(false),
    variant: 'outline',
  }}
>
  <form className="space-y-3">
    ...
  </form>
</Modal>
        
Live Preview

사이즈 변경 및 ESC/배경 클릭 제어

size, closeOnBackdrop, closeOnEsc 같은 동작 관련 props 제어 예시

Small
<Modal size="sm" ... />
Live Preview
Medium
<Modal size="md" ... />
Live Preview
Large
<Modal size="lg" ... />
Live Preview
Full
<Modal size="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);
}