모달을 만들었습니다. Tab을 눌렀더니 배경 버튼들이 포커스됩니다. ESC를 눌러도 안 닫힙니다. 스크린 리더는 "대화 상자"라는 안내도 없이 내용을 쭉 읽어 버리고요. 닫기 버튼을 눌렀더니 포커스가 어딘가로 사라졌습니다.
이 네 가지 문제, 모달을 만들다 보면 한 번씩 다 겪습니다. 포커스 트랩이라는 이름은 참 좋은데, 문제는 트랩이 엉뚱한 데 걸린다는 거죠. 하나씩 제대로 풀어 볼게요.
모달 접근성에서 챙길 것#
모달은 화면으로 보면 상자 하나지만, 키보드와 스크린 리더로 쓰는 사람에게는 이런 약속이 필요합니다. 열리면 포커스가 모달 안으로 들어가고, 모달 뒤의 배경은 건드릴 수 없고, ESC로 닫히고, 닫히면 열었던 버튼으로 돌아온다. 스크린 리더에는 대화 상자라는 것과 그 제목이 들리고, 화면에서는 뒤의 페이지가 따라 스크롤되지 않는다.
다행히 이 약속의 대부분을 브라우저가 대신 지켜 주는 방법이 있습니다.
dialog 요소와 showModal(): 브라우저가 해 주는 것#
<dialog> 요소를 showModal()로 열면 대부분이 해결됩니다. 2022년 3월 사파리 15.4와 파이어폭스 98이 합류하면서 주요 브라우저가 모두 지원해요.

