컴포넌트 패턴
모달 대화상자
페이지 위에 떠서 사용자의 응답을 받는 동안 나머지 화면을 쓸 수 없게 하는 창입니다. 열면 포커스를 안으로 옮기고, 안에서만 맴돌게 하고, Esc로 닫으며, 닫으면 연 버튼으로 포커스를 돌려줍니다. 네이티브 <dialog>의 showModal()이 가장 좋은 출발점입니다.
언제 쓰나요
모달 대화상자(modal dialog)는 페이지 위에 겹쳐 뜨는 창으로, 열려 있는 동안 바깥 화면을 조작할 수 없는 창입니다. 사용자가 지금 꼭 응답해야 하는 일 — 확인·취소, 짧은 입력, 중요한 경고 — 에 씁니다. 바깥이 비활성(inert)이 되므로, 키보드 포커스와 화면낭독기 탐색도 대화상자 안으로 한정되어야 합니다.
- 네이티브
<dialog>+showModal()을 먼저 쓰세요. 바깥 inert 처리, Esc 닫기, 첫 포커스 이동, 닫은 뒤 포커스 복귀, 최상위 레이어(top layer) 표시를 브라우저가 처리합니다.show()나open속성으로 열면 비모달이 되므로 모달이 필요하면 반드시showModal()을 씁니다. - 되돌릴 수 없는 작업의 확인처럼 즉시 주의를 끌어야 하는 경고는
role="alertdialog"변형을 씁니다. - 모달이 꼭 필요하지 않은 경우가 많습니다. 단순한 알림은 상태 메시지, 부가 정보는 디스클로저나 별도 페이지가 더 쓰기 편합니다. 페이지 진입 직후 뜨는 광고 팝업처럼 사용자가 요청하지 않은 모달은 피합니다.
- 바깥 화면도 계속 쓸 수 있어야 한다면 비모달 대화상자(
aria-modal없음)이며, 이때는 포커스를 가두지 않습니다.
구조
button (여는 버튼) ─────────────── 닫으면 포커스가 여기로 돌아옴
dialog 또는 div[role=dialog][aria-modal=true]
├─ aria-labelledby → h2 (제목) ← 대화상자의 이름
├─ aria-describedby → p (설명, 선택) ← 열릴 때 함께 읽힘
├─ 입력 요소들
└─ 버튼들 (닫기·취소·확인) ← Tab은 이 안에서만 순환
나머지 페이지 → inert (조작·포커스·탐색 불가)| 구분 | 네이티브 <dialog> + showModal() | ARIA 방식 (div role="dialog") |
|---|---|---|
| 역할 | 암묵적 dialog (필요하면 role="alertdialog") | dialog 또는 alertdialog 명시 |
| 모달임을 알림 | 브라우저가 자동으로 모달로 노출 | aria-modal="true" |
| 바깥 비활성화 | 자동 (바깥 전체가 inert) | 직접 구현 (inert 속성 등) |
| 첫 포커스 이동 | 자동 (autofocus 요소 또는 첫 포커스 가능 요소) | 직접 구현 |
| Esc로 닫기 | 자동 (cancel → close 이벤트) | 직접 구현 |
| 포커스 복귀 | 자동 (최신 브라우저) | 직접 구현 |
| 맨 위에 표시 | top layer — z-index 경쟁 없음, ::backdrop | CSS로 직접 처리 |
키보드 동작
| 키 | 동작 |
|---|---|
| Tab | 대화상자 안의 다음 포커스 가능한 요소로 이동합니다. 마지막 요소에서는 첫 요소로 돌아옵니다. |
| Shift + Tab | 대화상자 안의 이전 요소로 이동합니다. 첫 요소에서는 마지막 요소로 갑니다. |
| Esc | 대화상자를 닫고, 대화상자를 열었던 요소로 포커스를 돌려줍니다. |
포커스는 어디로 가야 하나요
| 시점 | 포커스 위치 (APG 기준) |
|---|---|
| 열 때 (일반) | 대화상자 안의 첫 포커스 가능한 요소. 입력 양식이면 대개 첫 입력란입니다. |
| 열 때 (내용이 긴 경우) | 첫 입력 요소로 가면 위쪽 설명이 화면 밖으로 밀린다면, 제목이나 첫 문단에 tabindex="-1"을 주고 그곳에 포커스를 둡니다. |
| 열 때 (되돌릴 수 없는 작업) | “취소”처럼 가장 덜 파괴적인 동작에 둡니다. |
| 열 때 (단순 정보 표시) | “확인”·“닫기” 버튼에 두어도 됩니다. |
| 닫을 때 | 대화상자를 열었던 요소로 되돌립니다. 그 요소가 사라졌거나 다음 작업 흐름상 다른 곳이 자연스럽다면 논리적인 다음 위치로 옮깁니다. |
네이티브 dialog와 브라우저 주소창
showModal()로 연 대화상자에서 마지막 요소 다음에 Tab을 누르면, 브라우저에 따라 포커스가 대화상자 첫 요소가 아니라 브라우저 주소창으로 갔다가 돌아옵니다. 페이지의 다른 부분으로는 가지 않으므로 모달의 요구 사항은 충족하며, 오히려 사용자가 브라우저를 계속 쓸 수 있다는 장점이 있습니다. 이를 막으려고 별도의 포커스 트랩 스크립트를 덧붙일 필요는 없습니다.
필요한 ARIA
dialog- 대화상자 컨테이너의 역할입니다.
<dialog>요소에는 기본으로 들어 있습니다. 화면낭독기는 대화상자에 들어갈 때 이름과 함께 “대화상자”로 안내합니다. aria-modal="true"- 대화상자 바깥의 내용이 지금은 비활성이라고 보조기술에 알려, 가상 커서가 바깥으로 나가지 않게 합니다. 바깥을 실제로 비활성화하지는 않습니다. ARIA 방식이라면
inert와 포커스 트랩을 함께 구현해야 하며, 실제로 모달처럼 동작하지 않는 창에 붙이면 오히려 바깥 내용을 숨겨 버립니다.showModal()로 연<dialog>에는 쓰지 않아도 됩니다. aria-labelledby·aria-label- 대화상자에는 반드시 이름이 있어야 합니다. 보이는 제목이 있으면 그
id를aria-labelledby로 연결하고, 없으면aria-label을 씁니다. 네이티브<dialog>도 이름은 자동으로 생기지 않으므로 직접 연결합니다. aria-describedby(선택)- 대화상자의 목적을 설명하는 짧은 문단을 연결하면, 열릴 때 이름과 함께 읽힙니다. 표·목록·긴 본문처럼 구조가 있는 내용 전체를 연결하면 한 덩어리로 읽혀 이해하기 어려우므로 짧은 설명에만 씁니다.
alertdialog- 사용자의 즉각적인 주의와 응답이 필요한 경고·확인용 대화상자입니다. 열리면 화면낭독기가 경고음과 함께 안내할 수 있습니다. 경고 메시지를
aria-describedby로 반드시 연결하고, 포커스는 대개 “취소”에 둡니다. 동작(포커스 이동·트랩·Esc·복귀)은 일반 모달 대화상자와 같습니다.<dialog role="alertdialog">처럼 네이티브 요소에 역할만 바꿔 쓸 수 있습니다.
배경 요소에 aria-hidden을 일일이 넣지 마세요
예전에는 대화상자를 열 때 나머지 페이지에 aria-hidden="true"를 넣는 방법을 썼지만, 이 방법은 마우스·키보드 조작은 막지 못하고, 닫을 때 되돌리지 못하면 페이지 전체가 화면낭독기에서 사라지는 사고가 납니다. 지금은 showModal()을 쓰거나, 직접 만든다면 inert 속성으로 바깥을 비활성화합니다. (콘텐츠 숨기기 참고)
직접 해 보기
각 버튼으로 대화상자를 열고, Tab·Shift + Tab으로 포커스가 안에서만 맴도는지, Esc로 닫히는지, 닫은 뒤 여는 버튼으로 포커스가 돌아오는지 확인해 보세요.
잘못된 구현
실시간 코드
여는 버튼과 닫기 ×가 모두 div·span이라 키보드로 열 수도 닫을 수도 없습니다. 마우스로 열어도 포커스는 바깥에 머물고, 역할·이름이 없어 화면낭독기는 창이 떴다는 사실을 알 수 없으며, 바깥 요소도 그대로 조작됩니다.
올바른 구현 — ARIA 방식
주소 변경
배송 받을 새 주소를 입력하세요.
실시간 코드
role="dialog", aria-modal, 이름·설명 연결에 더해 포커스 이동·트랩·Esc·복귀를 스크립트로 구현했습니다. 열려 있는 동안 페이지의 나머지 부분에는 inert가 들어갑니다.
올바른 구현 — 네이티브 dialog
실시간 코드
showModal() 한 줄로 모달 동작을 얻습니다. <form method="dialog"> 안의 버튼은 누르면 대화상자를 닫고 자기 value를 returnValue로 남깁니다. 이메일을 비워 둔 채 “구독”을 누르면 조용히 닫힙니다(필수 입력이 아님).
alertdialog 변형
실시간 코드
되돌릴 수 없는 작업의 확인창입니다. 경고 문장을 aria-describedby로 연결하고, 열리면 포커스를 “삭제”가 아닌 “취소”에 둡니다.
전체 코드
네이티브 dialog (권장)
<button type="button" id="open-subscribe">뉴스레터 구독</button>
<dialog id="subscribe" aria-labelledby="subscribe-title" aria-describedby="subscribe-desc">
<h2 id="subscribe-title">뉴스레터 구독</h2>
<p id="subscribe-desc">매주 월요일 아침에 접근성 소식을 보내 드립니다.</p>
<form method="dialog">
<label for="subscribe-email">이메일</label>
<input id="subscribe-email" type="email" autocomplete="email">
<button value="cancel" formnovalidate>취소</button>
<button value="ok">구독</button>
</form>
</dialog>
<script>
initNativeDialog(
document.getElementById('open-subscribe'),
document.getElementById('subscribe'),
function (value) { if (value === 'ok') { /* 구독 처리 */ } }
);
</script>// 네이티브 <dialog>: showModal() 하나로 모달 동작 대부분을 브라우저가 처리합니다.
function initNativeDialog(openBtn, dialog, onClose) {
openBtn.addEventListener('click', function () {
dialog.returnValue = '';
dialog.showModal(); // 바깥 inert + Esc 닫기 + 첫 요소로 포커스 이동
});
dialog.addEventListener('close', function () {
// 최신 브라우저는 닫을 때 연 요소로 포커스를 돌려줍니다. 혹시 모를 경우를 대비한 보완:
if (!document.activeElement || document.activeElement === document.body) openBtn.focus();
if (onClose) onClose(dialog.returnValue); // <form method="dialog"> 버튼의 value
});
}ARIA 방식
<button type="button" id="open-address">주소 변경</button>
<div class="backdrop" id="address-backdrop" hidden>
<div role="dialog" aria-modal="true" tabindex="-1"
aria-labelledby="address-title" aria-describedby="address-desc">
<h2 id="address-title">주소 변경</h2>
<button type="button" data-close="cancel" aria-label="닫기">×</button>
<p id="address-desc">배송 받을 새 주소를 입력하세요.</p>
<label for="zip">우편번호</label>
<input id="zip" inputmode="numeric" autocomplete="postal-code" data-autofocus>
<button type="button" data-close="cancel">취소</button>
<button type="button" data-close="save">저장</button>
</div>
</div>
<script>
var modal = createModal(document.getElementById('address-backdrop'), function (result) {
if (result === 'save') { /* 저장 처리 */ }
});
document.getElementById('open-address').addEventListener('click', function (e) {
modal.open(e.currentTarget);
});
</script>// ARIA 모달 대화상자: 역할은 마크업이, 동작은 모두 스크립트가 맡습니다.
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]),' +
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function createModal(backdrop, onClose) {
var dialog = backdrop.querySelector('[role="dialog"], [role="alertdialog"]');
var opener = null;
var inerted = [];
function focusables() {
return Array.prototype.filter.call(dialog.querySelectorAll(FOCUSABLE), function (el) {
return el.getClientRects().length > 0; // 화면에 보이는 것만
});
}
// 대화상자 바깥을 inert로: 클릭·포커스·화면낭독기 탐색을 모두 막습니다.
function setOutsideInert(on) {
if (!on) {
inerted.forEach(function (el) { el.inert = false; });
inerted = [];
return;
}
var node = backdrop;
while (node.parentElement && node !== document.body) {
Array.prototype.forEach.call(node.parentElement.children, function (sib) {
if (sib !== node && !sib.inert && sib.tagName !== 'SCRIPT' && sib.tagName !== 'STYLE') {
sib.inert = true;
inerted.push(sib);
}
});
node = node.parentElement;
}
}
function open(trigger) {
opener = trigger || document.activeElement; // 닫을 때 돌아갈 곳
backdrop.hidden = false;
setOutsideInert(true);
var first = dialog.querySelector('[data-autofocus]') || focusables()[0] || dialog;
first.focus();
}
function close(result) {
backdrop.hidden = true;
setOutsideInert(false);
if (opener && document.contains(opener)) opener.focus(); // 포커스 복귀
if (onClose) onClose(result);
}
dialog.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { // Esc로 닫기
e.preventDefault();
e.stopPropagation();
close('cancel');
return;
}
if (e.key !== 'Tab') return; // 포커스 트랩
var items = focusables();
if (!items.length) { e.preventDefault(); return; }
var first = items[0], last = items[items.length - 1];
var active = document.activeElement;
if (e.shiftKey && (active === first || active === dialog)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
});
// data-close="값" 버튼은 그 값으로 닫습니다.
Array.prototype.forEach.call(dialog.querySelectorAll('[data-close]'), function (btn) {
btn.addEventListener('click', function () { close(btn.getAttribute('data-close')); });
});
// 바깥(배경) 클릭으로 닫기 (선택)
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close('cancel');
});
return { open: open, close: close };
}/* 네이티브 dialog의 배경 */
dialog::backdrop { background: rgb(0 0 0 / .5); }
/* ARIA 방식의 배경과 위치 */
.backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / .5); }
.backdrop[hidden] { display: none; }<div class="modal" aria-modal="true">
<span class="close" onclick="closeModal()">×</span>
<p>저장할까요?</p>
</div><dialog aria-labelledby="t" aria-describedby="d">
<h2 id="t">저장 확인</h2>
<p id="d">변경한 내용을 저장할까요?</p>
<form method="dialog">
<button value="cancel">취소</button>
<button value="save">저장</button>
</form>
</dialog>화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 주소 변경 대화상자가 열림 | 주소 변경, 대화상자, 배송 받을 새 주소를 입력하세요. 우편번호, 편집 창 |
| 마지막 “저장”에서 Tab | 닫기, 버튼 (대화상자 처음으로 순환) |
| Esc로 닫음 | 주소 변경, 버튼 (여는 버튼으로 포커스 복귀) |
| 네이티브 dialog가 열림 | 뉴스레터 구독, 대화상자, 매주 월요일 아침에 … 이메일, 편집 창 |
| alertdialog가 열림 | 검색 기록을 삭제할까요?, 경고 대화상자, 최근 검색어 12개가 … 취소, 버튼 |
| 잘못된 구현에서 팝업이 뜸 | (아무 안내 없음, 포커스는 그대로) |
점검 체크리스트
- 모달은
<dialog>+showModal()로 열거나,role="dialog"+aria-modal="true"와 바깥inert를 함께 구현했습니다. - 대화상자에
aria-labelledby(또는aria-label)로 이름이 있습니다. - 열면 포커스가 대화상자 안의 알맞은 요소로 이동합니다.
- Tab·Shift + Tab으로 페이지의 다른 부분에 포커스가 가지 않습니다.
- Esc로 닫히고, 보이는 닫기(취소) 버튼도 있습니다.
- 닫으면 대화상자를 연 요소로 포커스가 돌아갑니다.
- 열려 있는 동안 바깥 내용이 가상 커서로 읽히지 않고, 마우스로도 조작되지 않습니다.
- 되돌릴 수 없는 작업의 확인창은
alertdialog이고, 첫 포커스가 “취소”에 있습니다. - 대화상자가 화면보다 커질 때 안에서 스크롤되며, 포커스된 요소가 가려지지 않습니다.
- 닫기 아이콘 버튼에 “닫기” 같은 접근 가능한 이름이 있습니다.
관련 WCAG 성공 기준
- 1.3.1 정보와 관계 (A) — 대화상자 역할·이름·설명이 마크업으로 연결되어야 합니다.
- 2.1.1 키보드 (A) — 키보드로 열고, 조작하고, 닫을 수 있어야 합니다.
- 2.1.2 키보드 트랩 없음 (A) — 포커스를 가두더라도 Esc나 닫기 버튼으로 빠져나올 수 있어야 합니다.
- 2.4.3 포커스 순서 (A) — 열 때 안으로, 닫을 때 원래 자리로 포커스가 이동해야 합니다.
- 2.4.11 포커스 가려짐 없음(최소) (AA) — 대화상자 뒤에 가려진 바깥 요소로 포커스가 가면 안 됩니다.
- 4.1.2 이름, 역할, 값 (A) — 대화상자 역할과 이름이 보조기술에 전달되어야 합니다.