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 | 열리는 즉시 포커스를 패널 첫 인터랙티브 요소로 이동, 닫히면 트리거로 복귀(포커스 트랩) |
| Close | Esc·닫기 버튼·(파괴적 액션이 아니면) 스크림 클릭으로 닫힘 |
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>✕</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 추가분) 승계 |