<h2 id="list-heading" tabindex="-1">항목 목록</h2>
<button type="button" id="open-modal-btn">항목 삭제</button>
<dialog id="confirm-modal" aria-labelledby="modal-title">
<h2 id="modal-title">정말 삭제하시겠습니까?</h2>
<p>이 작업은 되돌릴 수 없습니다.</p>
<div class="modal-actions">
<button type="button" id="modal-cancel">취소</button>
<button type="button" id="modal-confirm">삭제</button>
</div>
</dialog>showModal()로 열면 브라우저가 이렇게 해 줍니다.
- 포커스를 모달 안으로 옮깁니다(보통 첫 번째 포커스 가능 요소로)
- ESC를 누르면 닫습니다
::backdrop가상 요소로 배경 오버레이를 깝니다- 모달 바깥의 페이지를
inert(비활성) 상태로 만듭니다.inert인 영역은 포커스도, 클릭도, 스크린 리더 탐색도 닿지 않아요. 화면에는 보이지만 없는 셈 치는 거죠 - 닫히면
showModal()을 부르던 순간 포커스가 있던 요소로 포커스를 돌려줍니다
show()로 열면 이 중 어느 것도 일어나지 않습니다. 반드시 showModal()이어야 해요. 리액트에서 <dialog open={isOpen}>처럼 open 속성으로 여는 것도 show()와 같아서 배경이 막히지 않으니, ref로 showModal()과 close()를 부르세요.
브라우저가 대신 해 주지 않는 게 두 가지 있습니다. 하나는 배경 스크롤 막기이고, 다른 하나는 연 버튼 기억이에요. 사파리(맥·iOS 모두)는 버튼을 클릭해도 그 버튼에 포커스를 주지 않습니다. 그래서 마우스로 연 다이얼로그는 "열던 순간의 포커스"가 연 버튼이 아니고, 닫으면 포커스가 엉뚱한 데로 갑니다(WebKit에 버그로 올라가 있고 2026년 10월 현재 열려 있어요). 연 버튼을 직접 기억해 두면 해결돼요.
아래 코드는 스크롤 막기 함수(lockScroll·unlockScroll, 다음 절)를 함께 씁니다. 두 코드를 같은 <script>에 넣고 </body> 바로 앞에 두세요.
const modal = document.getElementById('confirm-modal');
const openBtn = document.getElementById('open-modal-btn');
let opener = null;
openBtn.addEventListener('click', () => {
opener = openBtn; // 사파리를 위해 연 버튼을 직접 기억한다
modal.showModal(); // show()가 아니라 showModal()
lockScroll(); // 스크롤 막기는 브라우저가 해 주지 않는다(다음 절)
});
document.getElementById('modal-cancel')
.addEventListener('click', () => modal.close());
document.getElementById('modal-confirm')
.addEventListener('click', () => {
// 실제 삭제 처리는 여기에
modal.close();
});
// 취소, 삭제, ESC 어느 쪽으로 닫혀도 close 이벤트는 한 번 온다
modal.addEventListener('close', () => {
unlockScroll();
// 연 버튼으로 돌려보내 보고, 지워졌거나 숨겨져서 못 받으면 목록 제목으로
opener?.focus();
if (document.activeElement !== opener) {
document.getElementById('list-heading').focus();
}
});정리는 버튼마다 달지 말고 close 이벤트 한 곳에서 하는 게 핵심입니다. ESC는 버튼 클릭 코드를 거치지 않고 바로 닫거든요. 취소 버튼에만 스크롤 풀기를 걸어 두면, ESC로 닫은 사용자는 페이지가 잠긴 채로 남습니다. 안드로이드의 뒤로 가기 제스처도 다이얼로그를 닫는데, 이것 역시 버튼을 거치지 않아요.
마지막 if는 "항목 삭제"처럼 연 버튼이 든 줄이 지워지거나, 행 메뉴가 닫히면서 버튼이 숨겨지는 경우를 대비한 것입니다. 그런 버튼은 focus()를 불러도 포커스를 받지 못하니, 대신 목록 제목처럼 가까운 곳으로 보냅니다. 제목처럼 원래 포커스를 받지 않는 요소라면 tabindex="-1"을 붙여 둬야 focus()가 먹어요. 아무 데도 보내지 않으면 포커스가 body로 떨어지고, 키보드 사용자는 다음 Tab에서 엉뚱한 곳으로 튕겨 나갑니다.
showModal()에서 Tab이 주소창으로 나가는 건 정상입니다#
showModal()로 연 모달에서 마지막 버튼까지 간 뒤 Tab을 한 번 더 누르면, 포커스가 모달 첫 버튼으로 돌아오지 않고 브라우저 주소창 같은 UI로 나갑니다(크롬 155에서 확인). 처음 보면 트랩이 샌 것 같아서 자바스크립트로 막고 싶어지는데, 막지 않는 게 맞아요.
HTML 명세를 관리하는 WHATWG에 바로 이걸 막자는 제안(이슈 #8339)이 올라왔지만, 지금 동작을 유지하는 쪽으로 정리되고 2023년 11월에 닫혔습니다. 결정에는 W3C 접근성 그룹(APA)의 의견이 반영됐어요. 키보드 사용자도 모달이 떠 있는 동안 새 탭을 열거나 브라우저 설정을 바꿀 수 있어야 하고, 키오스크처럼 빠져나갈 길이 필요한 환경도 있다는 거죠. 지켜야 하는 건 배경 페이지로 새지 않는 것이고, 그건 inert가 이미 해 줍니다.
W3C의 ARIA Authoring Practices Guide(APG) 모달 패턴은 "Tab이 대화 상자 안에서 순환한다"고 적고 있어서 헷갈릴 수 있는데, APG는 div로 직접 만드는 대화 상자의 패턴이고 <dialog> 요소는 다루지 않습니다. 그래서 이 글은 <dialog>에는 브라우저 동작을 그대로 두고, 직접 만드는 div 모달에는 APG대로 순환을 넣습니다.
모달 스크롤 막기: dialog와 div 공통#
모달이 열려 있을 때 뒤의 페이지가 스크롤되면 지금 어디에 있는지 놓치기 쉽습니다. <dialog>를 써도 직접 해야 하는 부분이에요.
// 단순한 방법: 대부분은 되지만 iOS 사파리에서는 뒤가 스크롤될 수 있다
// document.body.style.overflow = 'hidden';
// 더 안정적인 방법: 지금 위치에서 body를 고정한다
function lockScroll() {
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
}
function unlockScroll() {
const top = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
// 원래 위치로 되돌린다. instant가 없으면 scroll-behavior: smooth인
// 사이트에서 굴러 내려간다
window.scrollTo({
top: parseInt(top || '0', 10) * -1,
behavior: 'instant'
});
}position: fixed로 고정하면 스크롤 위치가 맨 위로 초기화되기 때문에, 열 때 위치를 top에 적어 두었다가 닫을 때 되돌립니다. iOS 사파리에서 overflow: hidden이 새던 문제는 사파리 26.4에서 상당 부분 고쳐졌지만, 비슷한 증상이 아직 따로 보고돼 있고 그 전 버전을 쓰는 사람도 많아서 position: fixed 쪽이 더 안전합니다. 두 방식을 섞어 쓰면 복원이 어긋나니 하나만 쓰세요.
윈도처럼 스크롤바가 자리를 차지하는 환경에서는 스크롤을 잠그는 순간 스크롤바가 사라지면서 본문이 그 폭만큼 옆으로 밀립니다(맥 기본 설정처럼 스크롤바가 내용 위에 겹쳐 뜨면 생기지 않아요). 흔히 html { scrollbar-gutter: stable; }로 스크롤바 자리를 늘 비워 두는 방법을 쓰는데, 이 글은 그런 환경에서 직접 확인하지 못했어요. 윈도에서 모달을 열고 닫으며 본문이 움직이는지 한 번 봐 주세요.
직접 만들까, 라이브러리를 쓸까#
모달을 만들기 전에 이 순서로 고르면 됩니다.
<dialog>와showModal()을 쓸 수 있으면 그걸 씁니다. 대부분은 여기서 끝납니다.- 리액트 같은 프레임워크를 쓰고 디자인 시스템에 맞춘 모달이 필요하면, 접근성을 관리해 주는 라이브러리의 다이얼로그를 씁니다. React Aria(어도비), Radix UI, Headless UI의 Dialog가 대표적이고, 프레임워크 없이 트랩만 필요하면 focus-trap 패키지가 있습니다.
- 둘 다 안 되는 경우에만
div로 직접 만듭니다.
이 글의 나머지 절반은 3번, 직접 만드는 경우입니다. 직접 만들 일이 없더라도 읽어 두면 쓸모가 있어요. 라이브러리나 <dialog>가 뒤에서 무슨 일을 해 주는지 알아야 고장 났을 때 어디를 볼지 압니다.
직접 만들 때는 다섯 조각을 차례로 붙입니다. 마크업과 ARIA(1), inert로 배경 막기(2), 포커스 트랩(3), 열고 닫기와 포커스 복원(4), ESC와 버튼 연결(5). 그리고 앞의 스크롤 막기 함수를 같이 씁니다. 자바스크립트는 모두 하나의 <script>에 이어 붙여 </body> 바로 앞에 두면 됩니다.
role="dialog"와 aria-modal: 대화 상자라고 알리기 (직접 만들기 1)#
<dialog>가 아니면 이게 대화 상자라는 걸 ARIA로 알려야 합니다. 모달 div는 <body>의 직계 자식으로 둡니다(이유는 다음 절에서 나옵니다).
<body>
<main>
<h2 id="custom-list-heading" tabindex="-1">항목 목록</h2>
<button type="button" id="open-custom-modal">항목 삭제</button>
<!-- 나머지 페이지 내용 -->
</main>
<div
id="custom-modal"
role="dialog"
aria-modal="true"
aria-labelledby="custom-modal-title"
aria-describedby="custom-modal-desc"
tabindex="-1"
hidden
>
<h2 id="custom-modal-title">삭제 확인</h2>
<p id="custom-modal-desc">
선택한 항목을 삭제합니다. 이 작업은 되돌릴 수 없습니다.
</p>
<button type="button" id="custom-modal-cancel">취소</button>
<button type="button" id="custom-modal-confirm">삭제</button>
</div>
</body>role="dialog": "여기는 대화 상자입니다"aria-modal="true": "이 대화 상자 바깥은 지금 쓸 수 없습니다"aria-labelledby: 대화 상자의 제목 요소를 이름으로 연결aria-describedby: 본문 설명을 연결(선택)tabindex="-1": 안에 포커스 가능한 요소가 하나도 없을 때 모달 자체에 포커스를 줄 수 있게
모달이 열리면 스크린 리더는 보통 "삭제 확인, 대화 상자"처럼 제목과 역할을 알리고, 포커스가 간 "취소" 버튼을 읽습니다. 다만 스크린 리더마다 읽는 순서와 문구가 다르고, 이 글은 실제로 들어 보며 재지는 않았어요. 애플 기기의 VoiceOver에는 <dialog>와 관련된 버그도 열려 있어요. iOS에서는 aria-labelledby를 쓴 다이얼로그를 showModal()로 열어도 안내하지 않는 문제가, 맥에서는 다이얼로그 안의 요소와 상호작용이 안 되는 문제가 보고돼 있습니다(참고 자료의 WebKit 버그 273846, 302351).
aria-modal="true"는 스크린 리더에게 하는 선언일 뿐, 배경을 실제로 막지는 않습니다. 실제로 막는 건 다음 절의 inert예요.
role·aria-*를 언제 쓰고 언제 쓰지 말아야 하는지 더 알고 싶다면 ARIA 실전 가이드의 「ARIA의 황금률」을 보세요.
첫 포커스를 어디에 둘지도 정해 두세요. 되돌릴 수 없는 작업을 묻는 모달이라면 "삭제"가 아니라 "취소"에 포커스가 먼저 가야, Enter를 무심코 눌러도 아무 일이 일어나지 않습니다. 이 예제는 "취소"가 앞에 있어서 그렇게 됩니다. 사실 삭제 확인처럼 사용자의 응답을 꼭 받아야 하는 경고성 창은 role="alertdialog"가 더 정확한 역할이고, 그때는 aria-describedby로 경고 내용을 꼭 연결해야 합니다. 이 글은 <dialog>와 같은 역할로 나란히 비교하려고 role="dialog"로 뒀어요.
화면 한가운데에 띄우는 최소한의 스타일입니다.
#custom-modal {
position: fixed;
inset: 0;
margin: auto;
box-sizing: border-box;
width: min(400px, 90vw);
height: fit-content;
max-height: calc(100dvh - 2rem); /* 화면보다 길면 */
overflow-y: auto; /* 모달 안에서 스크롤한다 */
padding: 24px;
background: #fff;
border: 1px solid transparent; /* 고대비 모드에서 테두리로 보인다 */
border-radius: 8px;
box-shadow: 0 0 0 100vmax rgb(0 0 0 / 0.5); /* 바깥을 어둡게 덮는다 */
}max-height와 overflow-y가 빠지면, 모바일이나 화면을 크게 확대한 사용자에게 긴 모달의 버튼이 화면 밖에 남습니다. 뒤 페이지는 스크롤이 잠겨 있으니 그 버튼에 영영 닿을 수 없어요. 투명 테두리는 윈도 고대비 모드를 위한 것입니다. 그 모드에서는 box-shadow가 사라지는데, 투명 테두리는 시스템 색 선으로 바뀌어 모달의 경계를 남겨 줍니다(고대비 모드 제대로 지원하기).
여기에 display를 주지 않은 데도 이유가 있어요. #custom-modal { display: flex; }처럼 쓰면 hidden 속성보다 CSS가 이겨서, 닫아도 모달이 계속 보입니다. display가 필요하면 #custom-modal[hidden] { display: none; }을 같이 적으세요.
inert로 배경 막기 (직접 만들기 2)#
aria-modal이 선언이라면 inert는 실제 잠금입니다. inert가 걸린 요소와 그 안의 모든 것은 포커스, 클릭, 스크린 리더 탐색이 모두 막혀요. 2023년 4월 파이어폭스 112를 끝으로 주요 브라우저가 모두 지원합니다.

