이 글은 누구를 위한 것인가
- 모달과 팝오버에 무거운 JS 라이브러리를 쓰고 싶지 않은 팀
<dialog>의 접근성 자동 처리(포커스 트랩, ARIA)를 활용하려는 개발자- Popover API로 선언적인 팝오버를 만들고 싶은 웹 개발자
들어가며
모달 하나 만들려고 react-modal, headlessui 같은 라이브러리를 추가했다. HTML <dialog>와 Popover API는 이 필요를 네이티브로 해결한다. 포커스 트랩, Escape 키 닫기, ::backdrop까지 브라우저가 처리한다.
이 글은 bluefoxdev.kr의 HTML 네이티브 컴포넌트 가이드 를 참고하여 작성했습니다.
1. HTML dialog와 Popover API 비교
[<dialog> 엘리먼트]
- 모달과 비모달 다이얼로그 모두 지원
- showModal(): 모달 모드 (backdrop, 포커스 트랩, Escape 닫기)
- show(): 비모달 (독립 레이어에 표시)
- close(): 닫기
- 반환값: returnValue (form[method=dialog]과 연동)
- 자동 처리: 포커스 트랩, aria-modal, Escape 키
- 브라우저 지원: Chrome 37+, Firefox 98+, Safari 15.4+
[Popover API]
- HTML 속성만으로 팝오버 동작
- popover="auto": 외부 클릭으로 자동 닫힘
- popover="manual": 명시적으로만 닫힘
- Top Layer에 표시 (z-index 전쟁 없음)
- 트리거: popovertarget 속성
[언제 무엇을 쓸까]
중요한 결정 필요 (확인/취소): <dialog> showModal()
임시 정보 표시 (툴팁, 알림): Popover API
사이드 패널, 드로어: <dialog> show()
드롭다운 메뉴: Popover API + Anchor Positioning
2. 네이티브 컴포넌트 구현
<!-- 기본 <dialog> 모달 -->
<button id="open-btn">모달 열기</button>
<dialog id="confirm-dialog">
<h2>정말 삭제하시겠습니까?</h2>
<p>이 작업은 되돌릴 수 없습니다.</p>
<form method="dialog">
<!-- method="dialog"로 닫기 시 returnValue 설정 -->
<button value="cancel" autofocus>취소</button>
<button value="confirm" class="danger">삭제</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('confirm-dialog');
const openBtn = document.getElementById('open-btn');
openBtn.addEventListener('click', () => {
dialog.showModal(); // 모달 모드로 열기
});
dialog.addEventListener('close', () => {
// returnValue: 클릭한 버튼의 value 속성
if (dialog.returnValue === 'confirm') {
console.log('삭제 확인됨');
deleteItem();
}
});
// Escape 키 처리는 브라우저가 자동으로 함
// 포커스 트랩도 자동 (모달 내부에서만 Tab 이동)
</script>
/* dialog 스타일링 */
dialog {
border: none;
border-radius: 12px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 480px;
width: 90%;
/* 열림 애니메이션 */
animation: dialog-show 0.2s ease-out;
}
@keyframes dialog-show {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 배경 오버레이 */
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: backdrop-show 0.2s ease-out;
}
@keyframes backdrop-show {
from { opacity: 0; }
to { opacity: 1; }
}
/* 닫힘 애니메이션 (CSS만으로는 어려움 - JS 필요) */
dialog[data-closing] {
animation: dialog-hide 0.15s ease-in forwards;
}
dialog[data-closing]::backdrop {
animation: backdrop-hide 0.15s ease-in forwards;
}
@keyframes dialog-hide {
to { opacity: 0; transform: scale(0.95); }
}
@keyframes backdrop-hide {
to { opacity: 0; }
}
// 닫힘 애니메이션 추가 (JS)
function closeWithAnimation(dialog) {
dialog.setAttribute('data-closing', '');
dialog.addEventListener('animationend', () => {
dialog.removeAttribute('data-closing');
dialog.close();
}, { once: true });
}
// React 래퍼
import { useRef, useEffect } from 'react';
function Dialog({ open, onClose, title, children }) {
const ref = useRef(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open) {
dialog.showModal();
} else {
// 닫힘 애니메이션
dialog.setAttribute('data-closing', '');
const handleEnd = () => {
dialog.removeAttribute('data-closing');
dialog.close();
};
dialog.addEventListener('animationend', handleEnd, { once: true });
}
}, [open]);
useEffect(() => {
const dialog = ref.current;
const handleClose = () => onClose?.();
dialog?.addEventListener('close', handleClose);
return () => dialog?.removeEventListener('close', handleClose);
}, [onClose]);
return (
<dialog ref={ref}>
<h2>{title}</h2>
{children}
<form method="dialog">
<button>닫기</button>
</form>
</dialog>
);
}
<!-- Popover API 예시 -->
<!-- 1. 기본 팝오버 (외부 클릭 자동 닫힘) -->
<button popovertarget="my-popover">
도움말
</button>
<div id="my-popover" popover>
<p>이 기능은 결제 후 이용 가능합니다.</p>
</div>
<!-- 2. manual 팝오버 (명시적으로만 닫힘) -->
<button popovertarget="notification" popovertargetaction="show">
알림 보기
</button>
<button popovertarget="notification" popovertargetaction="hide">
알림 닫기
</button>
<div id="notification" popover="manual" class="notification-panel">
<p>새 메시지 3개</p>
</div>
<!-- 3. popover 이벤트 -->
<script>
const popover = document.getElementById('my-popover');
popover.addEventListener('toggle', (event) => {
if (event.newState === 'open') {
console.log('팝오버 열림');
loadContent();
} else {
console.log('팝오버 닫힘');
}
});
</script>
/* Popover 스타일링 */
[popover] {
/* Top Layer에 표시 - z-index 불필요 */
margin: 0;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
/* 기본적으로 숨겨짐 */
display: none;
}
[popover]:popover-open {
display: block;
/* 열림 애니메이션 */
animation: popover-show 0.15s ease-out;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}
[popover]:not(:popover-open) {
/* 닫힘 전환 (display: none 전) */
animation: popover-hide 0.1s ease-in forwards;
display: block; /* 애니메이션 동안 표시 유지 */
}
@keyframes popover-show {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes popover-hide {
to { opacity: 0; transform: translateY(-4px); }
}
마무리
<dialog>의 showModal()은 포커스 트랩, Escape 닫기, aria-modal 설정을 자동으로 처리해서 접근성 높은 모달을 몇 줄로 구현할 수 있다. Popover API는 선언적 HTML만으로 팝오버 열기/닫기를 처리하고, Top Layer 덕분에 z-index 전쟁이 없다. 두 API 모두 2026년 현재 모든 주요 브라우저에서 지원되므로 폴리필 없이 사용 가능하다.