본문 바로가기

Components · 오버레이

Modal (모달)

화면 위를 스크림으로 덮고 사용자의 확인·입력을 받는 창. 승인형 워크플로우의 확인창 등에 사용한다.

정의

현재 화면의 흐름을 잠시 멈추고 확인·입력을 받아야 할 때 띄우는 오버레이 컴포넌트다.

Anatomy

발행할까요? 확인 후 되돌릴 수 없습니다. ① 스크림 ② 패널 ③ 닫기 ④ 액션 영역
부위토큰
① 스크림--modal-overlay-bg(ink 60%) · z-index: var(--z-overlay)
② 패널bg --modal-bg · 모서리 --modal-radius · 패딩 --modal-pad · 그림자 --modal-shadow · 최대폭 --modal-max-width · z-index: var(--z-modal)
③ 닫기 버튼마크업상 패널의 마지막 요소, 히트영역 --target-min
④ 액션 영역Button 컴포넌트 재사용(Primary 1개 + Secondary/Ghost)

변형

변형용도
확인(기본)짧은 확인·경고 — 제목+1~2문장+액션 2개
입력폼 필드 1~3개를 담는 짧은 입력 — 길어지면 모달이 아니라 별도 페이지

상태

상태규칙
Open스크림 위 패널 표시, 배경 스크롤 잠금(body { overflow: hidden })
Focus열리는 즉시 포커스를 패널 첫 인터랙티브 요소로 이동, 닫히면 트리거로 복귀(포커스 트랩)
CloseEsc·닫기 버튼·(파괴적 액션이 아니면) 스크림 클릭으로 닫힘

Do / Don't

Do
  • 한 화면에 모달은 1개만 — 되돌리기 어려운 액션의 최종 확인에 쓴다.
  • 제목은 질문형 한 문장으로 결정 내용을 그대로 담는다("발행할까요?").
Don't
  • 모달 위에 모달을 띄우지 말 것 — 두 번째 확인이 필요하면 흐름 자체를 재설계한다.
  • 긴 콘텐츠(스크롤 2화면 이상)를 모달에 넣지 말 것 — 별도 페이지로.

콘텐츠 규칙

  • 제목 = 결정을 그대로 담은 질문/선언 1문장(≤20자). "알림" 같은 무정보 제목 금지.
  • 본문은 결과·되돌림 가능 여부를 1~2문장으로. 액션 라벨은 제목과 호응하는 동사형("발행하기"/"취소") — "예/아니오" 금지.
  • 파괴적 액션 라벨은 위험을 명시("삭제하기", 색은 danger 토큰).

키보드 인터랙션

동작
Esc모달 닫기(포커스는 트리거로 복귀)
Tab / Shift+Tab패널 내부에서만 포커스 순환(포커스 트랩)
Enter포커스된 액션 실행

접근성

  • role="dialog" + aria-modal="true" + aria-labelledby(제목 id 연결). 필요 시 aria-describedby(본문).
  • 포커스 트랩 필수 — 키보드·스크린리더 초점이 모달 밖으로 나가지 않게(아래 JS). 닫힘 시 트리거로 복귀.
  • 스크린리더가 배경을 읽지 않도록 배경 콘텐츠에 inert(또는 aria-hidden="true") 적용.
  • 닫기 버튼은 aria-label="닫기" + 히트영역 --target-min.

코드

html
<button class="pi-button" type="button" data-modal-open="demo-modal">발행하기</button>

<div class="pi-modal" id="demo-modal" hidden>
  <div class="pi-modal__scrim" data-modal-close></div>
  <div class="pi-modal__panel" role="dialog" aria-modal="true" aria-labelledby="demo-modal-title">
    <h2 class="pi-modal__title" id="demo-modal-title">블로그 글을 발행할까요?</h2>
    <p class="pi-modal__body">발행 후에는 비공개로만 전환할 수 있습니다.</p>
    <div class="pi-modal__actions">
      <button class="pi-button" type="button">발행하기</button>
      <button class="pi-button pi-button--ghost" type="button" data-modal-close>취소</button>
    </div>
    <button class="pi-modal__close" type="button" aria-label="닫기" data-modal-close>&#10005;</button>
  </div>
</div>
css
.pi-modal { position: fixed; inset: 0; display: grid; place-items: center; z-index: var(--z-modal); }
.pi-modal[hidden] { display: none; }
.pi-modal__scrim { position: absolute; inset: 0; background: var(--modal-overlay-bg); }
.pi-modal__panel {
  position: relative;
  background: var(--modal-bg);
  border-radius: var(--modal-radius);
  padding: var(--modal-pad);
  box-shadow: var(--modal-shadow);
  width: min(var(--modal-max-width), calc(100% - var(--space-8)));
  word-break: keep-all;
}
.pi-modal__title { font: var(--text-h3); letter-spacing: var(--text-h3-letter-spacing); color: var(--color-text-heading); margin: 0 0 var(--space-3); }
.pi-modal__body { font: var(--text-body); color: var(--color-text-secondary); margin: 0 0 var(--space-6); }
.pi-modal__actions { display: flex; gap: var(--space-3); }
.pi-modal__close {
  position: absolute; top: var(--space-3); right: var(--space-3);
  min-width: var(--target-min); min-height: var(--target-min);
  background: none; border: none; cursor: pointer;
  color: var(--color-text-muted); font-size: var(--font-size-lg);
}
.pi-modal__close:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
js
// 포커스 트랩 + 열기/닫기 (의존성 없음)
document.querySelectorAll("[data-modal-open]").forEach((trigger) => {
  const modal = document.getElementById(trigger.dataset.modalOpen);
  if (!modal) return;
  const panel = modal.querySelector(".pi-modal__panel");
  const focusables = () => panel.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])");
  const open = () => { modal.hidden = false; document.body.style.overflow = "hidden"; focusables()[0]?.focus(); };
  const close = () => { modal.hidden = true; document.body.style.overflow = ""; trigger.focus(); };
  trigger.addEventListener("click", open);
  modal.querySelectorAll("[data-modal-close]").forEach((el) => el.addEventListener("click", close));
  modal.addEventListener("keydown", (e) => {
    if (e.key === "Escape") return close();
    if (e.key !== "Tab") return;
    const f = focusables(); const first = f[0]; const last = f[f.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });
});

Changelog

날짜변경
2026-07-15문서 계약(10섹션) 적용 — Anatomy 도해·콘텐츠 규칙·포커스 트랩 실코드 신설. KRDS 벤치마킹 규격(2026-07-15 추가분) 승계