function setInert(modal, on) {
// body 바로 아래 요소 중에서 모달을 품은 것만 남기고 모두 잠근다
const keep = Array.from(document.body.children)
.find((el) => el.contains(modal));
for (const el of document.body.children) {
if (el !== keep) el.inert = on;
}
}모달을 품은 요소를 찾아서 빼기 때문에, 리액트 포털처럼 모달을 <div id="modal-root"> 안에 그리는 구조에서도 modal-root만 남고 나머지가 잠깁니다. 반대로 모달이 <main> 안쪽 깊숙이 있으면 <main> 전체가 남아 배경 대부분이 열려 있게 돼요. 그래서 모달은 <body> 바로 아래(또는 포털 컨테이너)에 둡니다.
이 절이 사실 가장 중요합니다. inert만 걸어도 Tab은 배경으로 새지 않아요. 배경이 통째로 Tab 순서에서 빠지니, 포커스는 모달 안의 버튼과 브라우저 UI 사이만 오갑니다. showModal()로 연 <dialog>와 똑같은 동작이에요. 다음 절의 포커스 트랩은 여기에 "모달 안에서만 돈다"는 순환을 더하는 장치입니다.
포커스 트랩 구현: Tab을 모달 안에서 돌리기 (직접 만들기 3)#
APG의 모달 패턴은 마지막 요소에서 Tab을 누르면 첫 요소로, 첫 요소에서 Shift+Tab을 누르면 마지막 요소로 돌아오라고 권합니다. 브라우저 UI로 나가지 않고 모달 안을 맴도는 거죠. 이 절의 두 함수는 다음 절의 Modal이 그대로 쓰니, 설명은 건너뛰더라도 코드는 꼭 붙여 주세요.

먼저 "Tab으로 갈 수 있는 요소"를 골라내는 함수입니다.
// Tab으로 갈 수 있는 요소 후보
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'iframe',
'audio[controls]',
'video[controls]',
'summary',
'[contenteditable]:not([contenteditable="false"])',
'[tabindex]',
].join(',');
function getFocusableElements(container) {
return Array.from(
container.querySelectorAll(FOCUSABLE_SELECTOR)
).filter(
(el) =>
// tabindex="-1"은 Tab 순서에서 빠진다
el.tabIndex >= 0 &&
// 비활성 영역 안은 제외
!el.closest('[inert]') &&
// display:none·hidden이면 상자가 없다
el.getClientRects().length > 0 &&
// 자리만 있고 안 보이는 요소도 제외
getComputedStyle(el).visibility !== 'hidden'
);
}input:not([type="hidden"])이 빠지면 폼에 흔히 들어가는 숨은 입력(CSRF 토큰 같은)이 "첫 번째 요소"로 뽑혀 포커스 이동이 통째로 실패합니다. 아래쪽 두 줄도 같은 이유로, 화면에 없는 요소를 걸러 냅니다.
다음은 트랩입니다.
function createFocusTrap(modal) {
function handleKeyDown(e) {
// 모달 안의 다른 코드(예: 탭 문자를 넣는 편집기)가 이미 처리한 키는 건드리지 않는다
if (e.key !== 'Tab' || e.defaultPrevented) return;
// 내용이 바뀌는 모달도 있으니 Tab을 누를 때마다 다시 구한다
const focusable = getFocusableElements(modal);
// 포커스할 게 하나도 없으면 모달 자체(tabindex="-1")에 붙잡아 둔다
if (focusable.length === 0) {
e.preventDefault();
modal.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
// 포커스가 모달 밖이거나 모달 상자 자체에 있으면 안쪽 끝으로 데려온다
if (!modal.contains(active) || active === modal) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
return;
}
if (e.shiftKey && active === first) {
e.preventDefault(); // Shift+Tab: 첫 요소에서 마지막으로
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault(); // Tab: 마지막 요소에서 처음으로
first.focus();
}
}
// 모달이 아니라 document에 건다. 포커스가 모달 밖에 있을 때도 Tab을 잡아야 하니까
document.addEventListener('keydown', handleKeyDown);
// 해제 함수를 돌려준다
return () => document.removeEventListener('keydown', handleKeyDown);
}리스너를 모달이 아니라 document에 거는 이유가 있습니다. 모달에만 걸면 포커스가 어쩌다 모달 밖(예: body)에 있을 때 Tab 키 이벤트가 모달에 도착하지 않아 트랩이 통째로 무시돼요.
Tab 순서와
tabindex, 포커스 표시 같은 기본기는 키보드 접근성 A to Z의 「포커스와 Tab 순서」에서 따로 다뤘어요.
포커스 복원: 닫으면 연 버튼으로 (직접 만들기 4)#
같은 복원 패턴을 SPA 라우팅에서도 씁니다. SPA에서 포커스 관리하기의 「포커스 복원 패턴」과 짝을 이루는 이야기예요.
지금까지 만든 함수를 엮는 클래스입니다. 닫으면 포커스는 모달을 열었던 그 버튼으로 돌아갑니다. 그래야 "내가 어디 있었는지"를 잃지 않아요.
class Modal {
constructor(modalEl) {
this.modal = modalEl;
this.opener = null; // 모달을 연 요소
this.removeFocusTrap = null;
this.isOpen = false;
}
open(triggerEl) {
if (this.isOpen) return; // 두 번 열리지 않게
// 연 요소를 기억한다. 사파리는 버튼을 클릭해도 포커스를 주지 않아서
// document.activeElement만 믿으면 body가 기억될 수 있다
this.opener = triggerEl || document.activeElement;
this.modal.hidden = false;
setInert(this.modal, true);
lockScroll();
this.removeFocusTrap = createFocusTrap(this.modal);
// 첫 번째 포커스 가능 요소로, 없으면 모달 자체로
const first = getFocusableElements(this.modal)[0];
(first || this.modal).focus();
this.isOpen = true;
}
// fallbackEl: 연 버튼이 닫는 사이 사라졌을 때 대신 포커스를 줄 곳
close(fallbackEl) {
if (!this.isOpen) return;
this.isOpen = false;
this.removeFocusTrap?.();
setInert(this.modal, false);
unlockScroll();
this.modal.hidden = true;
// 연 버튼으로 돌려보내 보고, 지워졌거나 숨겨져서 못 받으면 대신 갈 곳으로
this.opener?.focus();
if (document.activeElement !== this.opener) fallbackEl?.focus();
}
}순서에도 까닭이 있습니다. 열 때는 hidden을 먼저 풀어야 getFocusableElements가 모달 안의 버튼을 찾을 수 있어요(숨겨진 동안은 상자가 없어서 전부 걸러집니다). 닫을 때는 inert를 먼저 풀어야 배경에 있는 연 버튼에 포커스를 돌려줄 수 있습니다.
close(fallbackEl)는 <dialog> 예제의 마지막 if와 같은 일을 합니다. 삭제처럼 연 버튼이 사라지거나 숨겨질 수 있는 동작이라면 대신 갈 곳을 넘겨 주세요(그 요소에도 tabindex="-1"이 필요해요).
ESC로 닫기와 버튼 연결 (직접 만들기 5)#
마지막으로 버튼과 ESC를 연결합니다.
const customModal = new Modal(document.getElementById('custom-modal'));
const openCustomBtn = document.getElementById('open-custom-modal');
const listHeading = document.getElementById('custom-list-heading');
openCustomBtn.addEventListener('click',
() => customModal.open(openCustomBtn));
document.getElementById('custom-modal-cancel')
.addEventListener('click', () => customModal.close());
document.getElementById('custom-modal-confirm')
.addEventListener('click', () => {
// 실제 삭제 처리는 여기에(연 버튼이 사라질 수 있으니
// 대신 갈 곳을 넘긴다)
customModal.close(listHeading);
});
// ESC로 닫기. 열려 있을 때만, 그리고 모달 안의 목록 같은 게 먼저 쓰지 않았을 때만
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !e.defaultPrevented && customModal.isOpen) {
customModal.close();
}
});모달 안에 자동완성 목록이 있다고 해 볼게요. 사용자가 ESC로 그 목록만 닫으려 했는데 모달까지 닫히면 곤란하죠. !e.defaultPrevented 조건이 그걸 막아 줍니다(목록 쪽 코드가 preventDefault()를 부른다는 전제입니다).
ESC는 "취소"와 같은 뜻으로 두는 게 기본입니다. 확인 모달에서 ESC를 누르면 아무것도 지우지 않고 닫히는 거죠. 모달 안에 작성 중인 폼이 있어서 그냥 닫으면 내용이 날아간다면, ESC를 막지 말고 같은 모달 안에 "작성 중인 내용이 사라집니다" 같은 확인 문구를 띄워 한 번 더 묻는 쪽이 낫습니다.
직접 만들기 1의 마크업과 CSS에 스크롤 막기 함수와 직접 만들기 2~5의 자바스크립트를 붙이면 그대로 동작하는 모달이 됩니다. 이 조립본을 크롬 155에서 돌려 보니 Tab과 Shift+Tab이 모달 안 두 버튼만 돌았고, 포커스를 일부러 body로 빼도 다음 Tab에서 모달 안으로 돌아왔습니다. ESC로 닫으면 연 버튼으로 돌아가고 inert와 스크롤 잠금이 풀렸고, 2,359px 내려간 위치에서 열고 닫아도 그 위치로 돌아왔어요.
이 코드가 가정하는 것#
짧게 유지하려고 일부러 다루지 않은 경우가 있습니다. 해당된다면 <dialog>나 라이브러리를 쓰는 편이 안전해요.
- 모달은 한 번에 하나만 엽니다. 모달 위에 모달을 띄우면 두 번째 모달까지
inert로 잠깁니다(showModal()은 겹쳐 열어도 맨 위 하나만 살려 줍니다). - 모달이 열린 동안
<body>에 새로 붙는 요소(토스트 알림 등)는 잠기지 않습니다. - 라디오 버튼 묶음,
<fieldset disabled>안의 입력, 닫힌<details>안의 요소는getFocusableElements가 실제 Tab 순서와 다르게 셀 수 있습니다. 이런 게 모달 맨 앞에 있으면 첫 포커스가, 맨 끝에 있으면 순환이 어긋나니 모달을 열자마자, 그리고 끝 요소에서 Tab을 직접 눌러 확인해 보세요. - 모달 안의
<iframe>에 포커스가 들어가면 키 입력이 부모 문서로 오지 않아, 그 안에서는 트랩과 ESC가 동작하지 않습니다. - 안드로이드의 뒤로 가기 같은 닫기 제스처는
<dialog>만 받아 줍니다.div모달은 ESC 키만 처리합니다.
div 모달과 dialog 실측: Tab을 눌러 보면#
포커스 트랩은 설명을 읽는 것보다 Tab을 몇 번 눌러 보는 게 빠릅니다. 모달 접근성 비교 데모에 div로 아무 처리 없이 만든 모달과 <dialog>.showModal()로 만든 모달을 나란히 뒀어요. 모달을 열고 Tab을 계속 눌러 보세요. 지금 포커스가 모달 안인지 배경인지 화면 아래에 실시간으로 나옵니다.
데모의 div 모달은 이 글의 직접 만들기 1~5를 하나도 적용하지 않은 대조군입니다. 역할도, inert도, 트랩도, ESC도, 복원도 없는 상태가 키보드로 어떻게 보이는지 보여 주려고 일부러 그렇게 만들었어요. 그 데모를 크롬 155에서 키보드로 조작한 결과입니다(2026년 10월 11일). Playwright로 키 입력을 보내 다섯 번 쟀고, "페이지 밖"은 브라우저 창에서 직접 눌러 포커스가 페이지를 떠나는 것(blur)까지 확인했습니다.
| 동작 | div 모달(아무 처리 없음) | <dialog>.showModal() |
|---|---|---|
| 키보드로 연 직후 포커스 | 연 버튼에 그대로(배경) | 모달 안 "취소" |
| Tab 1회 | 오른쪽 패널의 "버튼 A"(배경) | 모달 안 "삭제" |
| Tab 2회 | 오른쪽 패널의 "버튼 B"(배경) | 페이지 밖(브라우저 UI) |
| Tab 3회 | "좋은 모달 열기"(배경) | 모달 안 "취소" |
| Tab 4~6회 | 모달 안 "취소" → 모달 안 "삭제" → 푸터 링크(배경) | 모달 안 버튼과 브라우저 UI 사이를 오감(배경에는 닿지 않음) |
| ESC | 열린 채 그대로 | 닫히고 연 버튼으로 포커스가 돌아옴 |
| "취소" 클릭으로 닫은 뒤 | 포커스가 body로 떨어짐 | 연 버튼(데모의 복원 코드가 돌려놓음) |
div 모달은 Tab을 세 번 누르는 동안 모달 안으로 들어가지 못했습니다. 화면 한가운데에 대화 상자가 떠 있는데 키보드는 그 뒤편을 돌고 있는 거예요. 모달 div가 HTML에서 본문(<main>) 뒤에 있어서, Tab이 본문의 버튼을 다 지나서야 모달에 닿습니다. 네 번째에야 들어갔다가 여섯 번째에 다시 푸터로 빠져나가요. 마우스를 쓰는 사람에게는 보이지 않는 사고입니다.
<dialog> 쪽은 배경 버튼에 한 번도 닿지 않았습니다. 첫 순환은 다섯 번 모두 "삭제 → 페이지 밖 → 취소"로 같았고, 두 번째 순환부터는 측정할 때마다 페이지 밖을 거치기도 하고 바로 "취소"로 돌아오기도 했어요. 자동화 도구로는 "페이지 밖"이 안정적으로 재현되지 않아서 4~6회는 한 칸으로 묶었습니다.
ESC로 닫았을 때 포커스가 연 버튼으로 돌아온 건 데모의 복원 코드가 아니라 브라우저가 한 일입니다. 데모는 "취소" 버튼을 누를 때만 연 버튼으로 포커스를 돌려주는데, ESC는 그 코드를 거치지 않거든요. 키보드로 열었으니 "열던 순간의 포커스"가 연 버튼이었고, 브라우저가 거기로 되돌린 겁니다. 사파리에서 마우스로 열었다면 결과가 달랐을 거예요.
이 표는 크롬 한 브라우저에서 잰 결과입니다. 사파리와 파이어폭스는 재지 않았고, 스크린 리더가 실제로 어떻게 읽는지도 이 표에는 없어요. 여러분 브라우저에서 데모를 직접 눌러 보는 게 가장 확실합니다.
애니메이션과 prefers-reduced-motion#
모달을 열 때 애니메이션을 넣는다면 prefers-reduced-motion도 함께 챙겨 주세요. 움직임 줄이기를 켠 사용자는 애니메이션을 원하지 않거나, 어지럼증 때문에 견디기 힘든 경우가 있습니다.
/* 열 때 애니메이션. div 모달이라면 아래 두 군데의
dialog[open]을 모두 #custom-modal:not([hidden])로 */
dialog[open] {
animation: slide-up 0.2s ease-out;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 움직임 줄이기를 켠 경우 */
@media (prefers-reduced-motion: reduce) {
dialog[open] {
animation: none;
}
}모달 접근성 체크리스트#
모달을 만들었다면 하나씩 확인해 보세요.
<dialog>를showModal()로 열었거나,role="dialog"(경고성 확인 창이면alertdialog)와aria-modal="true"를 붙였다aria-labelledby로 제목을 연결했다- 열리면 포커스가 모달 안으로 들어가고, 되돌릴 수 없는 작업이면 "취소" 쪽에 먼저 간다
- 배경이 실제로 막혀 있다:
<dialog>.showModal()이거나inert(aria-modal만으로는 통과가 아니다) - Tab이 배경으로 새지 않는다.
div모달은 모달 안에서 돈다 - 눈에 보이는 닫기(취소) 버튼이 있고, ESC로도 닫힌다
- 닫으면 열었던 요소로 포커스가 돌아오고, 그 요소가 사라졌으면 가까운 곳으로 간다
- 배경이 스크롤되지 않고, ESC로 닫아도 스크롤이 풀린다
- 긴 내용은 모달 안에서 스크롤된다
- 애니메이션이 있다면
prefers-reduced-motion에서 꺼진다
정리#
할 수 있으면 <dialog>와 showModal()을 쓰세요. 배경 막기, ESC, 포커스 이동과 복원을 브라우저가 해 주고, 남는 건 스크롤 막기와 연 버튼 기억 정도입니다. 직접 만들어야 한다면 inert로 배경을 막는 게 먼저이고, 포커스 트랩은 그 위에 얹는 순환입니다.
도입부의 네 가지 사고(배경으로 새는 Tab, 안 먹히는 ESC, 안내 없이 내용만 읽는 스크린 리더, 사라지는 포커스)는 이제 체크리스트의 몇 줄이 됐어요. 트랩은 이제 제자리에 걸렸습니다. 배경이 아니라 모달 안에요.
질문으로 다시 보기#
모달은 dialog 요소로 만드는 게 좋나요?
dialog로 만든 모달에서 Tab을 누르면 주소창으로 나가는데, 버그인가요?
div로 만든 모달에 inert와 포커스 트랩이 둘 다 필요한가요?
이 시리즈의 다른 글#
- SPA에서 포커스 관리하기: 모달의 포커스 복원과 같은 뿌리의 이야기
- 폼 실시간 검증 제대로 하기: 모달 안에 폼을 넣었다면 검증 타이밍도 함께
- 고대비 모드 제대로 지원하기: 모달 오버레이가 통째로 사라지는 그 모드
프론트엔드 × 접근성 시리즈 전체 보기: 일반 프론트엔드 주제에 접근성 시각을 연결하는 연재입니다.
참고 자료#
- The dialog element (HTML Living Standard)
- Allow modal dialogs to trap focus, avoiding tabbing to the URL bar (WHATWG 이슈 #8339)
- Dialog (Modal) Pattern (W3C ARIA Authoring Practices Guide)
- inert (MDN)
- button: Clicking and focus (MDN)
- WebKit 버그 229895: Clicking button drops focus entirely
- WebKit 버그 236322: Dialog's "previously focused element" and focus-visible
- WebKit 버그 273846: VoiceOver does not announce dialog on showModal when it uses aria-labelledby (iOS)
- WebKit 버그 302351: VoiceOver not interacting with dialog elements (macOS)
- WebKit 버그 317941: body with overflow: hidden is still scrollable on iOS sometimes
- focus-trap (GitHub)

댓글 남기기